458 lines
18 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|