File: Mcp\Docs\DocsFetcher.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net;
using System.Net.Http.Headers;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Mcp.Docs;
 
/// <summary>
/// Service for fetching aspire.dev documentation content.
/// </summary>
internal interface IDocsFetcher
{
    /// <summary>
    /// Fetches the small (abridged) documentation content.
    /// Uses ETag-based caching to avoid re-downloading unchanged content.
    /// </summary>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns>The documentation content, or null if fetch failed.</returns>
    Task<string?> FetchDocsAsync(CancellationToken cancellationToken = default);
}
 
/// <summary>
/// Default implementation of <see cref="IDocsFetcher"/> that fetches from aspire.dev with ETag caching.
/// </summary>
internal sealed class DocsFetcher(HttpClient httpClient, IDocsCache cache, ILogger<DocsFetcher> logger) : IDocsFetcher
{
    private const string SmallDocsUrl = "https://aspire.dev/llms-small.txt";
 
    private readonly HttpClient _httpClient = httpClient;
    private readonly IDocsCache _cache = cache;
    private readonly ILogger<DocsFetcher> _logger = logger;
 
    public async Task<string?> FetchDocsAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            // Get cached ETag for conditional request
            var cachedETag = await _cache.GetETagAsync(SmallDocsUrl, cancellationToken).ConfigureAwait(false);
 
            using var request = new HttpRequestMessage(HttpMethod.Get, SmallDocsUrl);
 
            // Add If-None-Match header if we have a cached ETag
            if (!string.IsNullOrEmpty(cachedETag))
            {
                request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(cachedETag));
            }
 
            _logger.LogDebug("Fetching aspire.dev docs from {Url}, cached ETag: {ETag}", SmallDocsUrl, cachedETag ?? "(none)");
 
            using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 
            // If not modified, return cached content
            if (response is { StatusCode: HttpStatusCode.NotModified })
            {
                _logger.LogDebug("Server returned 304 Not Modified, using cached content");
 
                var cached = await _cache.GetAsync(SmallDocsUrl, cancellationToken).ConfigureAwait(false);
 
                if (cached is not null)
                {
                    return cached;
                }
 
                // Cache was cleared but ETag still exists - clear ETag and retry without If-None-Match
                _logger.LogDebug("Cache content missing despite valid ETag, clearing ETag and retrying");
                await _cache.SetETagAsync(SmallDocsUrl, null, cancellationToken).ConfigureAwait(false);
 
                using var retryRequest = new HttpRequestMessage(HttpMethod.Get, SmallDocsUrl);
                using var retryResponse = await _httpClient.SendAsync(retryRequest, cancellationToken).ConfigureAwait(false);
 
                retryResponse.EnsureSuccessStatusCode();
 
                var retryContent = await retryResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 
                // Store the new ETag if present
                var retryETag = retryResponse.Headers.ETag?.Tag;
                if (!string.IsNullOrEmpty(retryETag))
                {
                    await _cache.SetETagAsync(SmallDocsUrl, retryETag, cancellationToken).ConfigureAwait(false);
 
                    _logger.LogDebug("Stored new ETag after retry: {ETag}", retryETag);
                }
 
                // Cache the content
                await _cache.SetAsync(SmallDocsUrl, retryContent, cancellationToken).ConfigureAwait(false);
 
                _logger.LogInformation("Fetched aspire.dev docs after retry, length: {Length} chars", retryContent.Length);
 
                return retryContent;
            }
 
            response.EnsureSuccessStatusCode();
 
            var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 
            // Store the new ETag if present
            var newETag = response.Headers.ETag?.Tag;
            if (!string.IsNullOrEmpty(newETag))
            {
                await _cache.SetETagAsync(SmallDocsUrl, newETag, cancellationToken).ConfigureAwait(false);
                _logger.LogDebug("Stored new ETag: {ETag}", newETag);
            }
 
            // Cache the content
            await _cache.SetAsync(SmallDocsUrl, content, cancellationToken).ConfigureAwait(false);
 
            _logger.LogInformation("Fetched aspire.dev docs, length: {Length} chars", content.Length);
 
            return content;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to fetch aspire.dev docs");
 
            // Try to return cached content on error
            var cached = await _cache.GetAsync(SmallDocsUrl, cancellationToken).ConfigureAwait(false);
 
            if (cached is not null)
            {
                _logger.LogDebug("Returning cached content after fetch failure");
 
                return cached;
            }
 
            return null;
        }
    }
}