< Summary

Information
Class: Chronicis.Api.Services.ExternalLinks.ExternalLinkService
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/ExternalLinkService.cs
Line coverage
96%
Covered lines: 88
Uncovered lines: 3
Coverable lines: 91
Total lines: 200
Line coverage: 96.7%
Branch coverage
93%
Covered branches: 41
Total branches: 44
Branch coverage: 93.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
GetSuggestionsAsync()93.75%1616100%
GetContentAsync()100%1010100%
TryValidateSource(...)100%6693.75%
TryValidateId(...)90%101086.66%
BuildSuggestionCacheKey(...)100%11100%
BuildContentCacheKey(...)100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/ExternalLinkService.cs

#LineLine coverage
 1using Chronicis.Api.Repositories;
 2using Chronicis.Shared.Extensions;
 3using Microsoft.Extensions.Caching.Memory;
 4
 5namespace Chronicis.Api.Services.ExternalLinks;
 6
 7/// <summary>
 8/// Consolidated service for external link operations.
 9/// Resolves providers via <see cref="IExternalLinkProviderRegistry"/>,
 10/// centralizes caching patterns, and standardizes exception handling.
 11/// </summary>
 12public class ExternalLinkService : IExternalLinkService
 13{
 114    private static readonly TimeSpan SuggestionCacheDuration = TimeSpan.FromMinutes(2);
 115    private static readonly TimeSpan ContentCacheDuration = TimeSpan.FromMinutes(5);
 16
 17    private readonly IExternalLinkProviderRegistry _registry;
 18    private readonly IResourceProviderRepository _resourceProviderRepository;
 19    private readonly IMemoryCache _cache;
 20    private readonly ILogger<ExternalLinkService> _logger;
 21
 2222    public ExternalLinkService(
 2223        IExternalLinkProviderRegistry registry,
 2224        IResourceProviderRepository resourceProviderRepository,
 2225        IMemoryCache cache,
 2226        ILogger<ExternalLinkService> logger)
 27    {
 2228        _registry = registry;
 2229        _resourceProviderRepository = resourceProviderRepository;
 2230        _cache = cache;
 2231        _logger = logger;
 2232    }
 33
 34    /// <inheritdoc />
 35    public async Task<IReadOnlyList<ExternalLinkSuggestion>> GetSuggestionsAsync(
 36        Guid? worldId,
 37        string source,
 38        string query,
 39        CancellationToken ct)
 40    {
 841        if (string.IsNullOrWhiteSpace(source))
 42        {
 143            return Array.Empty<ExternalLinkSuggestion>();
 44        }
 45
 46        // Check world-level provider enablement
 747        if (worldId.HasValue)
 48        {
 249            var worldProviders = await _resourceProviderRepository.GetWorldProvidersAsync(worldId.Value);
 450            var enabledProvider = worldProviders.FirstOrDefault(p => p.Provider.Code == source && p.IsEnabled);
 51
 252            if (enabledProvider == default)
 53            {
 154                _logger.LogDebugSanitized(
 155                    "Provider {Source} is not enabled for world {WorldId}", source, worldId);
 156                return Array.Empty<ExternalLinkSuggestion>();
 57            }
 58        }
 59
 660        query ??= string.Empty;
 61
 662        var cacheKey = BuildSuggestionCacheKey(source, query);
 663        if (_cache.TryGetValue<IReadOnlyList<ExternalLinkSuggestion>>(cacheKey, out var cached) && cached != null)
 64        {
 165            return cached;
 66        }
 67
 568        var provider = _registry.GetProvider(source);
 569        if (provider == null)
 70        {
 171            return Array.Empty<ExternalLinkSuggestion>();
 72        }
 73
 74        IReadOnlyList<ExternalLinkSuggestion> suggestions;
 75        try
 76        {
 477            suggestions = await provider.SearchAsync(query, ct);
 378        }
 179        catch (Exception ex)
 80        {
 181            _logger.LogErrorSanitized(
 182                ex,
 183                "External link provider {Source} failed to search for query {Query}",
 184                source, query);
 185            return Array.Empty<ExternalLinkSuggestion>();
 86        }
 87
 388        _cache.Set(cacheKey, suggestions, SuggestionCacheDuration);
 389        return suggestions;
 890    }
 91
 92    /// <inheritdoc />
 93    public async Task<ExternalLinkContent?> GetContentAsync(
 94        string source,
 95        string id,
 96        CancellationToken ct)
 97    {
 698        if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(id))
 99        {
 2100            return null;
 101        }
 102
 4103        var cacheKey = BuildContentCacheKey(source, id);
 4104        if (_cache.TryGetValue<ExternalLinkContent>(cacheKey, out var cached) && cached != null)
 105        {
 1106            return cached;
 107        }
 108
 3109        var provider = _registry.GetProvider(source);
 3110        if (provider == null)
 111        {
 1112            return null;
 113        }
 114
 115        ExternalLinkContent content;
 116        try
 117        {
 2118            content = await provider.GetContentAsync(id, ct);
 1119        }
 1120        catch (Exception ex)
 121        {
 1122            _logger.LogErrorSanitized(
 1123                ex,
 1124                "External link provider {Source} failed to get content for {Id}",
 1125                source, id);
 1126            return null;
 127        }
 128
 1129        _cache.Set(cacheKey, content, ContentCacheDuration);
 1130        return content;
 6131    }
 132
 133    /// <inheritdoc />
 134    public bool TryValidateSource(string source, out string error)
 135    {
 4136        if (string.IsNullOrWhiteSpace(source))
 137        {
 1138            error = "Source is required.";
 1139            return false;
 140        }
 141
 3142        var provider = _registry.GetProvider(source);
 3143        if (provider == null)
 144        {
 2145            var available = _registry
 2146                .GetAllProviders()
 1147                .Select(p => p.Key)
 0148                .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
 2149                .ToList();
 150
 2151            error = available.Count == 0
 2152                ? $"Unknown external link source '{source}'."
 2153                : $"Unknown external link source '{source}'. Available sources: {string.Join(", ", available)}.";
 2154            return false;
 155        }
 156
 1157        error = string.Empty;
 1158        return true;
 159    }
 160
 161    /// <inheritdoc />
 162    public bool TryValidateId(string source, string id, out string error)
 163    {
 4164        if (string.IsNullOrWhiteSpace(id))
 165        {
 1166            error = "Id is required.";
 1167            return false;
 168        }
 169
 3170        if (Uri.TryCreate(id, UriKind.Absolute, out _))
 171        {
 1172            error = "External link id must be a relative path.";
 1173            return false;
 174        }
 175
 2176        if (!Uri.TryCreate(id, UriKind.Relative, out _))
 177        {
 0178            error = "External link id must be a relative path.";
 0179            return false;
 180        }
 181
 2182        if (source.Equals("srd", StringComparison.OrdinalIgnoreCase)
 2183            && !id.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
 184        {
 1185            error = "SRD ids must start with /api/.";
 1186            return false;
 187        }
 188
 1189        error = string.Empty;
 1190        return true;
 191    }
 192
 193    // -- Cache key helpers --
 194
 195    internal static string BuildSuggestionCacheKey(string source, string query)
 7196        => $"external-links:suggestions:{source}:{query}".ToLowerInvariant();
 197
 198    internal static string BuildContentCacheKey(string source, string id)
 5199        => $"external-links:content:{source}:{id}".ToLowerInvariant();
 200}