Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 30 additions & 16 deletions src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
new Registry { Value = "nuget", Label = "NuGet", Icon = "/icons/nuget.svg", Disabled = false, DisplayOrder = 1 },
new Registry { Value = "npm", Label = "npm", Icon = "/icons/npm.svg", Disabled = false, DisplayOrder = 2 },
new Registry { Value = "pypi", Label = "PyPI", Icon = "/icons/pypi.svg", Disabled = false, DisplayOrder = 3 },
new Registry { Value = "maven", Label = "Maven", Icon = "/icons/maven.svg", Disabled = true, DisplayOrder = 4 }
new Registry { Value = "maven", Label = "Maven", Icon = "/icons/maven.svg", Disabled = false, DisplayOrder = 4 }
};

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

var packagistSpellings = PkgDiff.Shared.Constants.RegistryNames.SpellingsOf(PkgDiff.Shared.Constants.RegistryNames.Packagist);
if (!await context.Registries.AnyAsync(r => packagistSpellings.Contains(r.Value.ToLower())))
{
var displayOrder = (await context.Registries.MaxAsync(r => (int?)r.DisplayOrder) ?? 0) + 1;
context.Registries.Add(new Registry
{
Value = PkgDiff.Shared.Constants.RegistryNames.Packagist,
Label = "Packagist",
Icon = "/icons/packagist.svg",
Disabled = false,
DisplayOrder = displayOrder
});
await context.SaveChangesAsync();
logger.LogInformation("Registry {Registry} added", PkgDiff.Shared.Constants.RegistryNames.Packagist);
}
await EnsureRegistryAsync(context, logger, PkgDiff.Shared.Constants.RegistryNames.Packagist, "Packagist", "/icons/packagist.svg", enable: false);
// The Maven row was seeded disabled while Maven had no client
await EnsureRegistryAsync(context, logger, PkgDiff.Shared.Constants.RegistryNames.Maven, "Maven", "/icons/maven.svg", enable: true);

var expectedPatterns = new[]
{
Expand Down Expand Up @@ -236,6 +224,32 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
logger.LogInformation("Version exclude patterns seeded: {Added} added, {Updated} updated", addedCount, updatedCount);
}
}

private static async Task EnsureRegistryAsync(ApplicationDbContext context, ILogger logger, string value, string label, string icon, bool enable)
{
var spellings = PkgDiff.Shared.Constants.RegistryNames.SpellingsOf(value);
var existing = await context.Registries.FirstOrDefaultAsync(r => spellings.Contains(r.Value.ToLower()));
if (existing is null)
{
var displayOrder = (await context.Registries.MaxAsync(r => (int?)r.DisplayOrder) ?? 0) + 1;
context.Registries.Add(new Registry
{
Value = value,
Label = label,
Icon = icon,
Disabled = false,
DisplayOrder = displayOrder
});
await context.SaveChangesAsync();
logger.LogInformation("Registry {Registry} added", value);
}
else if (enable && existing.Disabled)
{
existing.Disabled = false;
await context.SaveChangesAsync();
logger.LogInformation("Registry {Registry} enabled", value);
}
}
// Bootstrap only: once any Admin exists, pkgdiff_admin is never re-promoted or recreated
public static async Task SeedAdminUserAsync(UserManager<ApplicationUser> userManager, string? adminPassword, ILogger logger)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ public static IServiceCollection AddPkgDiffOutboundHttp(this IServiceCollection
client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)");
});

services.AddHttpClient(HttpClientNames.MavenCentral, client =>
{
client.BaseAddress = new Uri("https://repo1.maven.org/maven2/");
client.Timeout = FixedHostTimeout;
client.MaxResponseContentBufferSize = RegistryMaxResponseBytes;
client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)");
});

// Only publish dates come from the search API, so a slow answer must not hold up the package page
services.AddHttpClient(HttpClientNames.MavenSearch, client =>
{
client.BaseAddress = new Uri("https://search.maven.org/");
client.Timeout = TimeSpan.FromSeconds(10);
client.MaxResponseContentBufferSize = RegistryMaxResponseBytes;
client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0 (+https://pkgdiff.dev)");
});

services.AddHttpClient<IPubDevService, PubDevService>(HttpClientNames.PubDev, client =>
{
client.BaseAddress = new Uri("https://pub.dev/");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
services.AddScoped<INpmService, NpmService>();
services.AddScoped<IPyPIService, PyPIService>();
services.AddScoped<IPackagistService, PackagistService>();
services.AddScoped<IMavenService, MavenService>();
// IPubDevService is the PubDev typed client registered by AddPkgDiffOutboundHttp
services.AddScoped<IScraperService, ScraperService>();
services.AddSingleton<GitHubScrapeCache>();
Expand All @@ -46,6 +47,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
services.AddScoped<IRegistryClient, PyPIRegistryClient>();
services.AddScoped<IRegistryClient, PubDevRegistryClient>();
services.AddScoped<IRegistryClient, PackagistRegistryClient>();
services.AddScoped<IRegistryClient, MavenRegistryClient>();

services.AddScoped<IRegistryServiceFactory, RegistryServiceFactory>();
services.AddScoped<IPackageCacheService, PackageCacheService>();
Expand Down
10 changes: 10 additions & 0 deletions src/backend/PkgDiff.Application/Interfaces/IMavenService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using PkgDiff.Shared.DTOs;

namespace PkgDiff.Application.Interfaces;

public interface IMavenService
{
Task<IEnumerable<string>> GetPackageVersionsAsync(string packageId, bool includePrerelease = false);
Task<DateTime?> GetPackagePublishedDateAsync(string packageId, string version);
Task<PackageMetadata> GetPackageMetadataAsync(string packageId);
}
243 changes: 243 additions & 0 deletions src/backend/PkgDiff.Application/Versioning/MavenVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
namespace PkgDiff.Application.Versioning;

// A Maven Central version ordered as Maven's ComparableVersion does it:
// alpha < beta < milestone < rc < snapshot < release (ga, final) < sp < any other qualifier
public sealed class MavenVersion : IComparable<MavenVersion>, IComparable
{
private const int MaxLength = 100;

private static readonly string[] Qualifiers = { "alpha", "beta", "milestone", "rc", "snapshot", "", "sp" };

private static readonly Dictionary<string, string> Aliases = new(StringComparer.Ordinal)
{
["ga"] = "",
["final"] = "",
["release"] = "",
["cr"] = "rc"
};

private static readonly HashSet<string> PrereleaseWords = new(StringComparer.Ordinal)
{
"alpha", "beta", "milestone", "rc", "snapshot", "preview", "pre", "ea", "dev", "incubating"
};

private readonly ListItem _items;

private MavenVersion(ListItem items, bool isPrerelease)
{
_items = items;
IsPrerelease = isPrerelease;
}

public bool IsPrerelease { get; }

public static bool TryParse(string? value, out MavenVersion version)
{
version = null!;
if (string.IsNullOrWhiteSpace(value) || value.Length > MaxLength)
return false;

value = value.Trim();
if (!char.IsAsciiDigit(value[0]) || value.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('.' or '-' or '_' or '+')))
return false;

var items = Parse(value.ToLowerInvariant(), out var isPrerelease);
version = new MavenVersion(items, isPrerelease);
return true;
}

public int CompareTo(MavenVersion? other) => other is null ? 1 : _items.CompareTo(other._items);

public int CompareTo(object? obj) => CompareTo(obj as MavenVersion);

private static ListItem Parse(string version, out bool isPrerelease)
{
isPrerelease = false;
var root = new ListItem();
var list = root;
var stack = new Stack<ListItem>();
stack.Push(list);

var isDigit = false;
var start = 0;

for (var i = 0; i < version.Length; i++)
{
var c = version[i];
if (c == '.')
{
list.Add(i == start ? IntItem.Zero : ParseItem(isDigit, version[start..i], ref isPrerelease));
start = i + 1;
}
else if (c is '-' or '_' or '+')
{
list.Add(i == start ? IntItem.Zero : ParseItem(isDigit, version[start..i], ref isPrerelease));
start = i + 1;
list.Add(list = new ListItem());
stack.Push(list);
}
else if (char.IsAsciiDigit(c))
{
if (!isDigit && i > start)
{
// "m1" is milestone 1: a single letter right before a digit is a short qualifier
list.Add(StringItem.Of(version[start..i], followedByDigit: true, ref isPrerelease));
start = i;
list.Add(list = new ListItem());
stack.Push(list);
}
isDigit = true;
}
else
{
if (isDigit && i > start)
{
list.Add(ParseItem(true, version[start..i], ref isPrerelease));
start = i;
list.Add(list = new ListItem());
stack.Push(list);
}
isDigit = false;
}
}

if (version.Length > start)
list.Add(ParseItem(isDigit, version[start..], ref isPrerelease));

while (stack.Count > 0)
stack.Pop().Normalize();

return root;
}

private static Item ParseItem(bool isDigit, string text, ref bool isPrerelease)
{
if (isDigit)
{
var trimmed = text.TrimStart('0');
return new IntItem(trimmed.Length == 0 ? "0" : trimmed);
}
return StringItem.Of(text, followedByDigit: false, ref isPrerelease);
}

private abstract class Item
{
public abstract bool IsNull { get; }

public abstract int CompareTo(Item? other);
}

private sealed class IntItem : Item
{
public static readonly IntItem Zero = new("0");

// Digits without leading zeros, so longer means larger and equal length compares as text
private readonly string _digits;

public IntItem(string digits) => _digits = digits;

public override bool IsNull => _digits == "0";

public override int CompareTo(Item? other) => other switch
{
null => IsNull ? 0 : 1,
IntItem number => _digits.Length != number._digits.Length
? _digits.Length.CompareTo(number._digits.Length)
: string.CompareOrdinal(_digits, number._digits),
_ => 1
};
}

private sealed class StringItem : Item
{
private readonly string _value;

private StringItem(string value) => _value = value;

public static StringItem Of(string value, bool followedByDigit, ref bool isPrerelease)
{
if (followedByDigit && value.Length == 1)
{
value = value[0] switch
{
'a' => "alpha",
'b' => "beta",
'm' => "milestone",
_ => value
};
}

if (Aliases.TryGetValue(value, out var alias))
value = alias;

if (PrereleaseWords.Contains(value))
isPrerelease = true;

return new StringItem(value);
}

public override bool IsNull => Comparable(_value) == Comparable(string.Empty);

public override int CompareTo(Item? other) => other switch
{
null => string.CompareOrdinal(Comparable(_value), Comparable(string.Empty)),
StringItem text => string.CompareOrdinal(Comparable(_value), Comparable(text._value)),
_ => -1
};

// Known qualifiers by their rank; any other comes after all of them, in text order
private static string Comparable(string qualifier)
{
var index = Array.IndexOf(Qualifiers, qualifier);
return index >= 0 ? index.ToString() : $"{Qualifiers.Length}-{qualifier}";
}
}

private sealed class ListItem : Item
{
private readonly List<Item> _items = new();

public void Add(Item item) => _items.Add(item);

public override bool IsNull => _items.Count == 0;

// 1.0.0 is 1, and 1-ga is 1: trailing zeros and release words do not count
public void Normalize()
{
for (var i = _items.Count - 1; i >= 0; i--)
{
var item = _items[i];
if (item.IsNull)
_items.RemoveAt(i);
else if (item is not ListItem)
break;
}
}

public override int CompareTo(Item? other)
{
switch (other)
{
case null:
return _items.Count == 0 ? 0 : _items[0].CompareTo(null);
case IntItem:
return -1;
case StringItem:
return 1;
}

var right = ((ListItem)other)._items;
for (var i = 0; i < Math.Max(_items.Count, right.Count); i++)
{
var l = i < _items.Count ? _items[i] : null;
var r = i < right.Count ? right[i] : null;
var result = l is null
? (r is null ? 0 : -r.CompareTo(null))
: l.CompareTo(r);
if (result != 0)
return result;
}
return 0;
}
}
}
13 changes: 11 additions & 2 deletions src/backend/PkgDiff.Application/Versioning/PackageVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace PkgDiff.Application.Versioning;

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

private PackageVersion(string value, bool isPrerelease, IComparable key)
Expand All @@ -15,7 +15,7 @@ private PackageVersion(string value, bool isPrerelease, IComparable key)
_key = key;
}

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

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

if (scheme == VersionScheme.Maven)
{
if (!MavenVersion.TryParse(value, out var maven))
return false;

version = new PackageVersion(value, maven.IsPrerelease, maven);
return true;
}

if (scheme == VersionScheme.Composer)
{
if (!ComposerVersion.TryParse(value, out var composer))
Expand Down
Loading