Skip to content

Commit c8a54ba

Browse files
authored
Merge pull request #15 from pkgdiff-dev/gsd/quick-261021-maven-registry
feat(maven): add Java packages from Maven Central as a registry
2 parents 4deca6e + 7caf6b0 commit c8a54ba

32 files changed

Lines changed: 817 additions & 61 deletions

‎src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs‎

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
136136
new Registry { Value = "nuget", Label = "NuGet", Icon = "/icons/nuget.svg", Disabled = false, DisplayOrder = 1 },
137137
new Registry { Value = "npm", Label = "npm", Icon = "/icons/npm.svg", Disabled = false, DisplayOrder = 2 },
138138
new Registry { Value = "pypi", Label = "PyPI", Icon = "/icons/pypi.svg", Disabled = false, DisplayOrder = 3 },
139-
new Registry { Value = "maven", Label = "Maven", Icon = "/icons/maven.svg", Disabled = true, DisplayOrder = 4 }
139+
new Registry { Value = "maven", Label = "Maven", Icon = "/icons/maven.svg", Disabled = false, DisplayOrder = 4 }
140140
};
141141

142142
await context.Registries.AddRangeAsync(registries);
@@ -154,21 +154,9 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
154154
}
155155
}
156156

157-
var packagistSpellings = PkgDiff.Shared.Constants.RegistryNames.SpellingsOf(PkgDiff.Shared.Constants.RegistryNames.Packagist);
158-
if (!await context.Registries.AnyAsync(r => packagistSpellings.Contains(r.Value.ToLower())))
159-
{
160-
var displayOrder = (await context.Registries.MaxAsync(r => (int?)r.DisplayOrder) ?? 0) + 1;
161-
context.Registries.Add(new Registry
162-
{
163-
Value = PkgDiff.Shared.Constants.RegistryNames.Packagist,
164-
Label = "Packagist",
165-
Icon = "/icons/packagist.svg",
166-
Disabled = false,
167-
DisplayOrder = displayOrder
168-
});
169-
await context.SaveChangesAsync();
170-
logger.LogInformation("Registry {Registry} added", PkgDiff.Shared.Constants.RegistryNames.Packagist);
171-
}
157+
await EnsureRegistryAsync(context, logger, PkgDiff.Shared.Constants.RegistryNames.Packagist, "Packagist", "/icons/packagist.svg", enable: false);
158+
// The Maven row was seeded disabled while Maven had no client
159+
await EnsureRegistryAsync(context, logger, PkgDiff.Shared.Constants.RegistryNames.Maven, "Maven", "/icons/maven.svg", enable: true);
172160

173161
var expectedPatterns = new[]
174162
{
@@ -236,6 +224,32 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
236224
logger.LogInformation("Version exclude patterns seeded: {Added} added, {Updated} updated", addedCount, updatedCount);
237225
}
238226
}
227+
228+
private static async Task EnsureRegistryAsync(ApplicationDbContext context, ILogger logger, string value, string label, string icon, bool enable)
229+
{
230+
var spellings = PkgDiff.Shared.Constants.RegistryNames.SpellingsOf(value);
231+
var existing = await context.Registries.FirstOrDefaultAsync(r => spellings.Contains(r.Value.ToLower()));
232+
if (existing is null)
233+
{
234+
var displayOrder = (await context.Registries.MaxAsync(r => (int?)r.DisplayOrder) ?? 0) + 1;
235+
context.Registries.Add(new Registry
236+
{
237+
Value = value,
238+
Label = label,
239+
Icon = icon,
240+
Disabled = false,
241+
DisplayOrder = displayOrder
242+
});
243+
await context.SaveChangesAsync();
244+
logger.LogInformation("Registry {Registry} added", value);
245+
}
246+
else if (enable && existing.Disabled)
247+
{
248+
existing.Disabled = false;
249+
await context.SaveChangesAsync();
250+
logger.LogInformation("Registry {Registry} enabled", value);
251+
}
252+
}
239253
// Bootstrap only: once any Admin exists, pkgdiff_admin is never re-promoted or recreated
240254
public static async Task SeedAdminUserAsync(UserManager<ApplicationUser> userManager, string? adminPassword, ILogger logger)
241255
{

‎src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ public static IServiceCollection AddPkgDiffOutboundHttp(this IServiceCollection
6161
client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)");
6262
});
6363

64+
services.AddHttpClient(HttpClientNames.MavenCentral, client =>
65+
{
66+
client.BaseAddress = new Uri("https://repo1.maven.org/maven2/");
67+
client.Timeout = FixedHostTimeout;
68+
client.MaxResponseContentBufferSize = RegistryMaxResponseBytes;
69+
client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)");
70+
});
71+
72+
// Only publish dates come from the search API, so a slow answer must not hold up the package page
73+
services.AddHttpClient(HttpClientNames.MavenSearch, client =>
74+
{
75+
client.BaseAddress = new Uri("https://search.maven.org/");
76+
client.Timeout = TimeSpan.FromSeconds(10);
77+
client.MaxResponseContentBufferSize = RegistryMaxResponseBytes;
78+
client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)");
79+
});
80+
6481
services.AddHttpClient<IPubDevService, PubDevService>(HttpClientNames.PubDev, client =>
6582
{
6683
client.BaseAddress = new Uri("https://pub.dev/");

‎src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
2323
services.AddScoped<INpmService, NpmService>();
2424
services.AddScoped<IPyPIService, PyPIService>();
2525
services.AddScoped<IPackagistService, PackagistService>();
26+
services.AddScoped<IMavenService, MavenService>();
2627
// IPubDevService is the PubDev typed client registered by AddPkgDiffOutboundHttp
2728
services.AddScoped<IScraperService, ScraperService>();
2829
services.AddSingleton<GitHubScrapeCache>();
@@ -46,6 +47,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
4647
services.AddScoped<IRegistryClient, PyPIRegistryClient>();
4748
services.AddScoped<IRegistryClient, PubDevRegistryClient>();
4849
services.AddScoped<IRegistryClient, PackagistRegistryClient>();
50+
services.AddScoped<IRegistryClient, MavenRegistryClient>();
4951

5052
services.AddScoped<IRegistryServiceFactory, RegistryServiceFactory>();
5153
services.AddScoped<IPackageCacheService, PackageCacheService>();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using PkgDiff.Shared.DTOs;
2+
3+
namespace PkgDiff.Application.Interfaces;
4+
5+
public interface IMavenService
6+
{
7+
Task<IEnumerable<string>> GetPackageVersionsAsync(string packageId, bool includePrerelease = false);
8+
Task<DateTime?> GetPackagePublishedDateAsync(string packageId, string version);
9+
Task<PackageMetadata> GetPackageMetadataAsync(string packageId);
10+
}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
namespace PkgDiff.Application.Versioning;
2+
3+
// A Maven Central version ordered as Maven's ComparableVersion does it:
4+
// alpha < beta < milestone < rc < snapshot < release (ga, final) < sp < any other qualifier
5+
public sealed class MavenVersion : IComparable<MavenVersion>, IComparable
6+
{
7+
private const int MaxLength = 100;
8+
9+
private static readonly string[] Qualifiers = { "alpha", "beta", "milestone", "rc", "snapshot", "", "sp" };
10+
11+
private static readonly Dictionary<string, string> Aliases = new(StringComparer.Ordinal)
12+
{
13+
["ga"] = "",
14+
["final"] = "",
15+
["release"] = "",
16+
["cr"] = "rc"
17+
};
18+
19+
private static readonly HashSet<string> PrereleaseWords = new(StringComparer.Ordinal)
20+
{
21+
"alpha", "beta", "milestone", "rc", "snapshot", "preview", "pre", "ea", "dev", "incubating"
22+
};
23+
24+
private readonly ListItem _items;
25+
26+
private MavenVersion(ListItem items, bool isPrerelease)
27+
{
28+
_items = items;
29+
IsPrerelease = isPrerelease;
30+
}
31+
32+
public bool IsPrerelease { get; }
33+
34+
public static bool TryParse(string? value, out MavenVersion version)
35+
{
36+
version = null!;
37+
if (string.IsNullOrWhiteSpace(value) || value.Length > MaxLength)
38+
return false;
39+
40+
value = value.Trim();
41+
if (!char.IsAsciiDigit(value[0]) || value.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('.' or '-' or '_' or '+')))
42+
return false;
43+
44+
var items = Parse(value.ToLowerInvariant(), out var isPrerelease);
45+
version = new MavenVersion(items, isPrerelease);
46+
return true;
47+
}
48+
49+
public int CompareTo(MavenVersion? other) => other is null ? 1 : _items.CompareTo(other._items);
50+
51+
public int CompareTo(object? obj) => CompareTo(obj as MavenVersion);
52+
53+
private static ListItem Parse(string version, out bool isPrerelease)
54+
{
55+
isPrerelease = false;
56+
var root = new ListItem();
57+
var list = root;
58+
var stack = new Stack<ListItem>();
59+
stack.Push(list);
60+
61+
var isDigit = false;
62+
var start = 0;
63+
64+
for (var i = 0; i < version.Length; i++)
65+
{
66+
var c = version[i];
67+
if (c == '.')
68+
{
69+
list.Add(i == start ? IntItem.Zero : ParseItem(isDigit, version[start..i], ref isPrerelease));
70+
start = i + 1;
71+
}
72+
else if (c is '-' or '_' or '+')
73+
{
74+
list.Add(i == start ? IntItem.Zero : ParseItem(isDigit, version[start..i], ref isPrerelease));
75+
start = i + 1;
76+
list.Add(list = new ListItem());
77+
stack.Push(list);
78+
}
79+
else if (char.IsAsciiDigit(c))
80+
{
81+
if (!isDigit && i > start)
82+
{
83+
// "m1" is milestone 1: a single letter right before a digit is a short qualifier
84+
list.Add(StringItem.Of(version[start..i], followedByDigit: true, ref isPrerelease));
85+
start = i;
86+
list.Add(list = new ListItem());
87+
stack.Push(list);
88+
}
89+
isDigit = true;
90+
}
91+
else
92+
{
93+
if (isDigit && i > start)
94+
{
95+
list.Add(ParseItem(true, version[start..i], ref isPrerelease));
96+
start = i;
97+
list.Add(list = new ListItem());
98+
stack.Push(list);
99+
}
100+
isDigit = false;
101+
}
102+
}
103+
104+
if (version.Length > start)
105+
list.Add(ParseItem(isDigit, version[start..], ref isPrerelease));
106+
107+
while (stack.Count > 0)
108+
stack.Pop().Normalize();
109+
110+
return root;
111+
}
112+
113+
private static Item ParseItem(bool isDigit, string text, ref bool isPrerelease)
114+
{
115+
if (isDigit)
116+
{
117+
var trimmed = text.TrimStart('0');
118+
return new IntItem(trimmed.Length == 0 ? "0" : trimmed);
119+
}
120+
return StringItem.Of(text, followedByDigit: false, ref isPrerelease);
121+
}
122+
123+
private abstract class Item
124+
{
125+
public abstract bool IsNull { get; }
126+
127+
public abstract int CompareTo(Item? other);
128+
}
129+
130+
private sealed class IntItem : Item
131+
{
132+
public static readonly IntItem Zero = new("0");
133+
134+
// Digits without leading zeros, so longer means larger and equal length compares as text
135+
private readonly string _digits;
136+
137+
public IntItem(string digits) => _digits = digits;
138+
139+
public override bool IsNull => _digits == "0";
140+
141+
public override int CompareTo(Item? other) => other switch
142+
{
143+
null => IsNull ? 0 : 1,
144+
IntItem number => _digits.Length != number._digits.Length
145+
? _digits.Length.CompareTo(number._digits.Length)
146+
: string.CompareOrdinal(_digits, number._digits),
147+
_ => 1
148+
};
149+
}
150+
151+
private sealed class StringItem : Item
152+
{
153+
private readonly string _value;
154+
155+
private StringItem(string value) => _value = value;
156+
157+
public static StringItem Of(string value, bool followedByDigit, ref bool isPrerelease)
158+
{
159+
if (followedByDigit && value.Length == 1)
160+
{
161+
value = value[0] switch
162+
{
163+
'a' => "alpha",
164+
'b' => "beta",
165+
'm' => "milestone",
166+
_ => value
167+
};
168+
}
169+
170+
if (Aliases.TryGetValue(value, out var alias))
171+
value = alias;
172+
173+
if (PrereleaseWords.Contains(value))
174+
isPrerelease = true;
175+
176+
return new StringItem(value);
177+
}
178+
179+
public override bool IsNull => Comparable(_value) == Comparable(string.Empty);
180+
181+
public override int CompareTo(Item? other) => other switch
182+
{
183+
null => string.CompareOrdinal(Comparable(_value), Comparable(string.Empty)),
184+
StringItem text => string.CompareOrdinal(Comparable(_value), Comparable(text._value)),
185+
_ => -1
186+
};
187+
188+
// Known qualifiers by their rank; any other comes after all of them, in text order
189+
private static string Comparable(string qualifier)
190+
{
191+
var index = Array.IndexOf(Qualifiers, qualifier);
192+
return index >= 0 ? index.ToString() : $"{Qualifiers.Length}-{qualifier}";
193+
}
194+
}
195+
196+
private sealed class ListItem : Item
197+
{
198+
private readonly List<Item> _items = new();
199+
200+
public void Add(Item item) => _items.Add(item);
201+
202+
public override bool IsNull => _items.Count == 0;
203+
204+
// 1.0.0 is 1, and 1-ga is 1: trailing zeros and release words do not count
205+
public void Normalize()
206+
{
207+
for (var i = _items.Count - 1; i >= 0; i--)
208+
{
209+
var item = _items[i];
210+
if (item.IsNull)
211+
_items.RemoveAt(i);
212+
else if (item is not ListItem)
213+
break;
214+
}
215+
}
216+
217+
public override int CompareTo(Item? other)
218+
{
219+
switch (other)
220+
{
221+
case null:
222+
return _items.Count == 0 ? 0 : _items[0].CompareTo(null);
223+
case IntItem:
224+
return -1;
225+
case StringItem:
226+
return 1;
227+
}
228+
229+
var right = ((ListItem)other)._items;
230+
for (var i = 0; i < Math.Max(_items.Count, right.Count); i++)
231+
{
232+
var l = i < _items.Count ? _items[i] : null;
233+
var r = i < right.Count ? right[i] : null;
234+
var result = l is null
235+
? (r is null ? 0 : -r.CompareTo(null))
236+
: l.CompareTo(r);
237+
if (result != 0)
238+
return result;
239+
}
240+
return 0;
241+
}
242+
}
243+
}

‎src/backend/PkgDiff.Application/Versioning/PackageVersion.cs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ namespace PkgDiff.Application.Versioning;
55

66
public sealed class PackageVersion : IComparable<PackageVersion>, IEquatable<PackageVersion>
77
{
8-
// A Pep440Version, ComposerVersion or NuGetVersion; versions of one package always share the kind
8+
// A Pep440Version, ComposerVersion, MavenVersion or NuGetVersion; versions of one package always share the kind
99
private readonly IComparable _key;
1010

1111
private PackageVersion(string value, bool isPrerelease, IComparable key)
@@ -15,7 +15,7 @@ private PackageVersion(string value, bool isPrerelease, IComparable key)
1515
_key = key;
1616
}
1717

18-
// The spelling the registry is asked with: its own for npm, pub.dev, PyPI and Packagist, NuGetVersion.ToString() for NuGet
18+
// The spelling the registry is asked with: its own for npm, pub.dev, PyPI, Packagist and Maven, NuGetVersion.ToString() for NuGet
1919
public string Value { get; }
2020

2121
public bool IsPrerelease { get; }
@@ -37,6 +37,15 @@ public static bool TryParse(VersionScheme scheme, string? value, [NotNullWhen(tr
3737
return true;
3838
}
3939

40+
if (scheme == VersionScheme.Maven)
41+
{
42+
if (!MavenVersion.TryParse(value, out var maven))
43+
return false;
44+
45+
version = new PackageVersion(value, maven.IsPrerelease, maven);
46+
return true;
47+
}
48+
4049
if (scheme == VersionScheme.Composer)
4150
{
4251
if (!ComposerVersion.TryParse(value, out var composer))

0 commit comments

Comments
 (0)