using System.Net.Http.Headers; using System.Text; using System.Text.Json; using TikTokApi.Configuration; using Microsoft.Extensions.Options; namespace TikTokApi.Services; /// /// 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} /// public sealed class TikTokApiClient { private readonly HttpClient _http; private readonly TikTokConfig _config; private readonly ILogger _logger; public TikTokApiClient(HttpClient http, IOptions config, ILogger 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.) // ================================================================ /// /// GET request to TikTok Marketing API. /// TikTok GET endpoints use query parameters. /// public async Task GetAsync( string endpoint, Dictionary? 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.) // ================================================================ /// /// POST request to TikTok Marketing API. /// TikTok POST endpoints accept JSON body. /// public async Task 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 // ================================================================ /// /// Parse TikTok response envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."} /// Code 0 = success, anything else = error. /// private async Task 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 // ================================================================ /// /// Inject Access-Token header. TikTok uses a custom header name, NOT "Authorization: Bearer". /// private void InjectAuth(HttpRequestMessage request) { if (!string.IsNullOrWhiteSpace(_config.AccessToken)) { request.Headers.TryAddWithoutValidation("Access-Token", _config.AccessToken); } } private static string BuildUrl(string endpoint, Dictionary? 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(); } /// /// Strip tokens/secrets from URLs for safe logging. /// 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] + "..."; } /// /// Parsed TikTok API response. /// TikTok envelope: {"code": 0, "message": "OK", "data": {...}, "request_id": "..."} /// public sealed class TikTokApiResponse { public bool IsSuccess { get; set; } /// TikTok error code. 0 = success. public int Code { get; set; } /// Human-readable message from TikTok. public string? Message { get; set; } /// Response data payload (when successful). public JsonElement? Data { get; set; } /// TikTok-assigned request ID for support debugging. public string? TikTokRequestId { get; set; } public static TikTokApiResponse Error(string message, int code) => new() { IsSuccess = false, Code = code, Message = message }; }