258 lines
9.4 KiB
C#
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 };
|
|
}
|