using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.Options; using ViewEngine.Client.Configuration; using ViewEngine.Client.Models; namespace ViewEngine.Client; /// /// Client for interacting with the ViewEngine REST API /// public class ViewEngineClient : IDisposable { private readonly HttpClient _httpClient; private readonly ViewEngineOptions _options; private readonly bool _disposeHttpClient; /// /// Creates a new ViewEngineClient with the specified API key /// /// Your ViewEngine API key /// Optional base URL (defaults to https://www.viewengine.io) public ViewEngineClient(string apiKey, string? baseUrl = null) { if (string.IsNullOrWhiteSpace(apiKey)) throw new ArgumentNullException(nameof(apiKey)); _options = new ViewEngineOptions { ApiKey = apiKey, BaseUrl = baseUrl ?? "https://www.viewengine.io" }; _httpClient = new HttpClient { BaseAddress = new Uri(_options.BaseUrl), Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds) }; ConfigureHttpClient(); _disposeHttpClient = true; } /// /// Creates a new ViewEngineClient with the specified options /// /// Configuration options public ViewEngineClient(IOptions options) : this(options.Value) { } /// /// Creates a new ViewEngineClient with the specified options /// /// Configuration options public ViewEngineClient(ViewEngineOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); if (string.IsNullOrWhiteSpace(_options.ApiKey)) throw new ArgumentException("API key is required", nameof(options)); _httpClient = new HttpClient { BaseAddress = new Uri(_options.BaseUrl), Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds) }; ConfigureHttpClient(); _disposeHttpClient = true; } /// /// Creates a new ViewEngineClient with a custom HttpClient (for dependency injection) /// /// Configured HttpClient instance /// Configuration options public ViewEngineClient(HttpClient httpClient, IOptions options) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); if (string.IsNullOrWhiteSpace(_options.ApiKey)) throw new ArgumentException("API key is required", nameof(options)); _httpClient.BaseAddress = new Uri(_options.BaseUrl); _httpClient.Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds); ConfigureHttpClient(); _disposeHttpClient = false; // Don't dispose injected HttpClient } private void ConfigureHttpClient() { _httpClient.DefaultRequestHeaders.Clear(); _httpClient.DefaultRequestHeaders.Add("X-API-Key", _options.ApiKey); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } #region MCP Retrieval API /// /// Submit a web page retrieval request /// /// Retrieval request parameters /// Cancellation token /// Retrieval response with request ID public async Task SubmitRetrievalAsync(SubmitRetrievalRequest request, CancellationToken cancellationToken = default) { if (request == null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(request.Url)) throw new ArgumentException("URL is required", nameof(request)); var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.PostAsJsonAsync("/mcp/retrieve", request, cancellationToken); }, "SubmitRetrieval", cancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return result ?? throw new InvalidOperationException("Failed to deserialize retrieval response"); } /// /// Get the status of a retrieval request /// /// The request ID returned from SubmitRetrievalAsync /// Cancellation token /// Current status of the retrieval request public async Task GetRetrievalStatusAsync(Guid requestId, CancellationToken cancellationToken = default) { if (requestId == Guid.Empty) throw new ArgumentException("Invalid request ID", nameof(requestId)); var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.GetAsync($"/mcp/retrieve/{requestId}", cancellationToken); }, "GetRetrievalStatus", cancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return result ?? throw new InvalidOperationException("Failed to deserialize status response"); } /// /// Download the decrypted page content for a completed retrieval /// /// The request ID of the completed retrieval /// Cancellation token /// Page content data public async Task GetPageContentAsync(Guid requestId, CancellationToken cancellationToken = default) { if (requestId == Guid.Empty) throw new ArgumentException("Invalid request ID", nameof(requestId)); var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.GetAsync($"/mcp/retrieve/{requestId}/content", cancellationToken); }, "GetPageContent", cancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return result ?? throw new InvalidOperationException("Failed to deserialize page content"); } /// /// Submit a retrieval request and wait for completion /// /// Retrieval request parameters /// How often to check status in milliseconds (default: 2000) /// Cancellation token /// Page content data when complete public async Task RetrieveAndWaitAsync(SubmitRetrievalRequest request, int? pollingIntervalMs = null, CancellationToken cancellationToken = default) { // Submit the request var submitResponse = await SubmitRetrievalAsync(request, cancellationToken); // Poll for completion var interval = pollingIntervalMs ?? _options.DefaultPollingIntervalMs; while (!cancellationToken.IsCancellationRequested) { var status = await GetRetrievalStatusAsync(submitResponse.RequestId, cancellationToken); if (status.Status.Equals("complete", StringComparison.OrdinalIgnoreCase)) { // Download and return the content return await GetPageContentAsync(submitResponse.RequestId, cancellationToken); } else if (status.Status.Equals("failed", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"Retrieval failed: {status.Error ?? status.Message}"); } else if (status.Status.Equals("canceled", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("Retrieval was canceled"); } // Wait before polling again await Task.Delay(interval, cancellationToken); } throw new OperationCanceledException("Operation was canceled", cancellationToken); } /// /// List available MCP tools /// /// Cancellation token /// List of available tools public async Task ListToolsAsync(CancellationToken cancellationToken = default) { var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.GetAsync("/mcp/tools", cancellationToken); }, "ListTools", cancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return result ?? throw new InvalidOperationException("Failed to deserialize tools response"); } #endregion #region Client Management API /// /// Add a new client programmatically /// /// Client details /// Cancellation token /// Created client information public async Task AddClientAsync(AddClientRequest request, CancellationToken cancellationToken = default) { if (request == null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(request.Email)) throw new ArgumentException("Email is required", nameof(request)); var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.PostAsJsonAsync("/v1/clients", request, cancellationToken); }, "AddClient", cancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return result ?? throw new InvalidOperationException("Failed to deserialize client response"); } /// /// Get a list of all clients /// /// Cancellation token /// List of clients public async Task> GetClientsAsync(CancellationToken cancellationToken = default) { var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.GetAsync("/v1/clients", cancellationToken); }, "GetClients", cancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); return result ?? new List(); } /// /// Update client settings /// /// Client ID /// Updated settings /// Cancellation token /// Updated client information public async Task UpdateClientAsync(Guid clientId, UpdateClientRequest request, CancellationToken cancellationToken = default) { if (clientId == Guid.Empty) throw new ArgumentException("Invalid client ID", nameof(clientId)); if (request == null) throw new ArgumentNullException(nameof(request)); var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.PutAsJsonAsync($"/v1/clients/{clientId}", request, cancellationToken); }, "UpdateClient", cancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return result ?? throw new InvalidOperationException("Failed to deserialize client response"); } /// /// Suspend a client /// /// Client ID /// Cancellation token public async Task SuspendClientAsync(Guid clientId, CancellationToken cancellationToken = default) { if (clientId == Guid.Empty) throw new ArgumentException("Invalid client ID", nameof(clientId)); var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.PutAsync($"/v1/clients/{clientId}/suspend", null, cancellationToken); }, "SuspendClient", cancellationToken); response.EnsureSuccessStatusCode(); } /// /// Reactivate a suspended client /// /// Client ID /// Cancellation token public async Task ActivateClientAsync(Guid clientId, CancellationToken cancellationToken = default) { if (clientId == Guid.Empty) throw new ArgumentException("Invalid client ID", nameof(clientId)); var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.PutAsync($"/v1/clients/{clientId}/activate", null, cancellationToken); }, "ActivateClient", cancellationToken); response.EnsureSuccessStatusCode(); } /// /// Remove a client /// /// Client ID /// Cancellation token public async Task DeleteClientAsync(Guid clientId, CancellationToken cancellationToken = default) { if (clientId == Guid.Empty) throw new ArgumentException("Invalid client ID", nameof(clientId)); var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.DeleteAsync($"/v1/clients/{clientId}", cancellationToken); }, "DeleteClient", cancellationToken); response.EnsureSuccessStatusCode(); } /// /// Get detailed statistics for a client /// /// Client ID /// Cancellation token /// Client statistics public async Task GetClientStatsAsync(Guid clientId, CancellationToken cancellationToken = default) { if (clientId == Guid.Empty) throw new ArgumentException("Invalid client ID", nameof(clientId)); var response = await ExecuteWithRetryAsync(async () => { return await _httpClient.GetAsync($"/v1/clients/{clientId}/stats", cancellationToken); }, "GetClientStats", cancellationToken); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return result ?? throw new InvalidOperationException("Failed to deserialize stats response"); } #endregion #region Retry Logic private async Task ExecuteWithRetryAsync( Func> action, string operationName, CancellationToken cancellationToken = default) { int attempt = 0; while (attempt < _options.MaxRetries) { try { attempt++; var response = await action(); // Handle rate limiting (429) with exponential backoff if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) { if (attempt >= _options.MaxRetries) { throw new HttpRequestException($"Rate limit exceeded for {operationName} after {attempt} attempts"); } // Check for Retry-After header var retryAfter = response.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? (_options.BaseDelayMs * Math.Pow(2, attempt - 1)); await Task.Delay((int)retryAfter, cancellationToken); continue; } // Retry on server errors (500-599) if ((int)response.StatusCode >= 500 && (int)response.StatusCode < 600) { if (attempt >= _options.MaxRetries) { throw new HttpRequestException($"Server error for {operationName}: {response.StatusCode} after {attempt} attempts"); } var delay = _options.BaseDelayMs * Math.Pow(2, attempt - 1); await Task.Delay((int)delay, cancellationToken); continue; } return response; } catch (TaskCanceledException) when (attempt < _options.MaxRetries && !cancellationToken.IsCancellationRequested) { var delay = _options.BaseDelayMs * Math.Pow(2, attempt - 1); await Task.Delay((int)delay, cancellationToken); continue; } catch (HttpRequestException) when (attempt < _options.MaxRetries) { var delay = _options.BaseDelayMs * Math.Pow(2, attempt - 1); await Task.Delay((int)delay, cancellationToken); continue; } } throw new Exception($"Failed after {_options.MaxRetries} attempts for {operationName}"); } #endregion /// /// Disposes the ViewEngineClient and underlying HttpClient if not injected /// public void Dispose() { if (_disposeHttpClient) { _httpClient?.Dispose(); } } }