Files
AdPlatform-Server/TikTokApi/Services/TikTokApiClient.cs
2026-03-14 13:50:09 -07:00

258 lines
9.4 KiB
C#

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using TikTokApi.Configuration;
using Microsoft.Extensions.Options;
namespace TikTokApi.Services;
/// <summary>
/// HTTP wrapper for TikTok Marketing API calls.
/// Handles authentication, API versioning, error parsing, and response envelope unwrapping.
///
/// TikTok Marketing API pattern:
/// Base: https://business-api.tiktok.com/open_api/{version}/{endpoint}
/// Auth: Access-Token header (NOT query param, NOT Bearer)
/// Request: JSON body for POST, query params for GET
/// Response envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."}
/// Code 0 = success, anything else = error
///
/// Sandbox: https://sandbox-ads.tiktok.com/open_api/{version}/{endpoint}
/// </summary>
public sealed class TikTokApiClient
{
private readonly HttpClient _http;
private readonly TikTokConfig _config;
private readonly ILogger<TikTokApiClient> _logger;
public TikTokApiClient(HttpClient http, IOptions<TikTokConfig> config, ILogger<TikTokApiClient> logger)
{
_http = http;
_config = config.Value;
_logger = logger;
var baseUrl = _config.ApiBaseUrl.TrimEnd('/');
_http.BaseAddress = new Uri($"{baseUrl}/open_api/{_config.ApiVersion}/");
_http.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds);
}
public bool IsRealApiEnabled => _config.EnableRealApi
&& !string.IsNullOrWhiteSpace(_config.AccessToken);
// ================================================================
// GET - for read operations (campaign/get, advertiser/info, etc.)
// ================================================================
/// <summary>
/// GET request to TikTok Marketing API.
/// TikTok GET endpoints use query parameters.
/// </summary>
public async Task<TikTokApiResponse> GetAsync(
string endpoint, Dictionary<string, string>? queryParams = null, CancellationToken ct = default)
{
var url = BuildUrl(endpoint, queryParams);
var safeUrl = SanitizeForLogging(url);
_logger.LogDebug("[TikTokApi] GET {Url}", safeUrl);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
InjectAuth(request);
var response = await _http.SendAsync(request, ct);
return await ParseResponseAsync(response, safeUrl, ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
_logger.LogWarning("[TikTokApi] GET {Url} timed out", safeUrl);
return TikTokApiResponse.Error("Request timed out", -1);
}
catch (Exception ex)
{
_logger.LogError(ex, "[TikTokApi] GET {Url} failed", safeUrl);
return TikTokApiResponse.Error(ex.Message, -1);
}
}
// ================================================================
// POST - for write operations (campaign/create, campaign/update, etc.)
// ================================================================
/// <summary>
/// POST request to TikTok Marketing API.
/// TikTok POST endpoints accept JSON body.
/// </summary>
public async Task<TikTokApiResponse> PostAsync(
string endpoint, object body, CancellationToken ct = default)
{
var safeEndpoint = SanitizeForLogging(endpoint);
_logger.LogDebug("[TikTokApi] POST {Endpoint}", safeEndpoint);
try
{
var json = JsonSerializer.Serialize(body, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
InjectAuth(request);
var response = await _http.SendAsync(request, ct);
return await ParseResponseAsync(response, safeEndpoint, ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
_logger.LogWarning("[TikTokApi] POST {Endpoint} timed out", safeEndpoint);
return TikTokApiResponse.Error("Request timed out", -1);
}
catch (Exception ex)
{
_logger.LogError(ex, "[TikTokApi] POST {Endpoint} failed", safeEndpoint);
return TikTokApiResponse.Error(ex.Message, -1);
}
}
// ================================================================
// Response parsing
// ================================================================
/// <summary>
/// Parse TikTok response envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."}
/// Code 0 = success, anything else = error.
/// </summary>
private async Task<TikTokApiResponse> ParseResponseAsync(
HttpResponseMessage response, string context, CancellationToken ct)
{
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("[TikTokApi] HTTP {StatusCode} from {Context}: {Body}",
(int)response.StatusCode, context, Truncate(body, 500));
}
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
var code = root.TryGetProperty("code", out var codeProp) ? codeProp.GetInt32() : -1;
var message = root.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null;
var requestId = root.TryGetProperty("request_id", out var ridProp) ? ridProp.GetString() : null;
if (code == 0)
{
// Success - extract data
JsonElement? data = root.TryGetProperty("data", out var dataProp) ? dataProp.Clone() : null;
_logger.LogDebug("[TikTokApi] Success from {Context} | RequestId={RequestId}",
context, requestId);
return new TikTokApiResponse
{
IsSuccess = true,
Code = 0,
Message = message ?? "OK",
Data = data,
TikTokRequestId = requestId
};
}
else
{
// Error
_logger.LogWarning(
"[TikTokApi] Error from {Context} | Code={Code} Message={Message} RequestId={RequestId}",
context, code, message, requestId);
return new TikTokApiResponse
{
IsSuccess = false,
Code = code,
Message = message ?? "Unknown error",
TikTokRequestId = requestId
};
}
}
catch (JsonException ex)
{
_logger.LogError(ex, "[TikTokApi] Failed to parse response from {Context}: {Body}",
context, Truncate(body, 300));
return TikTokApiResponse.Error($"Invalid JSON response: {ex.Message}", -1);
}
}
// ================================================================
// Helpers
// ================================================================
/// <summary>
/// Inject Access-Token header. TikTok uses a custom header name, NOT "Authorization: Bearer".
/// </summary>
private void InjectAuth(HttpRequestMessage request)
{
if (!string.IsNullOrWhiteSpace(_config.AccessToken))
{
request.Headers.TryAddWithoutValidation("Access-Token", _config.AccessToken);
}
}
private static string BuildUrl(string endpoint, Dictionary<string, string>? queryParams)
{
if (queryParams == null || queryParams.Count == 0)
return endpoint;
var sb = new StringBuilder(endpoint);
sb.Append('?');
var first = true;
foreach (var (key, value) in queryParams)
{
if (!first) sb.Append('&');
sb.Append(Uri.EscapeDataString(key));
sb.Append('=');
sb.Append(Uri.EscapeDataString(value));
first = false;
}
return sb.ToString();
}
/// <summary>
/// Strip tokens/secrets from URLs for safe logging.
/// </summary>
private static string SanitizeForLogging(string input)
{
// TikTok doesn't put tokens in URLs (they're in headers), but sanitize just in case
return input;
}
private static string Truncate(string text, int maxLength)
=> text.Length <= maxLength ? text : text[..maxLength] + "...";
}
/// <summary>
/// Parsed TikTok API response.
/// TikTok envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."}
/// </summary>
public sealed class TikTokApiResponse
{
public bool IsSuccess { get; set; }
/// <summary>TikTok error code. 0 = success.</summary>
public int Code { get; set; }
/// <summary>Human-readable message from TikTok.</summary>
public string? Message { get; set; }
/// <summary>Response data payload (when successful).</summary>
public JsonElement? Data { get; set; }
/// <summary>TikTok-assigned request ID for support debugging.</summary>
public string? TikTokRequestId { get; set; }
public static TikTokApiResponse Error(string message, int code)
=> new() { IsSuccess = false, Code = code, Message = message };
}