Add project files.

This commit is contained in:
Grae Jones
2026-02-03 15:04:37 -08:00
parent a4838b594d
commit 8e7e03702e
65 changed files with 6227 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
namespace GoogleApi.Configuration;
/// <summary>
/// Root configuration for Google Ads API integration.
/// Bind to the "GoogleAds" section in appsettings.json or environment variables.
/// </summary>
public sealed class GoogleAdsConfig
{
public const string SectionName = "GoogleAds";
/// <summary>
/// Enable/disable real API calls. When false, the provider returns emulated responses.
/// </summary>
public bool EnableRealApi { get; set; } = false;
/// <summary>
/// Target Google Ads API version used by generated stubs (e.g. "v22").
/// NOTE: This value is informational; the compiled code targets a specific Vxx namespace.
/// </summary>
public string ApiVersion { get; set; } = "v22";
/// <summary>
/// Developer token from your Google Ads manager account.
/// </summary>
public string DeveloperToken { get; set; } = string.Empty;
/// <summary>
/// OAuth 2.0 application credentials used for server-to-server calls.
///
/// IMPORTANT:
/// - There is no interactive OAuth flow at runtime.
/// - A refresh token is generated once (out-of-band) and stored securely.
/// - This service uses that refresh token to obtain access tokens automatically.
/// </summary>
public GoogleOAuthConfig OAuth { get; set; } = new();
/// <summary>
/// Default login customer ID (manager account / MCC) if not specified per request.
/// Format: 1234567890 (no dashes)
/// </summary>
public string? DefaultLoginCustomerId { get; set; }
/// <summary>
/// Request timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 60;
}
/// <summary>
/// OAuth configuration for Google Ads API.
/// This provider uses the "refresh token" (offline) flow for non-interactive server-to-server calls.
/// </summary>
public sealed class GoogleOAuthConfig
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
/// <summary>
/// Platform refresh token used to obtain access tokens without user interaction.
/// Store in Key Vault / secret store; inject via environment variables in prod.
/// </summary>
public string? RefreshToken { get; set; }
}
/// <summary>
/// Per-request Google Ads context, populated from request and/or database.
/// </summary>
public sealed class GoogleAdsContext
{
/// <summary>
/// Target Google Ads customer ID for this request.
/// Format: 1234567890 (no dashes)
/// </summary>
public required string CustomerId { get; set; }
/// <summary>
/// Login customer ID (manager account / MCC).
/// Required when accessing client accounts under a manager account.
/// </summary>
public string? LoginCustomerId { get; set; }
/// <summary>
/// Optional override refresh token for a specific account (if you ever store per-account tokens).
/// If null, the platform token from config is used.
/// </summary>
public string? RefreshToken { get; set; }
}

View File

@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Mvc;
using GoogleApi.Models;
using GoogleApi.Security;
using GoogleApi.Services;
namespace GoogleApi.Controllers;
/// <summary>
/// Internal API endpoint called by Gateway.
/// Protected by X-Internal-Key header validation.
/// </summary>
[ApiController]
[Route("internal")]
public sealed class InternalController : ControllerBase
{
private readonly GoogleAdsService _googleAds;
private readonly ILogger<InternalController> _logger;
public InternalController(GoogleAdsService googleAds, ILogger<InternalController> logger)
{
_googleAds = googleAds;
_logger = logger;
}
/// <summary>
/// Health check - no auth required.
/// </summary>
[HttpGet("health")]
public IActionResult Health()
{
_logger.LogDebug("[InternalController] Health check");
return Ok(new
{
ok = true,
service = "GoogleApi",
timestamp = DateTimeOffset.UtcNow
});
}
/// <summary>
/// Main execution endpoint - Gateway calls this.
/// Protected by InternalAuthFilter.
/// </summary>
[ServiceFilter(typeof(InternalAuthFilter))]
[HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] ProviderRequest request, CancellationToken ct)
{
_logger.LogInformation(
"[InternalController] Execute called | Operation={Operation} RequestId={RequestId}",
request?.Operation, request?.RequestId);
if (request == null)
{
return BadRequest(ProviderResponse.Fail(null, "VALIDATION", "Request body is required"));
}
if (string.IsNullOrWhiteSpace(request.Operation))
{
return BadRequest(ProviderResponse.Fail(request.RequestId, "VALIDATION", "Operation is required"));
}
var result = await _googleAds.ExecuteAsync(request, ct);
if (result.Ok)
{
return Ok(result);
}
else
{
// Use appropriate status codes based on error
var statusCode = result.Error?.Code switch
{
"VALIDATION" => 400,
"NOT_FOUND" => 404,
"UNAUTHORIZED" => 401,
"FORBIDDEN" => 403,
_ => 400
};
return StatusCode(statusCode, result);
}
}
}

View File

@@ -0,0 +1,259 @@
# Google Ads API Configuration Guide
## Overview
This document describes how to configure the GoogleApi service to connect to the real Google Ads API. The service supports both **emulated mode** (for testing without Google credentials) and **real API mode**.
## Configuration Levels
| Level | Storage | Examples |
|-------|---------|----------|
| **Platform secrets** | Azure Key Vault | Developer token, OAuth client secret |
| **Platform config** | App Settings / appsettings.json | API version, timeouts |
| **Per-account credentials** | Database (tbGoogleCredential) | Refresh tokens per linked account |
## Quick Start (Test Account)
1. Create a Google Ads test manager account
2. Get a developer token (works immediately for test accounts)
3. Set up OAuth credentials in Google Cloud Console
4. Configure the environment variables below
## Environment Variables for Azure Container Apps
> **Note:** This service runs **server-to-server**. There is **no interactive OAuth UI** at runtime.
> Generate the refresh token once (out-of-band) and store it securely (Key Vault / secrets).
### GoogleApi Service
```bash
# ==========================================
# Core Settings
# ==========================================
# Enable real Google Ads API calls (default: false)
GoogleAds__EnableRealApi=true
# API version (default: v22)
GoogleAds__ApiVersion=v22
# ==========================================
# Authentication - Developer Token
# Required for all API calls
# ==========================================
# Your developer token from Google Ads API Center
# Format: 22-character alphanumeric string
# Get from: https://ads.google.com/aw/apicenter
GoogleAds__DeveloperToken=YOUR_DEVELOPER_TOKEN_HERE
# ==========================================
# Authentication - OAuth 2.0
# Required for authenticating API requests
# ==========================================
# OAuth Client ID from Google Cloud Console
GoogleAds__OAuth__ClientId=YOUR_CLIENT_ID.apps.googleusercontent.com
# OAuth Client Secret from Google Cloud Console
# SENSITIVE - Use Key Vault reference in production
GoogleAds__OAuth__ClientSecret=YOUR_CLIENT_SECRET
# Refresh token for platform-level access
# Generated via OAuth flow or gcloud CLI
# SENSITIVE - Use Key Vault reference in production
GoogleAds__OAuth__RefreshToken=YOUR_REFRESH_TOKEN
# ==========================================
# Manager Account (Optional)
# Required if accessing client accounts under a manager
# ==========================================
# Default login customer ID (manager account)
# Format: 1234567890 (no dashes)
GoogleAds__DefaultLoginCustomerId=1234567890
# ==========================================
# Internal Authentication
# For Gateway -> GoogleApi communication
# ==========================================
# Shared secret for internal API authentication
# SENSITIVE - Use Key Vault reference
GOOGLE_INTERNAL_KEY=your-secure-internal-key
# ==========================================
# Optional Settings
# ==========================================
# HTTP timeout in seconds (default: 60)
GoogleAds__TimeoutSeconds=60
# Max retry attempts (default: 3)
GoogleAds__MaxRetries=3
```
### Azure Key Vault References
For sensitive values, use Key Vault references in Azure Container Apps:
```bash
# Instead of plain values:
GoogleAds__DeveloperToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsDeveloperToken/)
GoogleAds__OAuth__ClientSecret=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsClientSecret/)
GoogleAds__OAuth__RefreshToken=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleAdsRefreshToken/)
GOOGLE_INTERNAL_KEY=@Microsoft.KeyVault(SecretUri=https://your-vault.vault.azure.net/secrets/GoogleInternalKey/)
```
## Step-by-Step Setup
### 1. Create Google Ads Manager Account
1. Go to https://ads.google.com/aw/apicenter
2. Sign in with a Google account NOT linked to production ads
3. Create a new manager account
4. For test accounts, click "Create a test manager account" link
### 2. Get Developer Token
1. In your manager account, go to **Tools & Settings > API Center**
2. Your developer token will be displayed
3. For test accounts: Token works immediately
4. For production: Apply for Basic Access (takes a few days)
### 3. Create Google Cloud Project
1. Go to https://console.cloud.google.com
2. Create a new project (or use existing)
3. Enable the **Google Ads API**:
- Go to APIs & Services > Library
- Search "Google Ads API"
- Click Enable
### 4. Create OAuth Credentials
1. Go to APIs & Services > Credentials
2. Click **Create Credentials > OAuth client ID**
3. Application type: **Desktop app** (for initial testing)
4. Download the JSON file
5. Note the Client ID and Client Secret
### 5. Generate Refresh Token
Option A: Using gcloud CLI
```bash
# Install gcloud CLI if not installed
gcloud auth login --cred-file=path/to/client_secret.json
gcloud auth print-access-token \
--scopes='https://www.googleapis.com/auth/adwords'
```
Option B: Using OAuth Playground
1. Go to https://developers.google.com/oauthplayground/
2. Click gear icon > Use your own credentials
3. Enter your Client ID and Secret
4. Select Google Ads API scope: `https://www.googleapis.com/auth/adwords`
5. Click Authorize APIs, sign in
6. Click "Exchange authorization code for tokens"
7. Copy the Refresh Token
### 6. Create Test Client Account
1. In your test manager account
2. Click Accounts > + > Create new account
3. This creates a test client account under your manager
4. Note the Customer ID (format: XXX-XXX-XXXX)
### 7. Configure Azure Container App
In Azure Portal > Container Apps > Your GoogleApi App > Settings > Environment Variables:
Add each variable from the list above, using Key Vault references for sensitive values.
## Testing the Configuration
### Check Health Endpoint
```bash
curl https://your-googleapi-url/health
```
Expected response:
```json
{
"service": "GoogleApi",
"status": "healthy",
"config": {
"realApiEnabled": true,
"apiVersion": "v18",
"developerTokenSet": true,
"oauthConfigured": true,
"defaultLoginCustomerId": "1234567890"
}
}
```
### Test API Call (via Gateway)
```bash
curl -X POST https://your-gateway-url/api/execution/request \
-H "Content-Type: application/json" \
-H "X-Dev-ClientId: test-client" \
-H "X-Dev-TenantId: 1234567890" \
-d '{
"operation": "ListAccessibleCustomers",
"payload": {}
}'
```
## Credential Flow Diagram
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Gateway │────▶│ GoogleApi │────▶│ Google Ads │
└─────────────┘ └─────────────┘ │ API │
│ └─────────────┘
┌──────┴──────┐
│ │
┌─────▼─────┐ ┌────▼────┐
│ Config │ │ Database │
│(env vars) │ │(per-acct)│
└───────────┘ └──────────┘
Config provides: Database provides:
- Developer Token - Per-account refresh tokens
- OAuth Client ID/Secret - Account-specific credentials
- Default refresh token - Linked customer IDs
```
## Troubleshooting
### "UNAUTHENTICATED" Error
- Check developer token is correct
- Verify OAuth credentials
- Ensure refresh token hasn't expired
### "PERMISSION_DENIED" Error
- Developer token may not be approved for production
- Verify account access permissions
- Check login-customer-id is correct
### "INVALID_CUSTOMER_ID" Error
- Customer ID format should be 10 digits, no dashes
- Verify account exists and is accessible
### Token Exchange Fails
- Client ID/Secret mismatch
- Refresh token was revoked
- OAuth consent was withdrawn
## Security Best Practices
1. **Never commit secrets** to source control
2. **Use Azure Key Vault** for all sensitive values
3. **Rotate refresh tokens** periodically
4. **Audit API access** via tbAdpApiLog
5. **Limit developer token access** - one token per application
6. **Use test accounts** for development and testing

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Container Settings -->
<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:8.0</ContainerBaseImage>
<ContainerRepository>googleapi</ContainerRepository>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Google.Ads.GoogleAds" Version="24.1.1" />
</ItemGroup>
<ItemGroup>
<ContainerPort Include="8080" Type="tcp" />
</ItemGroup>
</Project>

6
GoogleApi/GoogleApi.http Normal file
View File

@@ -0,0 +1,6 @@
@GoogleApi_HostAddress = http://localhost:5023
GET {{GoogleApi_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,87 @@
using System.Text.Json.Serialization;
namespace GoogleApi.Models;
#region Campaign Payloads
public sealed class CreateCampaignPayload
{
public string Name { get; set; } = string.Empty;
public CampaignType Type { get; set; } = CampaignType.Search;
public long BudgetMicros { get; set; }
public BiddingStrategy BiddingStrategy { get; set; } = BiddingStrategy.MaximizeClicks;
public string? StartDate { get; set; }
public string? EndDate { get; set; }
}
public sealed class GetCampaignPayload
{
public string CampaignId { get; set; } = string.Empty;
}
public sealed class UpdateCampaignPayload
{
public string CampaignId { get; set; } = string.Empty;
public string? Name { get; set; }
public long? BudgetMicros { get; set; }
public CampaignStatus? Status { get; set; }
}
public sealed class ListCampaignsPayload
{
public CampaignStatus? StatusFilter { get; set; }
public int PageSize { get; set; } = 50;
public string? PageToken { get; set; }
}
#endregion
#region Reporting Payloads
public sealed class CampaignStatsPayload
{
public string CampaignId { get; set; } = string.Empty;
public string? StartDate { get; set; }
public string? EndDate { get; set; }
}
public sealed class AccountStatsPayload
{
public string? StartDate { get; set; }
public string? EndDate { get; set; }
}
#endregion
#region Enums
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CampaignStatus
{
Unknown = 0,
Enabled = 1,
Paused = 2,
Removed = 3
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CampaignType
{
Search = 0,
Display = 1,
Shopping = 2,
Video = 3,
PerformanceMax = 4
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum BiddingStrategy
{
ManualCpc = 0,
MaximizeClicks = 1,
MaximizeConversions = 2,
TargetCpa = 3,
TargetRoas = 4
}
#endregion

View File

@@ -0,0 +1,93 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GoogleApi.Models;
/// <summary>
/// Request from Gateway to GoogleApi.
/// </summary>
public sealed class ProviderRequest
{
/// <summary>
/// Operation to execute (e.g., "Ping", "CreateCampaign", "GetCampaignStats")
/// </summary>
public string Operation { get; set; } = string.Empty;
/// <summary>
/// Tenant/customer ID - maps to Google Ads customer ID (the subaccount)
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Login customer ID - maps to Google Ads manager account (MCC)
/// Used in agency model where manager account accesses client subaccounts.
/// Populated by Gateway from tbAdAccount.accLoginAccountId.
/// </summary>
public string? LoginCustomerId { get; set; }
/// <summary>
/// Correlation ID for request tracing
/// </summary>
public string? RequestId { get; set; }
/// <summary>
/// Operation-specific payload
/// </summary>
public JsonElement? Payload { get; set; }
/// <summary>
/// Deserialize payload to strongly-typed object
/// </summary>
public T GetPayload<T>() where T : new()
{
if (Payload == null || Payload.Value.ValueKind == JsonValueKind.Null || Payload.Value.ValueKind == JsonValueKind.Undefined)
return new T();
try
{
return JsonSerializer.Deserialize<T>(Payload.Value.GetRawText(), JsonOptions.Default) ?? new T();
}
catch
{
return new T();
}
}
}
/// <summary>
/// Response from GoogleApi to Gateway.
/// </summary>
public sealed class ProviderResponse
{
public bool Ok { get; set; }
public string? RequestId { get; set; }
public object? Data { get; set; }
public ProviderError? Error { get; set; }
public static ProviderResponse Success(string? requestId, object? data = null)
=> new() { Ok = true, RequestId = requestId, Data = data };
public static ProviderResponse Fail(string? requestId, string code, string message, object? detail = null)
=> new()
{
Ok = false,
RequestId = requestId,
Error = new ProviderError { Code = code, Message = message, Detail = detail }
};
}
public sealed class ProviderError
{
public string Code { get; set; } = "ERROR";
public string Message { get; set; } = "Unknown error";
public object? Detail { get; set; }
}
internal static class JsonOptions
{
public static readonly JsonSerializerOptions Default = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}

101
GoogleApi/Program.cs Normal file
View File

@@ -0,0 +1,101 @@
using GoogleApi.Configuration;
using GoogleApi.Security;
using GoogleApi.Services;
var builder = WebApplication.CreateBuilder(args);
// ============================================================
// CRITICAL: Explicit port binding for Azure Container Apps
// ============================================================
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
// ============================================================
// Configuration
// ============================================================
// Bind GoogleAds configuration section
// Values can be overridden by environment variables:
// GoogleAds__EnableRealApi=true
// GoogleAds__DeveloperToken=xxx
// GoogleAds__OAuth__ClientId=xxx
// etc.
builder.Services.Configure<GoogleAdsConfig>(
builder.Configuration.GetSection(GoogleAdsConfig.SectionName));
// Log startup info
var googleConfig = builder.Configuration.GetSection(GoogleAdsConfig.SectionName).Get<GoogleAdsConfig>();
Console.WriteLine("===========================================");
Console.WriteLine($"[GoogleApi] Starting...");
Console.WriteLine($"[GoogleApi] Port: {port}");
Console.WriteLine($"[GoogleApi] Environment: {builder.Environment.EnvironmentName}");
Console.WriteLine($"[GoogleApi] GOOGLE_INTERNAL_KEY set: {!string.IsNullOrEmpty(builder.Configuration["InternalKey"] ?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY"))}");
Console.WriteLine($"[GoogleApi] Real API Enabled: {googleConfig?.EnableRealApi ?? false}");
Console.WriteLine($"[GoogleApi] API Version: {googleConfig?.ApiVersion ?? "not configured"}");
Console.WriteLine($"[GoogleApi] Developer Token Set: {!string.IsNullOrEmpty(googleConfig?.DeveloperToken)}");
Console.WriteLine("===========================================");
// ============================================================
// Services
// ============================================================
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "GoogleApi Provider", Version = "v1" });
});
// Core services
builder.Services.AddSingleton<GoogleAdsClientFactory>();
builder.Services.AddSingleton<GoogleAdsService>();
// Auth filter for internal calls from Gateway
builder.Services.AddScoped<InternalAuthFilter>();
// ============================================================
// Build & Configure
// ============================================================
var app = builder.Build();
Console.WriteLine("[GoogleApi] App built, configuring pipeline...");
// Always enable Swagger (helpful for debugging)
app.UseSwagger();
app.UseSwaggerUI();
app.UseRouting();
app.MapControllers();
// Root health check
app.MapGet("/", () => Results.Ok(new
{
service = "GoogleApi",
status = "healthy",
timestamp = DateTimeOffset.UtcNow
}));
// Detailed health check with config status
app.MapGet("/health", (IConfiguration config) =>
{
var googleConfig = config.GetSection(GoogleAdsConfig.SectionName).Get<GoogleAdsConfig>();
return Results.Ok(new
{
service = "GoogleApi",
status = "healthy",
timestamp = DateTimeOffset.UtcNow,
config = new
{
realApiEnabled = googleConfig?.EnableRealApi ?? false,
apiVersion = googleConfig?.ApiVersion ?? "not configured",
developerTokenSet = !string.IsNullOrEmpty(googleConfig?.DeveloperToken),
oauthConfigured = !string.IsNullOrEmpty(googleConfig?.OAuth?.ClientId),
defaultLoginCustomerId = googleConfig?.DefaultLoginCustomerId ?? "(not set)"
}
});
});
Console.WriteLine("[GoogleApi] Pipeline configured, starting listener...");
Console.WriteLine($"[GoogleApi] Listening on http://0.0.0.0:{port}");
app.Run();

View File

@@ -0,0 +1,26 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"GOOGLE_INTERNAL_KEY": "dev-test-key-12345"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5180"
},
"Container (.NET SDK)": {
"commandName": "SdkContainer",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"environmentVariables": {
"ASPNETCORE_HTTP_PORTS": "8080",
"GOOGLE_INTERNAL_KEY": "dev-test-key-12345"
},
"publishAllPorts": true
}
}
}

180
GoogleApi/README.md Normal file
View File

@@ -0,0 +1,180 @@
# GoogleApi Provider
Internal microservice that handles Google Ads API operations. Called by the Gateway via HTTP.
## Architecture
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Client │ ──────► │ Gateway │ ──────► │ GoogleApi │
│ │ │ (external) │ │ (internal) │
└──────────────┘ └──────────────┘ └──────────────────┘
│ │
│ X-Internal-Key │
│ header auth │
▼ ▼
POST /api/execute POST /internal/execute
```
## Local Development
```bash
# Run locally
cd GoogleApi
dotnet run
# Test health
curl http://localhost:5180/internal/health
# Test execute (with auth header)
curl -X POST http://localhost:5180/internal/execute \
-H "Content-Type: application/json" \
-H "X-Internal-Key: dev-test-key-12345" \
-d '{"operation": "Ping", "requestId": "test-123"}'
# Test create campaign
curl -X POST http://localhost:5180/internal/execute \
-H "Content-Type: application/json" \
-H "X-Internal-Key: dev-test-key-12345" \
-d '{
"operation": "CreateCampaign",
"tenantId": "1234567890",
"requestId": "test-456",
"payload": {
"name": "Test Campaign",
"budgetMicros": 10000000,
"type": "Search"
}
}'
```
## Supported Operations
| Operation | Description | Payload |
|-----------|-------------|---------|
| `Ping` | Health check | none |
| `CreateCampaign` | Create a campaign | `name`, `budgetMicros`, `type`, `biddingStrategy` |
| `GetCampaign` | Get campaign details | `campaignId` |
| `UpdateCampaign` | Update campaign | `campaignId`, `name?`, `budgetMicros?`, `status?` |
| `ListCampaigns` | List all campaigns | `statusFilter?`, `pageSize?`, `pageToken?` |
| `GetCampaignStats` | Get campaign metrics | `campaignId`, `startDate?`, `endDate?` |
| `GetAccountStats` | Get account metrics | `startDate?`, `endDate?` |
## Azure Deployment
### First-time setup
```bash
# Create the container app (internal ingress)
az containerapp create \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--environment AdPlatform-env-20260114160411 \
--image mcr.microsoft.com/dotnet/samples:aspnetapp \
--target-port 8080 \
--ingress internal \
--min-replicas 1 \
--max-replicas 3
# Set up managed identity for ACR (do this once)
az role assignment create \
--assignee $(az containerapp show -n usim-adp-googleapi -g RG-GraeJones --query identity.principalId -o tsv) \
--role AcrPull \
--scope /subscriptions/ad4c8963-6467-4ccf-bdf6-208a73b0a2af/resourceGroups/RG-GraeJones/providers/Microsoft.ContainerRegistry/registries/adplatform20260114160834
# Configure registry with managed identity
az containerapp registry set \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--server adplatform20260114160834.azurecr.io \
--identity system
# Set the internal key secret
az containerapp secret set \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--secrets google-internal-key="your-secret-key-here"
# Set environment variables
az containerapp update \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--set-env-vars "GOOGLE_INTERNAL_KEY=secretref:google-internal-key"
```
### Publish from Visual Studio
1. Right-click project → Publish
2. Select the `usim-adp-googleapi` profile
3. Click Publish
### Publish from CLI
```bash
# Build and push to ACR
dotnet publish -c Release
# Or manually
az acr build --registry adplatform20260114160834 --image googleapi:$(date +%Y%m%d%H%M%S) .
# Update container app
az containerapp update \
--name usim-adp-googleapi \
--resource-group RG-GraeJones \
--image adplatform20260114160834.azurecr.io/googleapi:<tag>
```
### Verify deployment
```bash
# Check revision status
az containerapp revision list -n usim-adp-googleapi -g RG-GraeJones -o table
# Check logs
az containerapp logs show -n usim-adp-googleapi -g RG-GraeJones --type console
az containerapp logs show -n usim-adp-googleapi -g RG-GraeJones --type system
# Check env vars
az containerapp show -n usim-adp-googleapi -g RG-GraeJones --query "properties.template.containers[0].env"
```
## Gateway Configuration
Update Gateway's environment variables to point to GoogleApi:
```bash
az containerapp update \
--name usim-adp-gateway \
--resource-group RG-GraeJones \
--set-env-vars "GOOGLE_PROVIDER_URL=https://usim-adp-googleapi.internal.lemonbeach-1e8e273b.westus.azurecontainerapps.io"
```
## Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `PORT` | HTTP listen port | No (default: 8080) |
| `GOOGLE_INTERNAL_KEY` | Shared secret for Gateway auth | Yes |
| `ASPNETCORE_ENVIRONMENT` | Runtime environment | No |
## Troubleshooting
### Container stuck in "Activating"
1. Check system logs for image pull errors
2. Verify ACR credentials/managed identity
3. Verify image exists: `az acr repository show-tags --name adplatform20260114160834 --repository googleapi`
### No console output
Check that `Program.cs` has explicit port binding:
```csharp
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
```
### Auth failures
1. Verify `GOOGLE_INTERNAL_KEY` is set in both Gateway and GoogleApi
2. Check the secret reference is correct: `secretref:google-internal-key`
3. Test with curl using the `-H "X-Internal-Key: ..."` header

View File

@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace GoogleApi.Security;
/// <summary>
/// Validates the X-Internal-Key header for internal service-to-service calls.
/// Gateway must provide the correct key to call GoogleApi endpoints.
/// </summary>
public sealed class InternalAuthFilter : IAsyncActionFilter
{
private readonly IConfiguration _config;
private readonly ILogger<InternalAuthFilter> _logger;
public InternalAuthFilter(IConfiguration config, ILogger<InternalAuthFilter> logger)
{
_config = config;
_logger = logger;
}
public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var headerName = _config["InternalAuth:HeaderName"] ?? "X-Internal-Key";
// Try multiple sources for the key
var expectedKey = _config["InternalAuth:Key"]
?? _config["GOOGLE_INTERNAL_KEY"]
?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY");
if (string.IsNullOrWhiteSpace(expectedKey))
{
_logger.LogError("[InternalAuth] No internal key configured - check GOOGLE_INTERNAL_KEY env var");
context.Result = new ObjectResult(new { error = "Internal auth key not configured" })
{
StatusCode = 500
};
return Task.CompletedTask;
}
if (!context.HttpContext.Request.Headers.TryGetValue(headerName, out var providedKey) ||
string.IsNullOrWhiteSpace(providedKey))
{
_logger.LogWarning("[InternalAuth] Missing {HeaderName} header", headerName);
context.Result = new UnauthorizedObjectResult(new { error = $"Missing {headerName} header" });
return Task.CompletedTask;
}
if (!string.Equals(providedKey.ToString(), expectedKey, StringComparison.Ordinal))
{
_logger.LogWarning("[InternalAuth] Invalid key provided");
context.Result = new UnauthorizedObjectResult(new { error = "Invalid internal auth key" });
return Task.CompletedTask;
}
_logger.LogDebug("[InternalAuth] Request authorized");
return next();
}
}

View File

@@ -0,0 +1,71 @@
using Google.Ads.Gax.Config;
using Google.Ads.GoogleAds.Config;
using Google.Ads.GoogleAds.Lib;
using GoogleApi.Configuration;
using Microsoft.Extensions.Options;
namespace GoogleApi.Services;
// ✅ Alias the Google library config type to avoid collision with your GoogleApi.Configuration.GoogleAdsConfig
using LibGoogleAdsConfig = Google.Ads.GoogleAds.Config.GoogleAdsConfig;
public sealed class GoogleAdsClientFactory
{
private readonly GoogleApi.Configuration.GoogleAdsConfig _cfg;
private readonly ILogger<GoogleAdsClientFactory> _logger;
public GoogleAdsClientFactory(
IOptions<GoogleApi.Configuration.GoogleAdsConfig> config,
ILogger<GoogleAdsClientFactory> logger)
{
_cfg = config.Value;
_logger = logger;
}
public bool IsRealApiEnabled =>
_cfg.EnableRealApi &&
!string.IsNullOrWhiteSpace(_cfg.DeveloperToken) &&
!string.IsNullOrWhiteSpace(_cfg.OAuth.ClientId) &&
!string.IsNullOrWhiteSpace(_cfg.OAuth.ClientSecret) &&
!string.IsNullOrWhiteSpace(_cfg.OAuth.RefreshToken);
public GoogleAdsClient CreateClient(GoogleAdsContext context)
{
var loginCustomerId = NormalizeCustomerId(
context.LoginCustomerId ?? _cfg.DefaultLoginCustomerId ?? string.Empty);
var libConfig = new LibGoogleAdsConfig
{
DeveloperToken = _cfg.DeveloperToken,
// ✅ Headless/server-to-server refresh-token flow
OAuth2Mode = OAuth2Flow.APPLICATION,
OAuth2ClientId = _cfg.OAuth.ClientId,
OAuth2ClientSecret = _cfg.OAuth.ClientSecret,
OAuth2RefreshToken = context.RefreshToken ?? _cfg.OAuth.RefreshToken,
// MCC/manager header
LoginCustomerId = string.IsNullOrWhiteSpace(loginCustomerId) ? null : loginCustomerId,
// ms
Timeout = Math.Max(1, _cfg.TimeoutSeconds) * 1000
};
_logger.LogDebug(
"[GoogleAds] CreateClient | RealApi={RealApi} LoginCustomerIdSet={LoginSet}",
IsRealApiEnabled,
!string.IsNullOrWhiteSpace(libConfig.LoginCustomerId));
return new GoogleAdsClient(libConfig);
}
public static string NormalizeCustomerId(string customerId)
=> (customerId ?? string.Empty).Replace("-", string.Empty).Trim();
public static string FormatCustomerId(string customerId)
{
var normalized = NormalizeCustomerId(customerId);
if (normalized.Length != 10) return normalized;
return $"{normalized[..3]}-{normalized[3..6]}-{normalized[6..]}";
}
}

View File

@@ -0,0 +1,576 @@
using GoogleApi.Configuration;
using GoogleApi.Models;
using Microsoft.Extensions.Options;
using Google.Ads.GoogleAds;
using Google.Ads.GoogleAds.Lib;
using Google.Ads.GoogleAds.V22.Common;
using Google.Ads.GoogleAds.V22.Enums;
using Google.Ads.GoogleAds.V22.Errors;
using Google.Ads.GoogleAds.V22.Resources;
using Google.Ads.GoogleAds.V22.Services;
namespace GoogleApi.Services;
// ✅ IMPORTANT: force "Services" to mean Google.Ads.GoogleAds.Services (not GoogleApi.Services)
using GAdsServices = global::Google.Ads.GoogleAds.Services;
// ✅ Avoid name collision with Google.Ads.GoogleAds.V22.Resources.BiddingStrategy
using ModelBiddingStrategy = GoogleApi.Models.BiddingStrategy;
public sealed class GoogleAdsService
{
private readonly GoogleAdsConfig _config;
private readonly GoogleAdsClientFactory _clientFactory;
private readonly ILogger<GoogleAdsService> _logger;
public GoogleAdsService(
IOptions<GoogleAdsConfig> config,
GoogleAdsClientFactory clientFactory,
ILogger<GoogleAdsService> logger)
{
_config = config.Value;
_clientFactory = clientFactory;
_logger = logger;
}
public async Task<ProviderResponse> ExecuteAsync(ProviderRequest request, CancellationToken ct)
{
var requestId = request.RequestId ?? Guid.NewGuid().ToString("N");
var operation = (request.Operation ?? string.Empty).Trim();
_logger.LogInformation(
"[GoogleAds] Executing {Operation} | RequestId={RequestId} TenantId={TenantId} RealApi={RealApi}",
operation, requestId, request.TenantId, _clientFactory.IsRealApiEnabled);
try
{
var context = new GoogleAdsContext
{
CustomerId = GoogleAdsClientFactory.NormalizeCustomerId(request.TenantId ?? string.Empty),
LoginCustomerId = request.LoginCustomerId
};
var result = operation switch
{
"Ping" => Ping(requestId),
"TestPing" => Ping(requestId),
"CreateCampaign" => await CreateCampaignAsync(request, context, requestId, ct),
"GetCampaign" => await GetCampaignAsync(request, context, requestId, ct),
"UpdateCampaign" => await UpdateCampaignAsync(request, context, requestId, ct),
"ListCampaigns" => await ListCampaignsAsync(request, context, requestId, ct),
"GetCampaignStats" => GetCampaignStats(request, requestId),
"GetAccountStats" => GetAccountStats(request, requestId),
"ListAccessibleCustomers" => await ListAccessibleCustomersAsync(context, requestId, ct),
"" => ProviderResponse.Fail(requestId, "VALIDATION", "Operation is required"),
_ => ProviderResponse.Fail(requestId, "UNKNOWN_OPERATION", $"Unknown operation: {operation}")
};
_logger.LogInformation(
"[GoogleAds] Completed {Operation} | RequestId={RequestId} Ok={Ok}",
operation, requestId, result.Ok);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "[GoogleAds] Error in {Operation} | RequestId={RequestId}", operation, requestId);
return ProviderResponse.Fail(requestId, "INTERNAL_ERROR", ex.Message);
}
}
private ProviderResponse Ping(string requestId)
=> ProviderResponse.Success(requestId, new
{
message = "GoogleApi provider is healthy",
service = "GoogleApi",
realApiEnabled = _clientFactory.IsRealApiEnabled,
apiVersion = _config.ApiVersion,
timestamp = DateTimeOffset.UtcNow
});
private async Task<ProviderResponse> CreateCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<CreateCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.Name))
return ProviderResponse.Fail(requestId, "VALIDATION", "Campaign name is required");
if (payload.BudgetMicros <= 0)
return ProviderResponse.Fail(requestId, "VALIDATION", "BudgetMicros must be > 0");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await CreateCampaignRealAsync(payload, context, requestId, ct);
var externalId = $"customers/{context.CustomerId}/campaigns/{GenerateId()}";
_logger.LogInformation("[GoogleAds] EMULATED: Created campaign {CampaignName} => {CampaignId}", payload.Name, externalId);
return ProviderResponse.Success(requestId, new
{
externalId,
name = payload.Name,
type = payload.Type.ToString(),
status = "ENABLED",
budgetMicros = payload.BudgetMicros,
biddingStrategy = payload.BiddingStrategy.ToString(),
createdAt = DateTimeOffset.UtcNow,
emulated = true
});
}
private async Task<ProviderResponse> CreateCampaignRealAsync(
CreateCampaignPayload payload, GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
// 1) Budget
CampaignBudgetServiceClient budgetService =
client.GetService(GAdsServices.V22.CampaignBudgetService);
var budget = new CampaignBudget
{
Name = $"{payload.Name} Budget ({DateTime.UtcNow:yyyyMMddHHmmss})",
AmountMicros = payload.BudgetMicros,
DeliveryMethod = BudgetDeliveryMethodEnum.Types.BudgetDeliveryMethod.Standard,
ExplicitlyShared = false
};
var budgetResponse = await budgetService.MutateCampaignBudgetsAsync(
new MutateCampaignBudgetsRequest
{
CustomerId = context.CustomerId,
Operations = { new CampaignBudgetOperation { Create = budget } }
},
cancellationToken: ct);
var budgetResourceName = budgetResponse.Results.FirstOrDefault()?.ResourceName;
if (string.IsNullOrWhiteSpace(budgetResourceName))
return ProviderResponse.Fail(requestId, "API_ERROR", "Budget create returned no resource name");
// 2) Campaign
CampaignServiceClient campaignService =
client.GetService(GAdsServices.V22.CampaignService);
var campaign = new Campaign
{
Name = payload.Name,
Status = CampaignStatusEnum.Types.CampaignStatus.Enabled,
AdvertisingChannelType = MapChannelType(payload.Type),
CampaignBudget = budgetResourceName
};
// Dates must be yyyyMMdd for Google Ads API
if (!string.IsNullOrWhiteSpace(payload.StartDate)) campaign.StartDate = payload.StartDate;
if (!string.IsNullOrWhiteSpace(payload.EndDate)) campaign.EndDate = payload.EndDate;
// ✅ Apply bidding in a way that does NOT rely on Campaign.MaximizeClicks property existing
ApplyBiddingStrategySafe(campaign, payload.BiddingStrategy);
var campResponse = await campaignService.MutateCampaignsAsync(
new MutateCampaignsRequest
{
CustomerId = context.CustomerId,
Operations = { new CampaignOperation { Create = campaign } }
},
cancellationToken: ct);
var campaignResourceName = campResponse.Results.FirstOrDefault()?.ResourceName;
return ProviderResponse.Success(requestId, new
{
campaignResourceName,
budgetResourceName,
name = payload.Name,
type = payload.Type.ToString(),
status = "ENABLED",
budgetMicros = payload.BudgetMicros,
biddingStrategy = payload.BiddingStrategy.ToString(),
emulated = false
});
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error creating campaign | RequestId={RequestId}", requestId);
return HandleGoogleAdsException(gex, requestId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create campaign via real API");
return ProviderResponse.Fail(requestId, "API_ERROR", ex.Message);
}
}
private async Task<ProviderResponse> GetCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<GetCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await GetCampaignRealAsync(payload.CampaignId, context, requestId, ct);
_logger.LogInformation("[GoogleAds] EMULATED: Retrieved campaign {CampaignId}", payload.CampaignId);
return ProviderResponse.Success(requestId, new
{
externalId = payload.CampaignId,
name = "Sample Campaign",
type = CampaignType.Search.ToString(),
status = "ENABLED",
budgetMicros = 10_000_000L,
// NOTE: GetCampaignPayload doesn't have BiddingStrategy — so don't reference it
biddingStrategy = ModelBiddingStrategy.MaximizeClicks.ToString(),
createdAt = DateTimeOffset.UtcNow.AddDays(-7),
emulated = true
});
}
private Task<ProviderResponse> GetCampaignRealAsync(
string campaignId, GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
GoogleAdsServiceClient googleAdsService =
client.GetService(GAdsServices.V22.GoogleAdsService);
var isResourceName = campaignId.Contains("/campaigns/", StringComparison.OrdinalIgnoreCase);
var where = isResourceName
? $"campaign.resource_name = '{campaignId}'"
: $"campaign.id = {campaignId}";
var query = $@"
SELECT
campaign.resource_name,
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign_budget.amount_micros
FROM campaign
WHERE {where}
LIMIT 1";
var resp = googleAdsService.Search(new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
});
var row = resp.FirstOrDefault();
if (row == null)
return Task.FromResult(ProviderResponse.Fail(requestId, "NOT_FOUND", "Campaign not found"));
return Task.FromResult(ProviderResponse.Success(requestId, new
{
campaign = new
{
resourceName = row.Campaign.ResourceName,
id = row.Campaign.Id,
name = row.Campaign.Name,
status = row.Campaign.Status.ToString(),
channelType = row.Campaign.AdvertisingChannelType.ToString(),
budgetMicros = row.CampaignBudget?.AmountMicros
},
emulated = false
}));
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error getting campaign | RequestId={RequestId}", requestId);
return Task.FromResult(HandleGoogleAdsException(gex, requestId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get campaign via real API");
return Task.FromResult(ProviderResponse.Fail(requestId, "API_ERROR", ex.Message));
}
}
private Task<ProviderResponse> UpdateCampaignAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
var payload = request.GetPayload<UpdateCampaignPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return Task.FromResult(ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required"));
_logger.LogInformation("[GoogleAds] EMULATED: Updated campaign {CampaignId}", payload.CampaignId);
return Task.FromResult(ProviderResponse.Success(requestId, new
{
updated = true,
campaignId = payload.CampaignId,
updatedAt = DateTimeOffset.UtcNow,
emulated = true
}));
}
private async Task<ProviderResponse> ListCampaignsAsync(
ProviderRequest request, GoogleAdsContext context, string requestId, CancellationToken ct)
{
if (_clientFactory.IsRealApiEnabled && !string.IsNullOrWhiteSpace(context.CustomerId))
return await ListCampaignsRealAsync(context, requestId, ct);
_logger.LogInformation("[GoogleAds] EMULATED: Listed campaigns for tenant {TenantId}", request.TenantId);
var campaigns = new[]
{
new { id = $"customers/{context.CustomerId}/campaigns/{GenerateId()}", name = "Brand Campaign", status = "Enabled", budgetMicros = 5_000_000L },
new { id = $"customers/{context.CustomerId}/campaigns/{GenerateId()}", name = "Product Campaign", status = "Enabled", budgetMicros = 10_000_000L },
new { id = $"customers/{context.CustomerId}/campaigns/{GenerateId()}", name = "Retargeting", status = "Paused", budgetMicros = 3_000_000L }
};
return ProviderResponse.Success(requestId, new
{
campaigns,
totalCount = campaigns.Length,
emulated = true
});
}
private Task<ProviderResponse> ListCampaignsRealAsync(
GoogleAdsContext context, string requestId, CancellationToken ct)
{
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
GoogleAdsServiceClient googleAdsService =
client.GetService(GAdsServices.V22.GoogleAdsService);
var query = @"
SELECT
campaign.resource_name,
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign_budget.amount_micros
FROM campaign
ORDER BY campaign.name";
var results = new List<object>();
var resp = googleAdsService.Search(new SearchGoogleAdsRequest
{
CustomerId = context.CustomerId,
Query = query
});
foreach (var row in resp)
{
ct.ThrowIfCancellationRequested();
results.Add(new
{
resourceName = row.Campaign.ResourceName,
id = row.Campaign.Id,
name = row.Campaign.Name,
status = row.Campaign.Status.ToString(),
channelType = row.Campaign.AdvertisingChannelType.ToString(),
budgetMicros = row.CampaignBudget?.AmountMicros
});
}
return Task.FromResult(ProviderResponse.Success(requestId, new
{
campaigns = results,
totalCount = results.Count,
emulated = false
}));
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error listing campaigns | RequestId={RequestId}", requestId);
return Task.FromResult(HandleGoogleAdsException(gex, requestId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list campaigns via real API");
return Task.FromResult(ProviderResponse.Fail(requestId, "API_ERROR", ex.Message));
}
}
private ProviderResponse GetCampaignStats(ProviderRequest request, string requestId)
{
var payload = request.GetPayload<CampaignStatsPayload>();
if (string.IsNullOrWhiteSpace(payload.CampaignId))
return ProviderResponse.Fail(requestId, "VALIDATION", "CampaignId is required");
_logger.LogInformation("[GoogleAds] EMULATED: Retrieved stats for campaign {CampaignId}", payload.CampaignId);
return ProviderResponse.Success(requestId, new
{
campaignId = payload.CampaignId,
dateRange = new { start = payload.StartDate ?? "2026-01-01", end = payload.EndDate ?? "2026-01-27" },
metrics = new
{
impressions = 15_234L,
clicks = 487L,
costMicros = 2_543_000L,
conversions = 23,
ctr = 0.032,
averageCpcMicros = 5_222L
},
emulated = true
});
}
private ProviderResponse GetAccountStats(ProviderRequest request, string requestId)
{
var payload = request.GetPayload<AccountStatsPayload>();
_logger.LogInformation("[GoogleAds] EMULATED: Retrieved account stats for tenant {TenantId}", request.TenantId);
return ProviderResponse.Success(requestId, new
{
tenantId = request.TenantId,
dateRange = new { start = payload.StartDate ?? "2026-01-01", end = payload.EndDate ?? "2026-01-27" },
metrics = new
{
totalCampaigns = 5,
activeCampaigns = 3,
totalImpressions = 152_340L,
totalClicks = 4_870L,
totalCostMicros = 25_430_000L,
totalConversions = 234
},
emulated = true
});
}
private Task<ProviderResponse> ListAccessibleCustomersAsync(
GoogleAdsContext context, string requestId, CancellationToken ct)
{
if (!_clientFactory.IsRealApiEnabled)
{
return Task.FromResult(ProviderResponse.Success(requestId, new
{
customers = new[] { "1234567890", "9876543210" },
emulated = true
}));
}
try
{
GoogleAdsClient client = _clientFactory.CreateClient(context);
CustomerServiceClient customerService =
client.GetService(GAdsServices.V22.CustomerService);
var resp = customerService.ListAccessibleCustomers(new ListAccessibleCustomersRequest());
var customers = resp.ResourceNames
.Select(rn => rn.Split('/').LastOrDefault() ?? rn)
.ToArray();
return Task.FromResult(ProviderResponse.Success(requestId, new
{
customers,
rawResourceNames = resp.ResourceNames.ToArray(),
emulated = false
}));
}
catch (GoogleAdsException gex)
{
_logger.LogError(gex, "Google Ads API error listing accessible customers | RequestId={RequestId}", requestId);
return Task.FromResult(HandleGoogleAdsException(gex, requestId));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list accessible customers");
return Task.FromResult(ProviderResponse.Fail(requestId, "API_ERROR", ex.Message));
}
}
private static string GenerateId() => Guid.NewGuid().ToString("N")[..12];
private static AdvertisingChannelTypeEnum.Types.AdvertisingChannelType MapChannelType(CampaignType type)
=> type switch
{
CampaignType.Search => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Search,
CampaignType.Display => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Display,
CampaignType.Shopping => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Shopping,
CampaignType.Video => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Video,
CampaignType.PerformanceMax => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.PerformanceMax,
_ => AdvertisingChannelTypeEnum.Types.AdvertisingChannelType.Search
};
// ✅ Strategy application that avoids Campaign.MaximizeClicks property dependency
private static void ApplyBiddingStrategySafe(Campaign campaign, ModelBiddingStrategy strategy)
{
// Try to set the enum safely without compile-time dependency on the member name.
// Different library/proto generations sometimes change the C# member casing.
static BiddingStrategyTypeEnum.Types.BiddingStrategyType ParseBst(params string[] names)
{
foreach (var n in names)
{
if (Enum.TryParse<BiddingStrategyTypeEnum.Types.BiddingStrategyType>(n, ignoreCase: true, out var v))
return v;
}
return BiddingStrategyTypeEnum.Types.BiddingStrategyType.Unspecified;
}
campaign.BiddingStrategyType = strategy switch
{
ModelBiddingStrategy.ManualCpc =>
ParseBst("ManualCpc", "MANUAL_CPC"),
ModelBiddingStrategy.MaximizeClicks =>
ParseBst("MaximizeClicks", "MAXIMIZE_CLICKS", "MaximizeClick"),
ModelBiddingStrategy.MaximizeConversions =>
ParseBst("MaximizeConversions", "MAXIMIZE_CONVERSIONS"),
ModelBiddingStrategy.TargetCpa =>
ParseBst("TargetCpa", "TARGET_CPA"),
ModelBiddingStrategy.TargetRoas =>
ParseBst("TargetRoas", "TARGET_ROAS"),
_ =>
ParseBst("MaximizeClicks", "MAXIMIZE_CLICKS", "Unspecified")
};
// Optional: set oneof objects ONLY when you know your generated Campaign has them.
// ManualCpc is the most consistently present.
if (strategy == ModelBiddingStrategy.ManualCpc)
{
campaign.ManualCpc = new ManualCpc();
}
// If your Campaign class DOES have these properties in your build, you can uncomment:
// if (strategy == ModelBiddingStrategy.MaximizeClicks) campaign.MaximizeClicks = new MaximizeClicks();
// if (strategy == ModelBiddingStrategy.MaximizeConversions) campaign.MaximizeConversions = new MaximizeConversions();
}
private static ProviderResponse HandleGoogleAdsException(GoogleAdsException gex, string requestId)
{
var errorDetails = gex.Failure?.Errors?.Select(e => new
{
errorCode = e.ErrorCode?.ToString(),
message = e.Message,
trigger = e.Trigger?.StringValue,
location = e.Location?.FieldPathElements?.Select(f => f.FieldName).ToArray()
}).ToList();
return ProviderResponse.Fail(requestId, "GOOGLE_ADS_ERROR", gex.Message, new
{
googleRequestId = gex.RequestId,
errors = errorDetails
});
}
}

View File

@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"GoogleApi": "Debug"
}
},
"GOOGLE_INTERNAL_KEY": "dev-test-key-12345",
"GoogleAds": {
"ApiVersion": "v22",
"OAuth": {
"ClientId": "",
"ClientSecret": "",
"RefreshToken": ""
}
}
}

View File

@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"InternalKey": "",
"GoogleAds": {
"EnableRealApi": false,
"ApiVersion": "v22",
"DeveloperToken": "",
"DefaultLoginCustomerId": "",
"TimeoutSeconds": 60,
"OAuth": {
"ClientId": "",
"ClientSecret": "",
"RefreshToken": ""
}
}
}