FUll implementation plan (first draft)
This commit is contained in:
		@@ -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.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user