using System.ComponentModel.DataAnnotations; using System.Net; using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Polly; using Polly.Extensions.Http; namespace HCFS.SDK; /// /// Main HCFS client for .NET applications. /// /// /// This client provides both synchronous and asynchronous methods for interacting /// with the HCFS API. It includes built-in caching, retry logic, rate limiting, /// and comprehensive error handling. /// /// /// Basic usage: /// /// var config = new HCFSConfig /// { /// BaseUrl = "https://api.hcfs.dev/v1", /// ApiKey = "your-api-key" /// }; /// /// using var client = new HCFSClient(config); /// /// // Create a context /// var context = new Context /// { /// Path = "/docs/readme", /// Content = "Hello, HCFS!", /// Summary = "Getting started guide" /// }; /// /// var created = await client.CreateContextAsync(context); /// Console.WriteLine($"Created context: {created.Id}"); /// /// // Search contexts /// var results = await client.SearchContextsAsync("hello world"); /// foreach (var result in results) /// { /// Console.WriteLine($"Found: {result.Context.Path} (score: {result.Score:F3})"); /// } /// /// /// public class HCFSClient : IDisposable { private const string SdkVersion = "2.0.0"; private const string UserAgent = $"hcfs-dotnet/{SdkVersion}"; private readonly HttpClient _httpClient; private readonly HCFSConfig _config; private readonly IMemoryCache? _cache; private readonly ILogger? _logger; private readonly JsonSerializerOptions _jsonOptions; private readonly Dictionary _analytics; private readonly DateTime _sessionStart; private readonly SemaphoreSlim _rateLimitSemaphore; /// /// Initializes a new instance of the class. /// /// The client configuration. /// Optional HTTP client. If not provided, a new one will be created. /// Optional logger for diagnostic information. /// Thrown when config is null. /// Thrown when config is invalid. public HCFSClient(HCFSConfig config, HttpClient? httpClient = null, ILogger? logger = null) { _config = config ?? throw new ArgumentNullException(nameof(config)); _logger = logger; _sessionStart = DateTime.UtcNow; _analytics = new Dictionary(); // Validate configuration ValidateConfig(_config); // Initialize JSON options _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; // Initialize cache if enabled if (_config.Cache.Enabled) { var cacheOptions = new MemoryCacheOptions { SizeLimit = _config.Cache.MaxSize }; _cache = new MemoryCache(cacheOptions); } // Initialize rate limiting _rateLimitSemaphore = new SemaphoreSlim(_config.RateLimit.MaxConcurrentRequests); // Initialize HTTP client _httpClient = httpClient ?? CreateHttpClient(); _logger?.LogInformation("HCFS client initialized with base URL: {BaseUrl}", _config.BaseUrl); } /// /// Checks the API health status. /// /// Cancellation token. /// A task containing the health response. public async Task HealthCheckAsync(CancellationToken cancellationToken = default) { var request = new HttpRequestMessage(HttpMethod.Get, "/health"); return await ExecuteRequestAsync(request, cancellationToken); } /// /// Creates a new context. /// /// The context data to create. /// Cancellation token. /// A task containing the created context. /// Thrown when contextData is null. /// Thrown when contextData is invalid. public async Task CreateContextAsync(ContextCreate contextData, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(contextData); if (!PathValidator.IsValid(contextData.Path)) { throw new ValidationException($"Invalid context path: {contextData.Path}"); } // Normalize path var normalized = contextData with { Path = PathValidator.Normalize(contextData.Path) }; var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/contexts") { Content = JsonContent.Create(normalized, options: _jsonOptions) }; var response = await ExecuteRequestAsync>(request, cancellationToken); // Invalidate relevant cache entries InvalidateCache("/api/v1/contexts"); return response.Data; } /// /// Retrieves a context by ID. /// /// The context ID. /// Cancellation token. /// A task containing the context. /// Thrown when contextId is invalid. /// Thrown when context is not found. public async Task GetContextAsync(int contextId, CancellationToken cancellationToken = default) { if (contextId <= 0) { throw new ArgumentException("Context ID must be positive", nameof(contextId)); } var path = $"/api/v1/contexts/{contextId}"; var cacheKey = $"GET:{path}"; // Check cache first if (_cache?.TryGetValue(cacheKey, out Context? cached) == true && cached != null) { RecordAnalytics("cache_hit"); return cached; } RecordAnalytics("cache_miss"); var request = new HttpRequestMessage(HttpMethod.Get, path); var response = await ExecuteRequestAsync>(request, cancellationToken); var context = response.Data; // Cache the result if (_cache != null) { var cacheEntryOptions = new MemoryCacheEntryOptions { Size = 1, AbsoluteExpirationRelativeToNow = _config.Cache.Ttl }; _cache.Set(cacheKey, context, cacheEntryOptions); } return context; } /// /// Lists contexts with optional filtering and pagination. /// /// The context filter (optional). /// The pagination options (optional). /// Cancellation token. /// A task containing the context list response. public async Task ListContextsAsync( ContextFilter? filter = null, PaginationOptions? pagination = null, CancellationToken cancellationToken = default) { var queryParams = new List(); // Add filter parameters if (filter != null) { AddFilterParams(queryParams, filter); } // Add pagination parameters if (pagination != null) { AddPaginationParams(queryParams, pagination); } var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""; var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/contexts{query}"); return await ExecuteRequestAsync(request, cancellationToken); } /// /// Updates an existing context. /// /// The context ID. /// The context updates. /// Cancellation token. /// A task containing the updated context. /// Thrown when contextId is invalid. /// Thrown when updates is null. public async Task UpdateContextAsync( int contextId, ContextUpdate updates, CancellationToken cancellationToken = default) { if (contextId <= 0) { throw new ArgumentException("Context ID must be positive", nameof(contextId)); } ArgumentNullException.ThrowIfNull(updates); var path = $"/api/v1/contexts/{contextId}"; var request = new HttpRequestMessage(HttpMethod.Put, path) { Content = JsonContent.Create(updates, options: _jsonOptions) }; var response = await ExecuteRequestAsync>(request, cancellationToken); // Invalidate cache InvalidateCache($"GET:{path}"); InvalidateCache("/api/v1/contexts"); return response.Data; } /// /// Deletes a context. /// /// The context ID. /// Cancellation token. /// A task representing the delete operation. /// Thrown when contextId is invalid. public async Task DeleteContextAsync(int contextId, CancellationToken cancellationToken = default) { if (contextId <= 0) { throw new ArgumentException("Context ID must be positive", nameof(contextId)); } var path = $"/api/v1/contexts/{contextId}"; var request = new HttpRequestMessage(HttpMethod.Delete, path); await ExecuteRequestAsync(request, cancellationToken); // Invalidate cache InvalidateCache($"GET:{path}"); InvalidateCache("/api/v1/contexts"); } /// /// Searches contexts using various search methods. /// /// The search query. /// The search options (optional). /// Cancellation token. /// A task containing the search results. /// Thrown when query is null or empty. public async Task> SearchContextsAsync( string query, SearchOptions? options = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) { throw new ArgumentException("Query cannot be null or empty", nameof(query)); } var searchData = new Dictionary { ["query"] = query }; if (options != null) { AddSearchOptions(searchData, options); } var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/search") { Content = JsonContent.Create(searchData, options: _jsonOptions) }; var response = await ExecuteRequestAsync(request, cancellationToken); return response.Data; } /// /// Creates multiple contexts in batch. /// /// The list of contexts to create. /// Cancellation token. /// A task containing the batch result. /// Thrown when contexts is null or empty. /// Thrown when any context has an invalid path. public async Task BatchCreateContextsAsync( IEnumerable contexts, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(contexts); var contextList = contexts.ToList(); if (contextList.Count == 0) { throw new ArgumentException("Contexts cannot be empty", nameof(contexts)); } var startTime = DateTime.UtcNow; // Validate and normalize all contexts var normalizedContexts = new List(); foreach (var context in contextList) { if (!PathValidator.IsValid(context.Path)) { throw new ValidationException($"Invalid context path: {context.Path}"); } normalizedContexts.Add(context with { Path = PathValidator.Normalize(context.Path) }); } var batchData = new { contexts = normalizedContexts }; var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/contexts/batch") { Content = JsonContent.Create(batchData, options: _jsonOptions) }; var response = await ExecuteRequestAsync>(request, cancellationToken); var result = response.Data; // Calculate additional metrics var executionTime = DateTime.UtcNow - startTime; var successRate = (double)result.SuccessCount / result.TotalItems; // Invalidate cache InvalidateCache("/api/v1/contexts"); return result with { ExecutionTime = executionTime, SuccessRate = successRate }; } /// /// Iterates through all contexts with automatic pagination. /// /// The context filter (optional). /// The page size. /// Cancellation token. /// An async enumerable of contexts. public async IAsyncEnumerable IterateContextsAsync( ContextFilter? filter = null, int pageSize = 100, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (pageSize <= 0) pageSize = 100; int page = 1; while (true) { var pagination = new PaginationOptions { Page = page, PageSize = pageSize }; var response = await ListContextsAsync(filter, pagination, cancellationToken); var contexts = response.Data; if (!contexts.Any()) { yield break; } foreach (var context in contexts) { yield return context; } // Check if we've reached the end if (contexts.Count < pageSize || !response.Pagination.HasNext) { yield break; } page++; } } /// /// Gets comprehensive system statistics. /// /// Cancellation token. /// A task containing the statistics. public async Task GetStatisticsAsync(CancellationToken cancellationToken = default) { var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/stats"); return await ExecuteRequestAsync(request, cancellationToken); } /// /// Gets client analytics and usage statistics. /// /// The analytics data. public IReadOnlyDictionary GetAnalytics() { var result = new Dictionary { ["session_start"] = _sessionStart, ["operation_counts"] = new Dictionary(_analytics) }; if (_cache != null) { // Note: MemoryCache doesn't provide detailed stats like hit rate // This is a simplified version var cacheStats = new Dictionary { ["enabled"] = true, ["estimated_size"] = _cache.GetType().GetProperty("Count")?.GetValue(_cache) ?? 0 }; result["cache_stats"] = cacheStats; } else { result["cache_stats"] = new Dictionary { ["enabled"] = false }; } return result; } /// /// Clears the client cache. /// public void ClearCache() { if (_cache is MemoryCache memoryCache) { memoryCache.Compact(1.0); // Remove all entries } } /// /// Disposes the client and releases resources. /// public void Dispose() { _httpClient?.Dispose(); _cache?.Dispose(); _rateLimitSemaphore?.Dispose(); GC.SuppressFinalize(this); } // Private helper methods private HttpClient CreateHttpClient() { var handler = new HttpClientHandler(); var retryPolicy = HttpPolicyExtensions .HandleTransientHttpError() .Or() .WaitAndRetryAsync( _config.Retry.MaxAttempts, retryAttempt => TimeSpan.FromMilliseconds(_config.Retry.BaseDelay * Math.Pow(2, retryAttempt - 1)), onRetry: (outcome, timespan, retryCount, context) => { _logger?.LogWarning("Retry {RetryCount} for request after {Delay}ms", retryCount, timespan.TotalMilliseconds); }); var client = new HttpClient(handler); client.BaseAddress = new Uri(_config.BaseUrl); client.Timeout = _config.Timeout; client.DefaultRequestHeaders.Add("User-Agent", UserAgent); if (!string.IsNullOrEmpty(_config.ApiKey)) { client.DefaultRequestHeaders.Add("X-API-Key", _config.ApiKey); } if (!string.IsNullOrEmpty(_config.JwtToken)) { client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.JwtToken}"); } return client; } private async Task ExecuteRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { await _rateLimitSemaphore.WaitAsync(cancellationToken); try { RecordAnalytics("request"); using var response = await _httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { RecordAnalytics("error"); await HandleErrorResponseAsync(response); } var json = await response.Content.ReadAsStringAsync(cancellationToken); return JsonSerializer.Deserialize(json, _jsonOptions) ?? throw new HCFSException("Failed to deserialize response"); } finally { _rateLimitSemaphore.Release(); } } private async Task HandleErrorResponseAsync(HttpResponseMessage response) { var content = await response.Content.ReadAsStringAsync(); try { var errorResponse = JsonSerializer.Deserialize(content, _jsonOptions); var message = errorResponse?.Error ?? $"HTTP {(int)response.StatusCode} error"; throw response.StatusCode switch { HttpStatusCode.BadRequest => new ValidationException(message), HttpStatusCode.Unauthorized => new HCFSAuthenticationException(message), HttpStatusCode.NotFound => new HCFSNotFoundException(message), HttpStatusCode.TooManyRequests => new HCFSRateLimitException(message, response.Headers.RetryAfter?.Delta?.TotalSeconds), HttpStatusCode.InternalServerError or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout => new HCFSServerException(message, (int)response.StatusCode), _ => new HCFSException(message) }; } catch (JsonException) { throw new HCFSException($"HTTP {(int)response.StatusCode}: {content}"); } } private static void ValidateConfig(HCFSConfig config) { if (string.IsNullOrWhiteSpace(config.BaseUrl)) { throw new ValidationException("Base URL cannot be null or empty"); } if (!Uri.TryCreate(config.BaseUrl, UriKind.Absolute, out _)) { throw new ValidationException("Base URL must be a valid absolute URI"); } if (config.Timeout <= TimeSpan.Zero) { throw new ValidationException("Timeout must be positive"); } } private void AddFilterParams(List queryParams, ContextFilter filter) { if (!string.IsNullOrEmpty(filter.PathPrefix)) queryParams.Add($"path_prefix={Uri.EscapeDataString(filter.PathPrefix)}"); if (!string.IsNullOrEmpty(filter.Author)) queryParams.Add($"author={Uri.EscapeDataString(filter.Author)}"); if (filter.Status.HasValue) queryParams.Add($"status={filter.Status}"); if (filter.CreatedAfter.HasValue) queryParams.Add($"created_after={filter.CreatedAfter:O}"); if (filter.CreatedBefore.HasValue) queryParams.Add($"created_before={filter.CreatedBefore:O}"); if (!string.IsNullOrEmpty(filter.ContentContains)) queryParams.Add($"content_contains={Uri.EscapeDataString(filter.ContentContains)}"); if (filter.MinContentLength.HasValue) queryParams.Add($"min_content_length={filter.MinContentLength}"); if (filter.MaxContentLength.HasValue) queryParams.Add($"max_content_length={filter.MaxContentLength}"); } private static void AddPaginationParams(List queryParams, PaginationOptions pagination) { if (pagination.Page.HasValue) queryParams.Add($"page={pagination.Page}"); if (pagination.PageSize.HasValue) queryParams.Add($"page_size={pagination.PageSize}"); if (!string.IsNullOrEmpty(pagination.SortBy)) queryParams.Add($"sort_by={Uri.EscapeDataString(pagination.SortBy)}"); if (pagination.SortOrder.HasValue) queryParams.Add($"sort_order={pagination.SortOrder}"); } private static void AddSearchOptions(Dictionary searchData, SearchOptions options) { if (options.SearchType.HasValue) searchData["search_type"] = options.SearchType.ToString()!.ToLowerInvariant(); if (options.TopK.HasValue) searchData["top_k"] = options.TopK.Value; if (options.SimilarityThreshold.HasValue) searchData["similarity_threshold"] = options.SimilarityThreshold.Value; if (!string.IsNullOrEmpty(options.PathPrefix)) searchData["path_prefix"] = options.PathPrefix; if (options.SemanticWeight.HasValue) searchData["semantic_weight"] = options.SemanticWeight.Value; if (options.IncludeContent.HasValue) searchData["include_content"] = options.IncludeContent.Value; if (options.IncludeHighlights.HasValue) searchData["include_highlights"] = options.IncludeHighlights.Value; if (options.MaxHighlights.HasValue) searchData["max_highlights"] = options.MaxHighlights.Value; } private void InvalidateCache(string pattern) { // Note: MemoryCache doesn't provide a way to iterate or pattern-match keys // This would require a custom cache implementation or a different caching library // For now, we'll clear the entire cache when needed if (pattern.Contains("/api/v1/contexts") && _cache != null) { ClearCache(); } } private void RecordAnalytics(string operation) { lock (_analytics) { _analytics.TryGetValue(operation, out var count); _analytics[operation] = count + 1; } } }