viewengine.client/ViewEngineClient.cs

458 lines
18 KiB
C#

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;
/// <summary>
/// Client for interacting with the ViewEngine REST API
/// </summary>
public class ViewEngineClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly ViewEngineOptions _options;
private readonly bool _disposeHttpClient;
/// <summary>
/// Creates a new ViewEngineClient with the specified API key
/// </summary>
/// <param name="apiKey">Your ViewEngine API key</param>
/// <param name="baseUrl">Optional base URL (defaults to https://www.viewengine.io)</param>
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;
}
/// <summary>
/// Creates a new ViewEngineClient with the specified options
/// </summary>
/// <param name="options">Configuration options</param>
public ViewEngineClient(IOptions<ViewEngineOptions> options) : this(options.Value)
{
}
/// <summary>
/// Creates a new ViewEngineClient with the specified options
/// </summary>
/// <param name="options">Configuration options</param>
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;
}
/// <summary>
/// Creates a new ViewEngineClient with a custom HttpClient (for dependency injection)
/// </summary>
/// <param name="httpClient">Configured HttpClient instance</param>
/// <param name="options">Configuration options</param>
public ViewEngineClient(HttpClient httpClient, IOptions<ViewEngineOptions> 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
/// <summary>
/// Submit a web page retrieval request
/// </summary>
/// <param name="request">Retrieval request parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Retrieval response with request ID</returns>
public async Task<RetrievalResponse> 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<RetrievalResponse>(cancellationToken: cancellationToken);
return result ?? throw new InvalidOperationException("Failed to deserialize retrieval response");
}
/// <summary>
/// Get the status of a retrieval request
/// </summary>
/// <param name="requestId">The request ID returned from SubmitRetrievalAsync</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Current status of the retrieval request</returns>
public async Task<RetrievalStatusResponse> 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<RetrievalStatusResponse>(cancellationToken: cancellationToken);
return result ?? throw new InvalidOperationException("Failed to deserialize status response");
}
/// <summary>
/// Download the decrypted page content for a completed retrieval
/// </summary>
/// <param name="requestId">The request ID of the completed retrieval</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Page content data</returns>
public async Task<PageData> 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<PageData>(cancellationToken: cancellationToken);
return result ?? throw new InvalidOperationException("Failed to deserialize page content");
}
/// <summary>
/// Submit a retrieval request and wait for completion
/// </summary>
/// <param name="request">Retrieval request parameters</param>
/// <param name="pollingIntervalMs">How often to check status in milliseconds (default: 2000)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Page content data when complete</returns>
public async Task<PageData> 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);
}
/// <summary>
/// List available MCP tools
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of available tools</returns>
public async Task<object> 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<object>(cancellationToken: cancellationToken);
return result ?? throw new InvalidOperationException("Failed to deserialize tools response");
}
#endregion
#region Client Management API
/// <summary>
/// Add a new client programmatically
/// </summary>
/// <param name="request">Client details</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Created client information</returns>
public async Task<ClientInfo> 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<ClientInfo>(cancellationToken: cancellationToken);
return result ?? throw new InvalidOperationException("Failed to deserialize client response");
}
/// <summary>
/// Get a list of all clients
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of clients</returns>
public async Task<List<ClientInfo>> 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<List<ClientInfo>>(cancellationToken: cancellationToken);
return result ?? new List<ClientInfo>();
}
/// <summary>
/// Update client settings
/// </summary>
/// <param name="clientId">Client ID</param>
/// <param name="request">Updated settings</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Updated client information</returns>
public async Task<ClientInfo> 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<ClientInfo>(cancellationToken: cancellationToken);
return result ?? throw new InvalidOperationException("Failed to deserialize client response");
}
/// <summary>
/// Suspend a client
/// </summary>
/// <param name="clientId">Client ID</param>
/// <param name="cancellationToken">Cancellation token</param>
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();
}
/// <summary>
/// Reactivate a suspended client
/// </summary>
/// <param name="clientId">Client ID</param>
/// <param name="cancellationToken">Cancellation token</param>
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();
}
/// <summary>
/// Remove a client
/// </summary>
/// <param name="clientId">Client ID</param>
/// <param name="cancellationToken">Cancellation token</param>
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();
}
/// <summary>
/// Get detailed statistics for a client
/// </summary>
/// <param name="clientId">Client ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Client statistics</returns>
public async Task<ClientStats> 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<ClientStats>(cancellationToken: cancellationToken);
return result ?? throw new InvalidOperationException("Failed to deserialize stats response");
}
#endregion
#region Retry Logic
private async Task<HttpResponseMessage> ExecuteWithRetryAsync(
Func<Task<HttpResponseMessage>> 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
/// <summary>
/// Disposes the ViewEngineClient and underlying HttpClient if not injected
/// </summary>
public void Dispose()
{
if (_disposeHttpClient)
{
_httpClient?.Dispose();
}
}
}