|
| 1 | +using System.Net; |
1 | 2 | using System.Net.Http.Headers; |
2 | 3 | using System.Text.Json; |
| 4 | +using System.Web; |
3 | 5 | using BadgeSmith.Api.Domain.Services.Contracts; |
4 | 6 | using BadgeSmith.Api.Infrastructure.Caching; |
5 | 7 | using BadgeSmith.Api.Json; |
6 | 8 | using Microsoft.Extensions.Logging; |
| 9 | +using ZLinq; |
7 | 10 |
|
8 | 11 | namespace BadgeSmith.Api.Domain.Services.GitHub; |
9 | 12 |
|
10 | 13 | internal sealed class GitHubPackageService : IGitHubPackageService |
11 | 14 | { |
12 | | - private readonly HttpClient _httpClient; |
| 15 | + private readonly HttpClient _gitHubClient; |
13 | 16 | private readonly INuGetVersionService _nuGetVersionService; |
14 | 17 | private readonly IAppCache _cache; |
15 | 18 | private readonly ILogger<GitHubPackageService> _logger; |
16 | 19 |
|
17 | | - private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); |
| 20 | + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(15); |
18 | 21 |
|
19 | 22 | public GitHubPackageService( |
20 | | - HttpClient httpClient, |
| 23 | + HttpClient gitHubClient, |
21 | 24 | INuGetVersionService nuGetVersionService, |
22 | 25 | IAppCache cache, |
23 | 26 | ILogger<GitHubPackageService> logger) |
24 | 27 | { |
25 | | - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); |
| 28 | + _gitHubClient = gitHubClient ?? throw new ArgumentNullException(nameof(gitHubClient)); |
26 | 29 | _nuGetVersionService = nuGetVersionService ?? throw new ArgumentNullException(nameof(nuGetVersionService)); |
27 | 30 | _cache = cache ?? throw new ArgumentNullException(nameof(cache)); |
28 | 31 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); |
29 | 32 | } |
30 | 33 |
|
31 | 34 | public async Task<GitHubPackageResult> GetLatestVersionAsync( |
32 | 35 | string organization, |
33 | | - string packageName, |
| 36 | + string packageId, |
34 | 37 | string token, |
35 | 38 | string? versionRange = null, |
36 | 39 | bool includePrerelease = false, |
37 | 40 | CancellationToken ct = default) |
38 | 41 | { |
| 42 | + using var activity = BadgeSmithApiActivitySource.ActivitySource.StartActivity($"{nameof(GitHubPackageService)}.{nameof(GetLatestVersionAsync)}"); |
39 | 43 | ArgumentException.ThrowIfNullOrWhiteSpace(organization); |
40 | | - ArgumentException.ThrowIfNullOrWhiteSpace(packageName); |
| 44 | + ArgumentException.ThrowIfNullOrWhiteSpace(packageId); |
41 | 45 | ArgumentException.ThrowIfNullOrEmpty(token); |
42 | 46 |
|
43 | | - var orgLower = organization.ToLowerInvariant(); |
44 | | - var packageLower = packageName.ToLowerInvariant(); |
| 47 | + _logger.LogInformation("Fetching GitHub package versions for {PackageId}", packageId); |
| 48 | + var orgNormalized = organization.ToLowerInvariant(); |
| 49 | + var packageIdNormalized = packageId.ToLowerInvariant(); |
| 50 | + var url = new Uri($"orgs/{HttpUtility.UrlEncode(orgNormalized)}/packages/nuget/{HttpUtility.UrlEncode(packageIdNormalized)}/versions", UriKind.Relative); |
| 51 | + var cacheKey = $"github_package:index:{orgNormalized}:{packageIdNormalized}"; |
| 52 | + var hasCache = _cache.TryGetValue<(string Payload, string? ETag, DateTimeOffset? LastModified)>(cacheKey, out var cached); |
45 | 53 |
|
46 | | - var cacheKey = $"github_package:{orgLower}:{packageLower}:{versionRange ?? "latest"}:{includePrerelease}"; |
47 | | - |
48 | | - // Try cache first |
49 | | - if (_cache.TryGetValue<GitHubPackageInfo>(cacheKey, out var cachedPackage)) |
50 | | - { |
51 | | - _logger.LogDebug("Retrieved cached GitHub package info for {Org}/{Package}", orgLower, packageLower); |
52 | | - return cachedPackage; |
53 | | - } |
| 54 | + using var request = new HttpRequestMessage(HttpMethod.Get, url); |
| 55 | + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); |
| 56 | + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); |
54 | 57 |
|
55 | | - try |
| 58 | + if (hasCache) |
56 | 59 | { |
57 | | - // Fetch package versions from GitHub Packages API |
58 | | - var packageVersions = await FetchPackageVersionsAsync(orgLower, packageLower, token, ct).ConfigureAwait(false); |
59 | | - |
60 | | - if (packageVersions == null || packageVersions.Count == 0) |
| 60 | + if (!string.IsNullOrWhiteSpace(cached.ETag)) |
61 | 61 | { |
62 | | - _logger.LogWarning("No versions found for GitHub package {Org}/{Package}", orgLower, packageLower); |
63 | | - return new PackageNotFound($"Package '{packageLower}' not found in organization '{orgLower}'"); |
| 62 | + request.Headers.IfNoneMatch.ParseAdd(cached.ETag); |
64 | 63 | } |
65 | 64 |
|
66 | | - // Filter and select the appropriate version |
67 | | - var selectedVersion = SelectVersion(packageVersions, versionRange, includePrerelease); |
68 | | - if (selectedVersion == null) |
| 65 | + if (cached.LastModified.HasValue) |
69 | 66 | { |
70 | | - var criteria = string.IsNullOrWhiteSpace(versionRange) ? "latest" : versionRange; |
71 | | - return new PackageNotFound($"No matching version found for package '{packageLower}' with criteria '{criteria}' (prerelease: {includePrerelease})"); |
| 67 | + request.Headers.IfModifiedSince = cached.LastModified.Value; |
72 | 68 | } |
73 | | - |
74 | | - var packageInfo = new GitHubPackageInfo( |
75 | | - PackageName: packageLower, |
76 | | - Organization: orgLower, |
77 | | - VersionString: selectedVersion.Name, |
78 | | - IsPrerelease: selectedVersion.Prerelease, |
79 | | - LastModifiedUtc: selectedVersion.UpdatedAt |
80 | | - ); |
81 | | - |
82 | | - // Cache the result |
83 | | - _cache.Set(cacheKey, packageInfo, CacheTtl); |
84 | | - _logger.LogDebug("Cached GitHub package info for {Org}/{Package} version {Version}", orgLower, packageLower, selectedVersion.Name); |
85 | | - |
86 | | - return packageInfo; |
87 | | - } |
88 | | - catch (HttpRequestException ex) |
89 | | - { |
90 | | - _logger.LogError(ex, "HTTP error while fetching GitHub package {Org}/{Package}", orgLower, packageLower); |
91 | | - return new Error($"Failed to fetch package information: {ex.Message}"); |
92 | | - } |
93 | | - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) |
94 | | - { |
95 | | - _logger.LogError(ex, "Timeout while fetching GitHub package {Org}/{Package}", orgLower, packageLower); |
96 | | - return new Error("Request timeout while fetching package information"); |
97 | 69 | } |
98 | | - catch (Exception ex) |
99 | | - { |
100 | | - _logger.LogError(ex, "Unexpected error while fetching GitHub package {Org}/{Package}", orgLower, packageLower); |
101 | | - return new Error($"An unexpected error occurred: {ex.Message}"); |
102 | | - } |
103 | | - } |
104 | | - |
105 | | - private async Task<IReadOnlyList<GithubPackageVersion>?> FetchPackageVersionsAsync(string org, string packageName, string token, CancellationToken ct) |
106 | | - { |
107 | | - // GitHub Packages API endpoint for package versions |
108 | | - var url = new Uri($"orgs/{org}/packages/nuget/{packageName}/versions", UriKind.Relative); |
109 | | - |
110 | | - using var request = new HttpRequestMessage(HttpMethod.Get, url); |
111 | | - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); |
112 | | - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("BadgeSmith", "1.0")); |
113 | | - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); |
114 | 70 |
|
115 | | - _logger.LogDebug("Fetching GitHub package versions from {Url}", url); |
| 71 | + using var response = await _gitHubClient.SendAsync(request, ct).ConfigureAwait(false); |
| 72 | + string content; |
| 73 | + string? etag; |
| 74 | + DateTimeOffset? lastMod; |
116 | 75 |
|
117 | | - using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); |
118 | | - |
119 | | - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) |
| 76 | + switch (response.StatusCode) |
120 | 77 | { |
121 | | - _logger.LogWarning("GitHub package {Org}/{Package} not found (404)", org, packageName); |
122 | | - return null; |
| 78 | + case HttpStatusCode.NotModified when !hasCache: |
| 79 | + return new Error("Received 304 Not Modified without a cached entry"); |
| 80 | + case HttpStatusCode.NotModified when hasCache: |
| 81 | + content = cached.Payload; |
| 82 | + etag = response.Headers.ETag?.Tag ?? cached.ETag; |
| 83 | + lastMod = response.Content.Headers.LastModified ?? response.Headers.Date ?? cached.LastModified; |
| 84 | + break; |
| 85 | + case HttpStatusCode.NotFound: |
| 86 | + return new PackageNotFound($"Package '{packageId}' not found"); |
| 87 | + case HttpStatusCode.Forbidden: |
| 88 | + return new ForbiddenPackageAccess($"GitHub package {orgNormalized}/{packageIdNormalized} access forbidden"); |
| 89 | + case HttpStatusCode.Unauthorized: |
| 90 | + return new UnauthorizedPackageAccess($"GitHub package {orgNormalized}/{packageIdNormalized} not authorized"); |
| 91 | + default: |
| 92 | + if (!response.IsSuccessStatusCode) |
| 93 | + { |
| 94 | + return new Error($"NuGet API error: {response.StatusCode}"); |
| 95 | + } |
| 96 | + |
| 97 | + content = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); |
| 98 | + etag = response.Headers.ETag?.Tag; |
| 99 | + lastMod = response.Content.Headers.LastModified ?? response.Headers.Date; |
| 100 | + break; |
123 | 101 | } |
124 | 102 |
|
125 | | - if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) |
| 103 | + _cache.Set(cacheKey, (content, etag, lastMod), CacheTtl); |
| 104 | + var githubPackageVersions = JsonSerializer.Deserialize(content, LambdaFunctionJsonSerializerContext.Default.IReadOnlyListGithubPackageVersion); |
| 105 | + |
| 106 | + if (githubPackageVersions == null || githubPackageVersions.Count == 0) |
126 | 107 | { |
127 | | - _logger.LogWarning("Access forbidden for GitHub package {Org}/{Package} (403)", org, packageName); |
128 | | - return null; |
| 108 | + return new PackageNotFound($"No versions found for package '{packageId}'"); |
129 | 109 | } |
130 | 110 |
|
131 | | - response.EnsureSuccessStatusCode(); |
| 111 | + var versions = githubPackageVersions.AsValueEnumerable().Select(version => version.Name).ToArray(); |
| 112 | + var nuGetVersionResult = _nuGetVersionService.ParseAndFilterVersions(versions, versionRange, includePrerelease); |
132 | 113 |
|
133 | | - var content = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); |
134 | | - var versions = JsonSerializer.Deserialize(content, LambdaFunctionJsonSerializerContext.Default.IReadOnlyListGithubPackageVersion); |
135 | | - |
136 | | - _logger.LogDebug("Retrieved {Count} versions for GitHub package {Org}/{Package}", versions?.Count ?? 0, org, packageName); |
137 | | - |
138 | | - return versions; |
| 114 | + return nuGetVersionResult |
| 115 | + .Match<GitHubPackageResult> |
| 116 | + ( |
| 117 | + version => new GitHubPackageInfo(packageIdNormalized, orgNormalized, version.ToString(), version.IsPrerelease, lastMod), |
| 118 | + range => range, |
| 119 | + notfound => new PackageNotFound(notfound.Reason) |
| 120 | + ); |
139 | 121 | } |
140 | 122 | } |
0 commit comments