File: Documentation\FileBackedDocumentContentCache.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.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.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Documentation;
 
/// <summary>
/// Provides file-backed cached text and JSON content with ETag support.
/// </summary>
internal sealed class FileBackedDocumentContentCache(
    IMemoryCache memoryCache,
    CliExecutionContext executionContext,
    string subdirectory,
    ILogger logger)
{
    private readonly IMemoryCache _memoryCache = memoryCache;
    private readonly ILogger _logger = logger;
    private readonly DirectoryInfo _diskCacheDirectory = new(Path.Combine(executionContext.CacheDirectory.FullName, subdirectory));
 
    /// <summary>
    /// Gets cached text content for the specified key.
    /// </summary>
    /// <param name="key">The cache key.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns>The cached content, or <c>null</c> if it is not available.</returns>
    public async Task<string?> GetAsync(string key, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        var cacheKey = GetMemoryCacheKey("text", key);
        if (_memoryCache.TryGetValue(cacheKey, out string? content))
        {
            _logger.LogDebug("Content cache memory hit for key: {Key}", key);
            return content;
        }
 
        var diskContent = await ReadTextFileAsync(GetTextFilePath(key), cancellationToken).ConfigureAwait(false);
        if (diskContent is not null)
        {
            _memoryCache.Set(cacheKey, diskContent);
            _logger.LogDebug("Content cache disk hit for key: {Key}", key);
            return diskContent;
        }
 
        _logger.LogDebug("Content cache miss for key: {Key}", key);
        return null;
    }
 
    /// <summary>
    /// Stores text content for the specified key.
    /// </summary>
    /// <param name="key">The cache key.</param>
    /// <param name="content">The content to store.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public async Task SetAsync(string key, string content, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        _memoryCache.Set(GetMemoryCacheKey("text", key), content);
        await WriteTextFileAsync(GetTextFilePath(key), content, cancellationToken).ConfigureAwait(false);
        _logger.LogDebug("Content cache set for key: {Key}", key);
    }
 
    /// <summary>
    /// Gets the cached ETag for the specified key.
    /// </summary>
    /// <param name="key">The cache key.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns>The cached ETag, or <c>null</c> if it is not available.</returns>
    public async Task<string?> GetETagAsync(string key, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        var cacheKey = GetMemoryCacheKey("etag", key);
        if (_memoryCache.TryGetValue(cacheKey, out string? etag))
        {
            _logger.LogDebug("ETag cache memory hit for key: {Key}", key);
            return etag;
        }
 
        var diskETag = await ReadTextFileAsync(GetETagFilePath(key), cancellationToken).ConfigureAwait(false);
        if (diskETag is not null)
        {
            var trimmed = diskETag.Trim();
            _memoryCache.Set(cacheKey, trimmed);
            _logger.LogDebug("ETag cache disk hit for key: {Key}", key);
            return trimmed;
        }
 
        _logger.LogDebug("ETag cache miss for key: {Key}", key);
        return null;
    }
 
    /// <summary>
    /// Stores or clears the cached ETag for the specified key.
    /// </summary>
    /// <param name="key">The cache key.</param>
    /// <param name="etag">The ETag to store, or <c>null</c> to clear it.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public async Task SetETagAsync(string key, string? etag, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        var cacheKey = GetMemoryCacheKey("etag", key);
        if (etag is null)
        {
            _memoryCache.Remove(cacheKey);
            DeleteFileQuietly(GetETagFilePath(key));
            _logger.LogDebug("ETag cache cleared for key: {Key}", key);
            return;
        }
 
        _memoryCache.Set(cacheKey, etag);
        await WriteTextFileAsync(GetETagFilePath(key), etag, cancellationToken).ConfigureAwait(false);
        _logger.LogDebug("ETag cache set for key: {Key}", key);
    }
 
    /// <summary>
    /// Removes cached text content for the specified key.
    /// </summary>
    /// <param name="key">The cache key.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public Task InvalidateAsync(string key, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        _memoryCache.Remove(GetMemoryCacheKey("text", key));
        DeleteFileQuietly(GetTextFilePath(key));
        _logger.LogDebug("Content cache invalidated key: {Key}", key);
        return Task.CompletedTask;
    }
 
    /// <summary>
    /// Removes cached JSON content for the specified key.
    /// </summary>
    /// <param name="key">The cache key.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public Task InvalidateJsonAsync(string key, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        _memoryCache.Remove(GetMemoryCacheKey("json", key));
        DeleteFileQuietly(GetJsonFilePath(key));
        _logger.LogDebug("JSON cache invalidated key: {Key}", key);
        return Task.CompletedTask;
    }
 
    /// <summary>
    /// Gets cached JSON content for the specified key.
    /// </summary>
    /// <typeparam name="T">The JSON payload type.</typeparam>
    /// <param name="key">The cache key.</param>
    /// <param name="jsonTypeInfo">The source-generated JSON metadata.</param>
    /// <param name="requiredETagKey">An optional key whose ETag must still exist for the cached JSON to remain valid.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns>The cached JSON value, or <c>default</c> when it is not available.</returns>
    public async Task<T?> GetJsonAsync<T>(
        string key,
        JsonTypeInfo<T> jsonTypeInfo,
        string? requiredETagKey = null,
        CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        if (!string.IsNullOrEmpty(requiredETagKey))
        {
            var etag = await GetETagAsync(requiredETagKey, cancellationToken).ConfigureAwait(false);
            if (string.IsNullOrEmpty(etag))
            {
                _memoryCache.Remove(GetMemoryCacheKey("json", key));
                DeleteFileQuietly(GetJsonFilePath(key));
                return default;
            }
        }
 
        var cacheKey = GetMemoryCacheKey("json", key);
        if (_memoryCache.TryGetValue(cacheKey, out T? cached))
        {
            _logger.LogDebug("JSON cache memory hit for key: {Key}", key);
            return cached;
        }
 
        try
        {
            var filePath = GetJsonFilePath(key);
            if (!File.Exists(filePath))
            {
                _logger.LogDebug("JSON cache miss for key: {Key}", key);
                return default;
            }
 
            await using var stream = File.OpenRead(filePath);
            var value = await JsonSerializer.DeserializeAsync(stream, jsonTypeInfo, cancellationToken).ConfigureAwait(false);
            if (value is not null)
            {
                _memoryCache.Set(cacheKey, value);
                _logger.LogDebug("JSON cache disk hit for key: {Key}", key);
            }
 
            return value;
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to read JSON cache for key: {Key}", key);
            return default;
        }
    }
 
    /// <summary>
    /// Stores JSON content for the specified key.
    /// </summary>
    /// <typeparam name="T">The JSON payload type.</typeparam>
    /// <param name="key">The cache key.</param>
    /// <param name="value">The value to store.</param>
    /// <param name="jsonTypeInfo">The source-generated JSON metadata.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    public async Task SetJsonAsync<T>(
        string key,
        T value,
        JsonTypeInfo<T> jsonTypeInfo,
        CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        _memoryCache.Set(GetMemoryCacheKey("json", key), value);
 
        try
        {
            EnsureCacheDirectoryExists();
 
            var filePath = GetJsonFilePath(key);
            var tempPath = filePath + ".tmp";
 
            await using (var stream = File.Create(tempPath))
            {
                await JsonSerializer.SerializeAsync(stream, value, jsonTypeInfo, cancellationToken).ConfigureAwait(false);
            }
 
            File.Move(tempPath, filePath, overwrite: true);
            _logger.LogDebug("JSON cache set for key: {Key}", key);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to write JSON cache for key: {Key}", key);
        }
    }
 
    private string GetTextFilePath(string key) => Path.Combine(_diskCacheDirectory.FullName, $"{SanitizeFileName(key)}.txt");
 
    private string GetETagFilePath(string key) => Path.Combine(_diskCacheDirectory.FullName, $"{SanitizeFileName(key)}.etag.txt");
 
    private string GetJsonFilePath(string key) => Path.Combine(_diskCacheDirectory.FullName, $"{SanitizeFileName(key)}.json");
 
    private string GetMemoryCacheKey(string kind, string key) => $"{_diskCacheDirectory.Name}:{kind}:{key}";
 
    private static string SanitizeFileName(string key)
    {
        var result = new char[key.Length];
        for (var i = 0; i < key.Length; i++)
        {
            var c = key[i];
            result[i] = char.IsLetterOrDigit(c) || c is '.' or '-' or '_' ? c : '_';
        }
 
        return new string(result);
    }
 
    private async Task<string?> ReadTextFileAsync(string filePath, CancellationToken cancellationToken)
    {
        try
        {
            if (!File.Exists(filePath))
            {
                return null;
            }
 
            return await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to read cache file: {FilePath}", filePath);
            return null;
        }
    }
 
    private async Task WriteTextFileAsync(string filePath, string content, CancellationToken cancellationToken)
    {
        try
        {
            EnsureCacheDirectoryExists();
 
            var tempPath = filePath + ".tmp";
            await File.WriteAllTextAsync(tempPath, content, cancellationToken).ConfigureAwait(false);
            File.Move(tempPath, filePath, overwrite: true);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to write cache file: {FilePath}", filePath);
        }
    }
 
    private void EnsureCacheDirectoryExists()
    {
        if (_diskCacheDirectory.Exists)
        {
            return;
        }
 
        try
        {
            _diskCacheDirectory.Create();
            _diskCacheDirectory.Refresh();
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to create cache directory: {Directory}", _diskCacheDirectory.FullName);
        }
    }
 
    private void DeleteFileQuietly(string filePath)
    {
        try
        {
            if (File.Exists(filePath))
            {
                File.Delete(filePath);
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to delete cache file: {FilePath}", filePath);
        }
    }
}