< Summary

Information
Class: Chronicis.Api.Services.ExternalLinks.Open5eExternalLinkProvider
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/Open5eExternalLinkProvider.cs
Line coverage
100%
Covered lines: 64
Uncovered lines: 0
Coverable lines: 64
Total lines: 260
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%
get_Key()100%11100%
GetCategorySuggestions(...)100%66100%
ParseSearchResult(...)100%44100%
BuildWebUrl(...)100%11100%
ParseId(...)100%44100%
FindStrategy(...)100%22100%
CreateEmptyContent(...)100%11100%
CreateStrategies()100%11100%

File(s)

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

#LineLine coverage
 1using System.Collections.Frozen;
 2using System.Text.Json;
 3using static Chronicis.Api.Services.ExternalLinks.Open5eJsonHelpers;
 4
 5namespace Chronicis.Api.Services.ExternalLinks;
 6
 7public sealed class Open5eExternalLinkProvider : IExternalLinkProvider
 8{
 9    private const string SourceKey = "srd";
 10    private const string HttpClientName = "Open5eApi";
 11
 12    private readonly IHttpClientFactory _httpClientFactory;
 13    private readonly ILogger<Open5eExternalLinkProvider> _logger;
 14
 115    private static readonly FrozenDictionary<string, IOpen5eCategoryStrategy> Strategies =
 116        CreateStrategies();
 17
 18    public Open5eExternalLinkProvider(
 19        IHttpClientFactory httpClientFactory,
 20        ILogger<Open5eExternalLinkProvider> logger)
 21    {
 3122        _httpClientFactory = httpClientFactory;
 3123        _logger = logger;
 3124    }
 25
 6426    public string Key => SourceKey;
 27
 28    public async Task<IReadOnlyList<ExternalLinkSuggestion>> SearchAsync(string query, CancellationToken ct)
 29    {
 30        if (string.IsNullOrWhiteSpace(query))
 31            return GetCategorySuggestions(string.Empty);
 32
 33        var slashIndex = query.IndexOf('/');
 34
 35        if (slashIndex < 0)
 36            return GetCategorySuggestions(query);
 37
 38        var categoryPart = query[..slashIndex].Trim().ToLowerInvariant();
 39        var searchTerm = slashIndex < query.Length - 1 ? query[(slashIndex + 1)..].Trim() : string.Empty;
 40
 41        var strategy = FindStrategy(categoryPart);
 42        if (strategy == null)
 43            return GetCategorySuggestions(categoryPart);
 44
 45        if (string.IsNullOrWhiteSpace(searchTerm))
 46            return Array.Empty<ExternalLinkSuggestion>();
 47
 48        var client = _httpClientFactory.CreateClient(HttpClientName);
 49        var results = await SearchCategoryAsync(client, strategy, searchTerm, ct);
 50
 51        return results
 52            .OrderBy(s => s.Title)
 53            .Take(20)
 54            .ToList();
 55    }
 56
 57    public async Task<ExternalLinkContent> GetContentAsync(string id, CancellationToken ct)
 58    {
 59        if (string.IsNullOrWhiteSpace(id))
 60            return CreateEmptyContent(id ?? string.Empty);
 61
 62        var (category, itemKey) = ParseId(id);
 63        if (string.IsNullOrEmpty(category) || string.IsNullOrEmpty(itemKey))
 64        {
 65            _logger.LogWarningSanitized("Invalid id format: {Id}", id);
 66            return CreateEmptyContent(id);
 67        }
 68
 69        if (!Strategies.TryGetValue(category, out var strategy))
 70        {
 71            _logger.LogWarningSanitized("Unknown category in id: {Category}", category);
 72            return CreateEmptyContent(id);
 73        }
 74
 75        var client = _httpClientFactory.CreateClient(HttpClientName);
 76
 77        try
 78        {
 79            var url = $"/v2/{strategy.Endpoint}/{itemKey}/";
 80            _logger.LogTraceSanitized("Fetching Open5e content: {Url}", url);
 81
 82            using var response = await client.GetAsync(url, ct);
 83            if (!response.IsSuccessStatusCode)
 84            {
 85                _logger.LogWarningSanitized("Open5e content fetch failed for {Id} with status {StatusCode}",
 86                    id, response.StatusCode);
 87                return CreateEmptyContent(id);
 88            }
 89
 90            await using var stream = await response.Content.ReadAsStreamAsync(ct);
 91            using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
 92
 93            var root = document.RootElement;
 94            var title = GetString(root, "name") ?? itemKey;
 95            var markdown = strategy.BuildMarkdown(root, title);
 96            var attribution = BuildAttribution(root);
 97
 98            return new ExternalLinkContent
 99            {
 100                Source = Key,
 101                Id = id,
 102                Title = title,
 103                Kind = strategy.DisplayName,
 104                Markdown = markdown,
 105                Attribution = attribution,
 106                ExternalUrl = BuildWebUrl(strategy, itemKey)
 107            };
 108        }
 109        catch (Exception ex)
 110        {
 111            _logger.LogErrorSanitized(ex, "Open5e content fetch failed for {Id}", id);
 112            return CreateEmptyContent(id);
 113        }
 114    }
 115
 116    private List<ExternalLinkSuggestion> GetCategorySuggestions(string filter)
 117    {
 3118        var suggestions = new List<ExternalLinkSuggestion>();
 119
 66120        foreach (var strategy in Strategies.Values)
 121        {
 30122            if (!string.IsNullOrEmpty(filter) &&
 30123                !strategy.CategoryKey.StartsWith(filter, StringComparison.OrdinalIgnoreCase))
 124            {
 125                continue;
 126            }
 127
 11128            suggestions.Add(new ExternalLinkSuggestion
 11129            {
 11130                Source = Key,
 11131                Id = $"_category/{strategy.CategoryKey}",
 11132                Title = char.ToUpper(strategy.CategoryKey[0]) + strategy.CategoryKey[1..],
 11133                Subtitle = $"Browse {strategy.DisplayName}s",
 11134                Category = "_category",
 11135                Icon = strategy.Icon
 11136            });
 137        }
 138
 3139        return suggestions.OrderBy(s => s.Title).ToList();
 140    }
 141
 142    private async Task<List<ExternalLinkSuggestion>> SearchCategoryAsync(
 143        HttpClient client,
 144        IOpen5eCategoryStrategy strategy,
 145        string query,
 146        CancellationToken ct)
 147    {
 148        try
 149        {
 150            var docFilter = $"document__gamesystem__key={strategy.DocumentSlug}";
 151            var url = $"/v2/{strategy.Endpoint}/?name__contains={Uri.EscapeDataString(query)}&{docFilter}&limit=50";
 152
 153            using var response = await client.GetAsync(url, ct);
 154            if (!response.IsSuccessStatusCode)
 155            {
 156                _logger.LogWarningSanitized("Open5e search failed for {Category} with status {StatusCode}",
 157                    strategy.CategoryKey, response.StatusCode);
 158                return new List<ExternalLinkSuggestion>();
 159            }
 160
 161            await using var stream = await response.Content.ReadAsStreamAsync(ct);
 162            using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
 163
 164            var results = document.RootElement.GetProperty("results");
 165            var suggestions = new List<ExternalLinkSuggestion>();
 166
 167            foreach (var item in results.EnumerateArray())
 168            {
 169                var name = GetString(item, "name");
 170                if (string.IsNullOrEmpty(name) ||
 171                    !name.Contains(query, StringComparison.OrdinalIgnoreCase))
 172                {
 173                    continue;
 174                }
 175
 176                var suggestion = ParseSearchResult(item, strategy);
 177                if (suggestion != null)
 178                    suggestions.Add(suggestion);
 179            }
 180
 181            return suggestions;
 182        }
 183        catch (Exception ex)
 184        {
 185            _logger.LogErrorSanitized(ex, "Open5e search failed for category {Category} query {Query}",
 186                strategy.CategoryKey, query);
 187            return new List<ExternalLinkSuggestion>();
 188        }
 189    }
 190
 191    private ExternalLinkSuggestion? ParseSearchResult(JsonElement item, IOpen5eCategoryStrategy strategy)
 192    {
 34193        var itemKey = GetString(item, "key");
 34194        var name = GetString(item, "name");
 195
 34196        if (string.IsNullOrEmpty(itemKey) || string.IsNullOrEmpty(name))
 1197            return null;
 198
 33199        return new ExternalLinkSuggestion
 33200        {
 33201            Source = Key,
 33202            Id = $"{strategy.CategoryKey}/{itemKey}",
 33203            Title = name,
 33204            Subtitle = strategy.BuildSubtitle(item),
 33205            Category = strategy.CategoryKey,
 33206            Href = BuildWebUrl(strategy, itemKey)
 33207        };
 208    }
 209
 210    private static string? BuildWebUrl(IOpen5eCategoryStrategy strategy, string itemKey) =>
 46211        $"https://open5e.com/{strategy.WebCategory}/{itemKey}";
 212
 213    private static (string? category, string? itemKey) ParseId(string id)
 214    {
 17215        var slashIndex = id.IndexOf('/');
 17216        if (slashIndex > 0 && slashIndex < id.Length - 1)
 16217            return (id[..slashIndex], id[(slashIndex + 1)..]);
 1218        return (null, null);
 219    }
 220
 221    private static IOpen5eCategoryStrategy? FindStrategy(string categoryPart)
 222    {
 9223        if (Strategies.TryGetValue(categoryPart, out var exact))
 7224            return exact;
 225
 2226        return Strategies.Values.FirstOrDefault(s =>
 2227            s.CategoryKey.StartsWith(categoryPart, StringComparison.OrdinalIgnoreCase));
 228    }
 229
 230    private ExternalLinkContent CreateEmptyContent(string id)
 231    {
 6232        return new ExternalLinkContent
 6233        {
 6234            Source = Key,
 6235            Id = id,
 6236            Title = string.Empty,
 6237            Kind = string.Empty,
 6238            Markdown = string.Empty
 6239        };
 240    }
 241
 242    private static FrozenDictionary<string, IOpen5eCategoryStrategy> CreateStrategies()
 243    {
 1244        var strategies = new IOpen5eCategoryStrategy[]
 1245        {
 1246            new SpellCategoryStrategy(),
 1247            new MonsterCategoryStrategy(),
 1248            new MagicItemCategoryStrategy(),
 1249            new ConditionCategoryStrategy(),
 1250            new BackgroundCategoryStrategy(),
 1251            new FeatCategoryStrategy(),
 1252            new ClassCategoryStrategy(),
 1253            new RaceCategoryStrategy(),
 1254            new WeaponCategoryStrategy(),
 1255            new ArmorCategoryStrategy()
 1256        };
 257
 1258        return strategies.ToFrozenDictionary(s => s.CategoryKey, StringComparer.OrdinalIgnoreCase);
 259    }
 260}