|
|
|
|
@@ -231,9 +231,99 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
|
|
|
|
return new JobTriggerResult(true, "Accepted", location, run);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
return new JobTriggerResult(false, failureMessage, null, null);
|
|
|
|
|
}
|
|
|
|
|
var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
return new JobTriggerResult(false, failureMessage, null, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
EnsureBackendConfigured();
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(route))
|
|
|
|
|
{
|
|
|
|
|
throw new ArgumentException("Route must be provided.", nameof(route));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var relative = route.TrimStart('/');
|
|
|
|
|
using var request = CreateRequest(method, $"excititor/{relative}");
|
|
|
|
|
|
|
|
|
|
if (payload is not null && method != HttpMethod.Get && method != HttpMethod.Delete)
|
|
|
|
|
{
|
|
|
|
|
request.Content = JsonContent.Create(payload, options: SerializerOptions);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
var (message, payloadElement) = await ExtractExcititorResponseAsync(response, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
var location = response.Headers.Location?.ToString();
|
|
|
|
|
return new ExcititorOperationResult(true, message, location, payloadElement);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
return new ExcititorOperationResult(false, failure, null, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
EnsureBackendConfigured();
|
|
|
|
|
|
|
|
|
|
var query = includeDisabled ? "?includeDisabled=true" : string.Empty;
|
|
|
|
|
using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}");
|
|
|
|
|
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
throw new InvalidOperationException(failure);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.Content is null || response.Content.Headers.ContentLength is 0)
|
|
|
|
|
{
|
|
|
|
|
return Array.Empty<ExcititorProviderSummary>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (stream is null || stream.Length == 0)
|
|
|
|
|
{
|
|
|
|
|
return Array.Empty<ExcititorProviderSummary>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
|
|
|
var root = document.RootElement;
|
|
|
|
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("providers", out var providersProperty))
|
|
|
|
|
{
|
|
|
|
|
root = providersProperty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (root.ValueKind != JsonValueKind.Array)
|
|
|
|
|
{
|
|
|
|
|
return Array.Empty<ExcititorProviderSummary>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var list = new List<ExcititorProviderSummary>();
|
|
|
|
|
foreach (var item in root.EnumerateArray())
|
|
|
|
|
{
|
|
|
|
|
var id = GetStringProperty(item, "id") ?? string.Empty;
|
|
|
|
|
if (string.IsNullOrWhiteSpace(id))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var kind = GetStringProperty(item, "kind") ?? "unknown";
|
|
|
|
|
var displayName = GetStringProperty(item, "displayName") ?? id;
|
|
|
|
|
var trustTier = GetStringProperty(item, "trustTier") ?? string.Empty;
|
|
|
|
|
var enabled = GetBooleanProperty(item, "enabled", defaultValue: true);
|
|
|
|
|
var lastIngested = GetDateTimeOffsetProperty(item, "lastIngestedAt");
|
|
|
|
|
|
|
|
|
|
list.Add(new ExcititorProviderSummary(id, kind, displayName, trustTier, enabled, lastIngested));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return list;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
|
|
|
|
{
|
|
|
|
|
@@ -328,10 +418,114 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<(string Message, JsonElement? Payload)> ExtractExcititorResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (response.Content is null || response.Content.Headers.ContentLength is 0)
|
|
|
|
|
{
|
|
|
|
|
return ($"HTTP {(int)response.StatusCode}", null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (stream is null || stream.Length == 0)
|
|
|
|
|
{
|
|
|
|
|
return ($"HTTP {(int)response.StatusCode}", null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
|
|
|
var root = document.RootElement.Clone();
|
|
|
|
|
string? message = null;
|
|
|
|
|
if (root.ValueKind == JsonValueKind.Object)
|
|
|
|
|
{
|
|
|
|
|
message = GetStringProperty(root, "message") ?? GetStringProperty(root, "status");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(message))
|
|
|
|
|
{
|
|
|
|
|
message = root.ValueKind == JsonValueKind.Object || root.ValueKind == JsonValueKind.Array
|
|
|
|
|
? root.ToString()
|
|
|
|
|
: root.GetRawText();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (message ?? $"HTTP {(int)response.StatusCode}", root);
|
|
|
|
|
}
|
|
|
|
|
catch (JsonException)
|
|
|
|
|
{
|
|
|
|
|
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
|
return (string.IsNullOrWhiteSpace(text) ? $"HTTP {(int)response.StatusCode}" : text.Trim(), null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement property)
|
|
|
|
|
{
|
|
|
|
|
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
|
|
|
{
|
|
|
|
|
foreach (var candidate in element.EnumerateObject())
|
|
|
|
|
{
|
|
|
|
|
if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
property = candidate.Value;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
property = default;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string? GetStringProperty(JsonElement element, string propertyName)
|
|
|
|
|
{
|
|
|
|
|
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
|
|
|
|
|
{
|
|
|
|
|
if (property.ValueKind == JsonValueKind.String)
|
|
|
|
|
{
|
|
|
|
|
return property.GetString();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool GetBooleanProperty(JsonElement element, string propertyName, bool defaultValue)
|
|
|
|
|
{
|
|
|
|
|
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
|
|
|
|
|
{
|
|
|
|
|
return property.ValueKind switch
|
|
|
|
|
{
|
|
|
|
|
JsonValueKind.True => true,
|
|
|
|
|
JsonValueKind.False => false,
|
|
|
|
|
JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed,
|
|
|
|
|
_ => defaultValue
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return defaultValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName)
|
|
|
|
|
{
|
|
|
|
|
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property) && property.ValueKind == JsonValueKind.String)
|
|
|
|
|
{
|
|
|
|
|
if (DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
|
|
|
|
|
{
|
|
|
|
|
return parsed.ToUniversalTime();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void EnsureBackendConfigured()
|
|
|
|
|
{
|
|
|
|
|
if (_httpClient.BaseAddress is null)
|
|
|
|
|
{
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|