< 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
100%
Covered lines: 40
Uncovered lines: 0
Coverable lines: 40
Total lines: 206
Line coverage: 100%
Branch coverage
100%
Covered branches: 16
Total branches: 16
Branch coverage: 100%
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%
TryValidateSource(...)100%66100%
TryValidateId(...)100%1010100%
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 Microsoft.Extensions.Caching.Memory;
 3
 4namespace Chronicis.Api.Services.ExternalLinks;
 5
 6/// <summary>
 7/// Consolidated service for external link operations.
 8/// Resolves providers via <see cref="IExternalLinkProviderRegistry"/>,
 9/// centralizes caching patterns, and standardizes exception handling.
 10/// </summary>
 11public sealed class ExternalLinkService : IExternalLinkService
 12{
 113    private static readonly TimeSpan SuggestionCacheDuration = TimeSpan.FromMinutes(2);
 114    private static readonly TimeSpan ContentCacheDuration = TimeSpan.FromMinutes(5);
 15
 16    private readonly IExternalLinkProviderRegistry _registry;
 17    private readonly IResourceProviderRepository _resourceProviderRepository;
 18    private readonly IMemoryCache _cache;
 19    private readonly ILogger<ExternalLinkService> _logger;
 20
 21    public ExternalLinkService(
 22        IExternalLinkProviderRegistry registry,
 23        IResourceProviderRepository resourceProviderRepository,
 24        IMemoryCache cache,
 25        ILogger<ExternalLinkService> logger)
 26    {
 2427        _registry = registry;
 2428        _resourceProviderRepository = resourceProviderRepository;
 2429        _cache = cache;
 2430        _logger = logger;
 2431    }
 32
 33    /// <inheritdoc />
 34    public async Task<IReadOnlyList<ExternalLinkSuggestion>> GetSuggestionsAsync(
 35        Guid? worldId,
 36        string source,
 37        string query,
 38        CancellationToken ct)
 39    {
 40        if (string.IsNullOrWhiteSpace(source))
 41        {
 42            return Array.Empty<ExternalLinkSuggestion>();
 43        }
 44
 45        var resolvedSource = source;
 46
 47        // Check world-level provider enablement and resolve lookup-key aliases
 48        if (worldId.HasValue)
 49        {
 50            var worldProviders = await _resourceProviderRepository.GetWorldProvidersAsync(worldId.Value);
 51            var enabledProvider = worldProviders.FirstOrDefault(p =>
 52                p.IsEnabled
 53                && (p.Provider.Code.Equals(source, StringComparison.OrdinalIgnoreCase)
 54                    || p.LookupKey.Equals(source, StringComparison.OrdinalIgnoreCase)));
 55
 56            if (enabledProvider == default)
 57            {
 58                _logger.LogTraceSanitized(
 59                    "Provider {Source} is not enabled for world {WorldId}", source, worldId);
 60                return Array.Empty<ExternalLinkSuggestion>();
 61            }
 62
 63            resolvedSource = enabledProvider.Provider.Code;
 64        }
 65
 66        query ??= string.Empty;
 67
 68        var cacheKey = BuildSuggestionCacheKey(resolvedSource, query);
 69        if (_cache.TryGetValue<IReadOnlyList<ExternalLinkSuggestion>>(cacheKey, out var cached) && cached != null)
 70        {
 71            return cached;
 72        }
 73
 74        var provider = _registry.GetProvider(resolvedSource);
 75        if (provider == null)
 76        {
 77            return Array.Empty<ExternalLinkSuggestion>();
 78        }
 79
 80        IReadOnlyList<ExternalLinkSuggestion> suggestions;
 81        try
 82        {
 83            suggestions = await provider.SearchAsync(query, ct);
 84        }
 85        catch (Exception ex)
 86        {
 87            _logger.LogErrorSanitized(
 88                ex,
 89                "External link provider {Source} failed to search for query {Query}",
 90                resolvedSource, query);
 91            return Array.Empty<ExternalLinkSuggestion>();
 92        }
 93
 94        _cache.Set(cacheKey, suggestions, SuggestionCacheDuration);
 95        return suggestions;
 96    }
 97
 98    /// <inheritdoc />
 99    public async Task<ExternalLinkContent?> GetContentAsync(
 100        string source,
 101        string id,
 102        CancellationToken ct)
 103    {
 104        if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(id))
 105        {
 106            return null;
 107        }
 108
 109        var cacheKey = BuildContentCacheKey(source, id);
 110        if (_cache.TryGetValue<ExternalLinkContent>(cacheKey, out var cached) && cached != null)
 111        {
 112            return cached;
 113        }
 114
 115        var provider = _registry.GetProvider(source);
 116        if (provider == null)
 117        {
 118            return null;
 119        }
 120
 121        ExternalLinkContent content;
 122        try
 123        {
 124            content = await provider.GetContentAsync(id, ct);
 125        }
 126        catch (Exception ex)
 127        {
 128            _logger.LogErrorSanitized(
 129                ex,
 130                "External link provider {Source} failed to get content for {Id}",
 131                source, id);
 132            return null;
 133        }
 134
 135        _cache.Set(cacheKey, content, ContentCacheDuration);
 136        return content;
 137    }
 138
 139    /// <inheritdoc />
 140    public bool TryValidateSource(string source, out string error)
 141    {
 4142        if (string.IsNullOrWhiteSpace(source))
 143        {
 1144            error = "Source is required.";
 1145            return false;
 146        }
 147
 3148        var provider = _registry.GetProvider(source);
 3149        if (provider == null)
 150        {
 2151            var available = _registry
 2152                .GetAllProviders()
 2153                .Select(p => p.Key)
 2154                .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
 2155                .ToList();
 156
 2157            error = available.Count == 0
 2158                ? $"Unknown external link source '{source}'."
 2159                : $"Unknown external link source '{source}'. Available sources: {string.Join(", ", available)}.";
 2160            return false;
 161        }
 162
 1163        error = string.Empty;
 1164        return true;
 165    }
 166
 167    /// <inheritdoc />
 168    public bool TryValidateId(string source, string id, out string error)
 169    {
 5170        if (string.IsNullOrWhiteSpace(id))
 171        {
 1172            error = "Id is required.";
 1173            return false;
 174        }
 175
 4176        if (Uri.TryCreate(id, UriKind.Absolute, out _))
 177        {
 1178            error = "External link id must be a relative path.";
 1179            return false;
 180        }
 181
 3182        if (!Uri.TryCreate(id, UriKind.Relative, out _))
 183        {
 1184            error = "External link id must be a relative path.";
 1185            return false;
 186        }
 187
 2188        if (source.Equals("srd", StringComparison.OrdinalIgnoreCase)
 2189            && !id.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
 190        {
 1191            error = "SRD ids must start with /api/.";
 1192            return false;
 193        }
 194
 1195        error = string.Empty;
 1196        return true;
 197    }
 198
 199    // -- Cache key helpers --
 200
 201    internal static string BuildSuggestionCacheKey(string source, string query)
 8202        => $"external-links:suggestions:{source}:{query}".ToLowerInvariant();
 203
 204    internal static string BuildContentCacheKey(string source, string id)
 5205        => $"external-links:content:{source}:{id}".ToLowerInvariant();
 206}