Initial import into Gitea
This commit is contained in:
257
TikTokApi/Services/TikTokApiClient.cs
Normal file
257
TikTokApi/Services/TikTokApiClient.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user