using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GB5Shared.Auth.Jwt;
using GB5Shared.DTO.Framework.Login;
using Microsoft.Extensions.Logging;
namespace GB5Shared.ExternalAI
{
///
/// Default IAIEngineSyncClient implementation — reuses the exact JWT-mint + HttpClient call
/// pattern already proven live in FrameworkBLL.GOP.Worker.NodeExecutors.AIExtractNodeExecutor.
/// Constructor is deliberately DI-plain (no per-call config bound at construction) so this
/// class can be registered once, centrally, and shared by every module that needs to talk to
/// the external Enterprise AI engine — each caller supplies its own AIEngineOptions per call.
///
public class AIEngineHttpClient : IAIEngineSyncClient
{
private const string HttpClientName = "ai-enterprise";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IJwtAccessTokenIssuer _jwtIssuer;
private readonly ILogger _logger;
public AIEngineHttpClient(
IHttpClientFactory httpClientFactory,
IJwtAccessTokenIssuer jwtIssuer,
ILogger logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_jwtIssuer = jwtIssuer ?? throw new ArgumentNullException(nameof(jwtIssuer));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SendJsonAsync(
AIEngineOptions options,
HttpMethod method,
string relativePath,
object? jsonBody,
LoginDTO login,
CancellationToken ct = default)
{
try
{
var accessToken = await IssueTokenAsync(options, login, ct).ConfigureAwait(false);
var requestUrl = $"{options.EngineBaseUrl.TrimEnd('/')}/{relativePath.TrimStart('/')}";
using var request = new HttpRequestMessage(method, requestUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
if (jsonBody is not null)
{
var json = JsonSerializer.Serialize(jsonBody);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
return await SendAsync(options, request, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex,
"AIEngineHttpClient | SendJsonAsync | Failed | Path={Path}", relativePath);
return new AIEngineResponse { IsSuccess = false, ErrorMessage = ex.Message };
}
}
public async Task SendMultipartAsync(
AIEngineOptions options,
string relativePath,
IReadOnlyDictionary formFields,
(string FileName, byte[] Bytes)? file,
LoginDTO login,
CancellationToken ct = default)
{
try
{
var accessToken = await IssueTokenAsync(options, login, ct).ConfigureAwait(false);
var requestUrl = $"{options.EngineBaseUrl.TrimEnd('/')}/{relativePath.TrimStart('/')}";
using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var form = new MultipartFormDataContent();
foreach (var kvp in formFields)
form.Add(new StringContent(kvp.Value ?? string.Empty), kvp.Key);
if (file is { } f)
{
var fileContent = new ByteArrayContent(f.Bytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(fileContent, "file", f.FileName);
}
request.Content = form;
return await SendAsync(options, request, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex,
"AIEngineHttpClient | SendMultipartAsync | Failed | Path={Path}", relativePath);
return new AIEngineResponse { IsSuccess = false, ErrorMessage = ex.Message };
}
}
private async Task IssueTokenAsync(AIEngineOptions options, LoginDTO login, CancellationToken ct)
{
var claims = new AIEngineAccessTokenClaims { TenantId = login.ClientId, UserId = login.UserId };
var issued = await _jwtIssuer.IssueAsync(
claims, options.SigningKeyVaultPath, options.Issuer, options.Audience,
options.AccessTokenMinutes, ct).ConfigureAwait(false);
return issued.AccessToken;
}
private async Task SendAsync(
AIEngineOptions options, HttpRequestMessage request, CancellationToken ct)
{
var client = _httpClientFactory.CreateClient(HttpClientName);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds > 0 ? options.TimeoutSeconds : 120));
try
{
using var response = await client.SendAsync(request, timeoutCts.Token).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return new AIEngineResponse
{
IsSuccess = response.IsSuccessStatusCode,
StatusCode = (int)response.StatusCode,
Body = body,
ErrorMessage = response.IsSuccessStatusCode
? null
: $"HTTP {(int)response.StatusCode}: {body}"
};
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Our own timeout fired, not the caller's token.
return new AIEngineResponse { IsSuccess = false, ErrorMessage = "Request to AI-Enterprise-v1.0 timed out." };
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "AIEngineHttpClient | SendAsync | Network error | Url={Url}", request.RequestUri);
return new AIEngineResponse { IsSuccess = false, ErrorMessage = ex.Message };
}
}
}
}