diff --git a/src/BaGet.Core/Extensions/DependencyInjectionExtensions.cs b/src/BaGet.Core/Extensions/DependencyInjectionExtensions.cs index a56c41eab..e9d193df7 100644 --- a/src/BaGet.Core/Extensions/DependencyInjectionExtensions.cs +++ b/src/BaGet.Core/Extensions/DependencyInjectionExtensions.cs @@ -75,6 +75,7 @@ private static void AddBaGetServices(this IServiceCollection services) services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/BaGet.Core/IUrlGenerator.cs b/src/BaGet.Core/IUrlGenerator.cs index 443dba796..9115890ad 100644 --- a/src/BaGet.Core/IUrlGenerator.cs +++ b/src/BaGet.Core/IUrlGenerator.cs @@ -13,6 +13,11 @@ public interface IUrlGenerator /// string GetServiceIndexUrl(); + /// + /// Get the URL for the package source that implements the legacy NuGet V2 API. + /// + string GetServiceIndexV2Url(); + /// /// Get the URL for the root of the package content resource. /// See: https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource @@ -80,6 +85,13 @@ public interface IUrlGenerator /// The package's ID string GetPackageVersionsUrl(string id); + /// + /// Get the URL to download a package (.nupkg). + /// See: https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg + /// + /// The package to download + string GetPackageDownloadUrl(Package package); + /// /// Get the URL to download a package (.nupkg). /// See: https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg @@ -101,5 +113,11 @@ public interface IUrlGenerator /// The package's ID /// The package's version string GetPackageIconDownloadUrl(string id, NuGetVersion version); + + /// + /// Get the URL for the metadata of a single package version. + /// + /// The package to lookup + string GetPackageMetadataV2Url(Package package); } } diff --git a/src/BaGet.Core/Metadata/RegistrationBuilder.cs b/src/BaGet.Core/Metadata/RegistrationBuilder.cs index 4090e9323..0916ad764 100644 --- a/src/BaGet.Core/Metadata/RegistrationBuilder.cs +++ b/src/BaGet.Core/Metadata/RegistrationBuilder.cs @@ -52,7 +52,7 @@ public virtual RegistrationLeafResponse BuildLeaf(Package package) Listed = package.Listed, Published = package.Published, RegistrationLeafUrl = _url.GetRegistrationLeafUrl(id, version), - PackageContentUrl = _url.GetPackageDownloadUrl(id, version), + PackageContentUrl = _url.GetPackageDownloadUrl(package), RegistrationIndexUrl = _url.GetRegistrationIndexUrl(id) }; } @@ -61,7 +61,7 @@ private BaGetRegistrationIndexPageItem ToRegistrationIndexPageItem(Package packa new BaGetRegistrationIndexPageItem { RegistrationLeafUrl = _url.GetRegistrationLeafUrl(package.Id, package.Version), - PackageContentUrl = _url.GetPackageDownloadUrl(package.Id, package.Version), + PackageContentUrl = _url.GetPackageDownloadUrl(package), PackageMetadata = new BaGetPackageMetadata { PackageId = package.Id, @@ -78,7 +78,7 @@ private BaGetRegistrationIndexPageItem ToRegistrationIndexPageItem(Package packa Listed = package.Listed, MinClientVersion = package.MinClientVersion, ReleaseNotes = package.ReleaseNotes, - PackageContentUrl = _url.GetPackageDownloadUrl(package.Id, package.Version), + PackageContentUrl = _url.GetPackageDownloadUrl(package), PackageTypes = package.PackageTypes.Select(t => t.Name).ToList(), ProjectUrl = package.ProjectUrlString, RepositoryUrl = package.RepositoryUrlString, diff --git a/src/BaGet.Core/Upstream/Clients/V2UpstreamClient.cs b/src/BaGet.Core/Upstream/Clients/V2UpstreamClient.cs index 763aed6cb..e9e3aa989 100644 --- a/src/BaGet.Core/Upstream/Clients/V2UpstreamClient.cs +++ b/src/BaGet.Core/Upstream/Clients/V2UpstreamClient.cs @@ -23,30 +23,26 @@ namespace BaGet.Core /// public class V2UpstreamClient : IUpstreamClient, IDisposable { - private readonly SourceCacheContext _cache; private readonly SourceRepository _repository; - private readonly INuGetLogger _ngLogger; private readonly ILogger _logger; + private readonly SourceCacheContext _cache = new SourceCacheContext(); + private readonly INuGetLogger _ngLogger = NullLogger.Instance; + public V2UpstreamClient( IOptionsSnapshot options, ILogger logger) { - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - if (options.Value?.PackageSource?.AbsolutePath == null) - { - throw new ArgumentException("No mirror package source has been set."); - } + var source = new PackageSource(options.Value.PackageSource.AbsoluteUri); + _repository = Repository.Factory.GetCoreV2(source); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } - _ngLogger = NullLogger.Instance; - _cache = new SourceCacheContext(); - _repository = Repository.Factory.GetCoreV2(new PackageSource(options.Value.PackageSource.AbsoluteUri)); + public V2UpstreamClient(SourceRepository repository, ILogger logger) + { + _repository = repository; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task> ListPackageVersionsAsync(string id, CancellationToken cancellationToken) @@ -138,9 +134,9 @@ private Package ToPackage(IPackageSearchMetadata package) Description = package.Description, Downloads = 0, HasReadme = false, - Language = null, + Language = string.Empty, Listed = package.IsListed, - MinClientVersion = null, + MinClientVersion = string.Empty, Published = package.Published?.UtcDateTime ?? DateTime.MinValue, RequireLicenseAcceptance = package.RequireLicenseAcceptance, Summary = package.Summary, diff --git a/src/BaGet.Core/V2/IV2Builder.cs b/src/BaGet.Core/V2/IV2Builder.cs new file mode 100644 index 000000000..48a673ce3 --- /dev/null +++ b/src/BaGet.Core/V2/IV2Builder.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Xml.Linq; + +namespace BaGet.Core +{ + // TODO: comments + public interface IV2Builder + { + XElement BuildIndex(); + XElement BuildPackages(IReadOnlyList packages); + XElement BuildPackage(Package package); + } +} diff --git a/src/BaGet.Core/V2/V2Builder.cs b/src/BaGet.Core/V2/V2Builder.cs new file mode 100644 index 000000000..05dbc7e68 --- /dev/null +++ b/src/BaGet.Core/V2/V2Builder.cs @@ -0,0 +1,231 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace BaGet.Core +{ + public class V2Builder : IV2Builder + { + private readonly IUrlGenerator _url; + + public V2Builder(IUrlGenerator url) + { + _url = url; + } + + public XElement BuildIndex() + { + var serviceIndex = _url.GetServiceIndexV2Url(); + + return XElement.Parse($@" + + + Default + + Packages + + +"); + } + + public XElement BuildPackages(IReadOnlyList packages) + { + // See: https://joelverhagen.github.io/NuGetUndocs/#endpoint-find-packages-by-id + var serviceIndex = _url.GetServiceIndexV2Url(); + + // TODO: Add to top + // TODO: nuget.org adds `m:null="true"` attribute to elements with no value. Is that necessary? + return new XElement( + N.feed, + new XAttribute(N.baze, XNamespace.Get(serviceIndex)), + new XAttribute(N.m, NS.m), + new XAttribute(N.d, NS.d), + new XAttribute(N.georss, NS.georss), + new XAttribute(N.gml, NS.gml), + new XElement(N.m_count, packages.Count), + + packages.Select(package => + { + var packageV2Url = _url.GetPackageMetadataV2Url(package); + var downloadUrl = _url.GetPackageDownloadUrl(package); + + return new XElement( + N.entry, + new XElement(N.id, packageV2Url), + new XElement(N.title, package.Id), + new XElement( + N.content, + new XAttribute("type", "application/zip"), + new XAttribute("src", downloadUrl) + ), + + BuildAuthor(package), + BuildProperties(package) + ); + }) + ); + } + + public XElement BuildPackage(Package package) + { + // See: https://joelverhagen.github.io/NuGetUndocs/#endpoint-get-a-single-package + var serviceIndex = _url.GetServiceIndexV2Url(); + var packageV2Url = _url.GetPackageMetadataV2Url(package); + var downloadUrl = _url.GetPackageDownloadUrl(package); + + return new XElement( + N.entry, + new XAttribute(N.baze, XNamespace.Get(serviceIndex)), + new XAttribute(N.m, NS.m), + new XAttribute(N.d, NS.d), + new XAttribute(N.georss, NS.georss), + new XAttribute(N.gml, NS.gml), + + new XElement(N.id, packageV2Url), + new XElement( + N.content, + new XAttribute("type", "application/zip"), + new XAttribute("src", downloadUrl) + ), + new XElement(N.summary, package.Summary), + new XElement(N.title, package.Title), + + BuildAuthor(package), + BuildProperties(package) + ); + } + + private XElement BuildProperties(Package package) + { + // See: https://joelverhagen.github.io/NuGetUndocs/#package-entity + return new XElement( + N.m_properties, + new XElement(N.d_Authors, string.Join(", ", package.Authors)), + new XElement(N.d_Copyright, ""), // TODO + new XElement(N.d_Description, package.Description), + new XElement( + N.d_DownloadCount, + new XAttribute(N.m_type, "Edm.Int32"), + package.Downloads), + new XElement(N.d_IconUrl, package.IconUrl), // TODO, URL logic + new XElement(N.d_Id, package.Id), + new XElement(N.d_IsPrerelease, package.Version.IsPrerelease), + new XElement(N.d_Language, package.Language), + new XElement(N.d_LastEdited, package.Published), + new XElement(N.d_LicenseUrl, package.LicenseUrl), // TODO + new XElement(N.d_MinClientVersion, package.MinClientVersion), + new XElement(N.d_NormalizedVersion, package.NormalizedVersionString), + new XElement(N.d_PackageHash, ""), + new XElement(N.d_PackageHashAlgorithm, ""), + new XElement(N.d_PackageSize, 0), + new XElement(N.d_ProjectUrl, package.ProjectUrl), + new XElement(N.d_Published, package.Published), + new XElement(N.d_ReleaseNotes, package.ReleaseNotes), + new XElement(N.d_RequireLicenseAcceptance, package.RequireLicenseAcceptance), + new XElement(N.d_Summary, package.Summary), + new XElement(N.d_Tags, string.Join(" ", package.Tags)), + new XElement(N.d_Title, package.Title), + new XElement(N.d_Version, package.OriginalVersionString), + + BuildDependencies(package) + ); + } + + private XElement BuildAuthor(Package package) + { + return new XElement( + N.author, + new XElement(N.name, string.Join(", ", package.Authors)) + ); + } + + private XElement BuildDependencies(Package package) + { + var flattenedDependencies = new List(); + + flattenedDependencies.AddRange( + package + .Dependencies + .Where(IsFrameworkDependency) + .Select(dependency => dependency.TargetFramework) + .Distinct() + .Select(targetFramework => $"::{targetFramework}")); + + flattenedDependencies.AddRange( + package + .Dependencies + .Where(dependency => !IsFrameworkDependency(dependency)) + .Select(dependency => $"{dependency.Id}:{dependency.VersionRange}:{dependency.TargetFramework}")); + + var result = string.Join("|", flattenedDependencies); + + return new XElement(N.d_Dependencies, result); + } + + private bool IsFrameworkDependency(PackageDependency dependency) + { + return dependency.Id == null && dependency.VersionRange == null; + } + + private static class NS + { + public static readonly XNamespace xmlns = "http://www.w3.org/2005/Atom"; + // TODO: Remove? + //public static readonly XNamespace baze = "https://www.nuget.org/api/v2/curated-feeds/microsoftdotnet"; + public static readonly XNamespace m = "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"; + public static readonly XNamespace d = "http://schemas.microsoft.com/ado/2007/08/dataservices"; + public static readonly XNamespace georss = "http://www.georss.org/georss"; + public static readonly XNamespace gml = "http://www.opengis.net/gml"; + } + + private static class N + { + public static readonly XName feed = NS.xmlns + "feed"; + public static readonly XName entry = NS.xmlns + "entry"; + public static readonly XName title = NS.xmlns + "title"; + public static readonly XName author = NS.xmlns + "author"; + public static readonly XName name = NS.xmlns + "name"; + public static readonly XName link = NS.xmlns + "link"; + public static readonly XName id = NS.xmlns + "id"; + public static readonly XName content = NS.xmlns + "content"; + public static readonly XName summary = NS.xmlns + "summary"; + + public static readonly XName m_count = NS.m + "count"; + public static readonly XName m_properties = NS.m + "properties"; + public static readonly XName m_type = NS.m + "type"; + + public static readonly XName d_Authors = NS.d + "Authors"; + public static readonly XName d_Copyright = NS.d + "Copyright"; + public static readonly XName d_Created = NS.d + "Created"; + public static readonly XName d_Dependencies = NS.d + "Dependencies"; + public static readonly XName d_Description = NS.d + "Description"; + public static readonly XName d_DownloadCount = NS.d + "DownloadCount"; + public static readonly XName d_IconUrl = NS.d + "IconUrl"; + public static readonly XName d_Id = NS.d + "Id"; + public static readonly XName d_IsPrerelease = NS.d + "IsPrerelease"; + public static readonly XName d_Language = NS.d + "Language"; + public static readonly XName d_LastEdited = NS.d + "LastEdited"; + public static readonly XName d_LicenseUrl = NS.d + "LicenseUrl"; + public static readonly XName d_MinClientVersion = NS.d + "MinClientVersion"; + public static readonly XName d_NormalizedVersion = NS.d + "NormalizedVersion"; + public static readonly XName d_PackageHash = NS.d + "PackageHash"; + public static readonly XName d_PackageHashAlgorithm = NS.d + "PackageHashAlgorithm"; + public static readonly XName d_PackageSize = NS.d + "PackageSize"; + public static readonly XName d_ProjectUrl = NS.d + "ProjectUrl"; + public static readonly XName d_Published = NS.d + "Published"; + public static readonly XName d_ReleaseNotes = NS.d + "ReleaseNotes"; + public static readonly XName d_ReportAbuseUrl = NS.d + "ReportAbuseUrl"; + public static readonly XName d_RequireLicenseAcceptance = NS.d + "RequireLicenseAcceptance"; + public static readonly XName d_Summary = NS.d + "Summary"; + public static readonly XName d_Tags = NS.d + "Tags"; + public static readonly XName d_Title = NS.d + "Title"; + public static readonly XName d_Version = NS.d + "Version"; + + public static readonly XName baze = XNamespace.Xmlns + "base"; + public static readonly XName m = XNamespace.Xmlns + "m"; + public static readonly XName d = XNamespace.Xmlns + "d"; + public static readonly XName georss = XNamespace.Xmlns + "georss"; + public static readonly XName gml = XNamespace.Xmlns + "gml"; + } + } +} diff --git a/src/BaGet.Web/BaGetEndpointBuilder.cs b/src/BaGet.Web/BaGetEndpointBuilder.cs index d485eb40e..642d02d06 100644 --- a/src/BaGet.Web/BaGetEndpointBuilder.cs +++ b/src/BaGet.Web/BaGetEndpointBuilder.cs @@ -17,12 +17,13 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) MapSearchRoutes(endpoints); MapPackageMetadataRoutes(endpoints); MapPackageContentRoutes(endpoints); + MapV2ApiRoutes(endpoints); } public void MapServiceIndexRoutes(IEndpointRouteBuilder endpoints) { endpoints.MapControllerRoute( - name: Routes.IndexRouteName, + name: Routes.V3IndexRouteName, pattern: "v3/index.json", defaults: new { controller = "ServiceIndex", action = "Get" }); } @@ -126,5 +127,33 @@ public void MapPackageContentRoutes(IEndpointRouteBuilder endpoints) pattern: "v3/package/{id}/{version}/icon", defaults: new { controller = "PackageContent", action = "DownloadIcon" }); } + + public void MapV2ApiRoutes(IEndpointRouteBuilder endpoints) + { + endpoints.MapControllerRoute( + name: Routes.V2IndexRouteName, + pattern: "api/v2", + defaults: new { controller = "V2Api", action = "Index" }); + + endpoints.MapControllerRoute( + name: Routes.V2ListRouteName, + pattern: "api/v2/Packages()", + defaults: new { controller = "V2Api", action = "List" }); + + endpoints.MapControllerRoute( + name: Routes.V2SearchRouteName, + pattern: "api/v2/Search()", + defaults: new { controller = "V2Api", action = "Search" }); + + endpoints.MapControllerRoute( + name: Routes.V2PackageRouteName, + pattern: "api/v2/FindPackagesById()", + defaults: new { controller = "V2Api", action = "Package" }); + + endpoints.MapControllerRoute( + name: Routes.V2PackageVersionRouteName, + pattern: "api/v2/Packages(Id='{id}',Version='{version}')", + defaults: new { controller = "V2Api", action = "PackageVersion" }); + } } } diff --git a/src/BaGet.Web/BaGetUrlGenerator.cs b/src/BaGet.Web/BaGetUrlGenerator.cs index 48b61bb57..554d65c2a 100644 --- a/src/BaGet.Web/BaGetUrlGenerator.cs +++ b/src/BaGet.Web/BaGetUrlGenerator.cs @@ -22,7 +22,7 @@ public string GetServiceIndexUrl() { return _linkGenerator.GetUriByRouteValues( _httpContextAccessor.HttpContext, - Routes.IndexRouteName, + Routes.V3IndexRouteName, values: null); } @@ -102,19 +102,30 @@ public string GetPackageVersionsUrl(string id) values: new { Id = id.ToLowerInvariant() }); } + public string GetPackageDownloadUrl(Package package) + { + return GetPackageDownloadUrl( + package.Id.ToLowerInvariant(), + package.NormalizedVersionString.ToLowerInvariant()); + } + public string GetPackageDownloadUrl(string id, NuGetVersion version) { - id = id.ToLowerInvariant(); - var versionString = version.ToNormalizedString().ToLowerInvariant(); + return GetPackageDownloadUrl( + id.ToLowerInvariant(), + version.ToNormalizedString().ToLowerInvariant()); + } + private string GetPackageDownloadUrl(string lowerId, string lowerVersion) + { return _linkGenerator.GetUriByRouteValues( _httpContextAccessor.HttpContext, Routes.PackageDownloadRouteName, values: new { - Id = id, - Version = versionString, - IdVersion = $"{id}.{versionString}" + Id = lowerId, + Version = lowerVersion, + IdVersion = $"{lowerId}.{lowerVersion}" }); } @@ -149,6 +160,26 @@ public string GetPackageIconDownloadUrl(string id, NuGetVersion version) }); } + public string GetServiceIndexV2Url() + { + return _linkGenerator.GetUriByRouteValues( + _httpContextAccessor.HttpContext, + Routes.V2IndexRouteName, + values: null); + } + + public string GetPackageMetadataV2Url(Package package) + { + return _linkGenerator.GetUriByRouteValues( + _httpContextAccessor.HttpContext, + Routes.V2PackageVersionRouteName, + values: new + { + Id = package.Id, + Version = package.NormalizedVersionString + }); + } + private string AbsoluteUrl(string relativePath) { var request = _httpContextAccessor.HttpContext.Request; diff --git a/src/BaGet.Web/Controllers/V2ApiController.cs b/src/BaGet.Web/Controllers/V2ApiController.cs new file mode 100644 index 000000000..6067e9b7a --- /dev/null +++ b/src/BaGet.Web/Controllers/V2ApiController.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using BaGet.Core; +using BaGet.Protocol; +using Microsoft.AspNetCore.Mvc; +using NuGet.Versioning; + +namespace BaGet.Web +{ + /// + /// Controller that implements the legacy NuGet V2 APIs. + /// + [Produces("application/xml")] + public class V2ApiController : Controller + { + private readonly IPackageService _packages; + private readonly ISearchService _search; + private readonly IV2Builder _builder; + + public V2ApiController( + IPackageService packages, + ISearchService search, + IV2Builder builder) + { + _packages = packages ?? throw new ArgumentNullException(nameof(packages)); + _search = search ?? throw new ArgumentNullException(nameof(search)); + _builder = builder ?? throw new ArgumentNullException(nameof(builder)); + } + + public XElement Index() => _builder.BuildIndex(); + + public async Task List( + [FromQuery(Name = "$skip")] int skip = 0, + [FromQuery(Name = "$top")] int top = 20, + [FromQuery(Name = "$orderby")] string orderBy = null, + CancellationToken cancellationToken = default) + { + // TODO: Order by + var search = new SearchRequest + { + Skip = skip, + Take = top, + IncludePrerelease = true, + IncludeSemVer2 = true, + }; + + var response = await _search.SearchAsync(search, cancellationToken); + + // TODO: Undo + var packages = response + .Data + .Select(r => new Package + { + Id = r.PackageId, + Authors = r.Authors.ToArray(), + Description = r.Description, + Downloads = r.TotalDownloads, + Language = "English", + MinClientVersion = "1.2.3", + Published = DateTime.Now.AddDays(-1), + Summary = r.Summary, + + IconUrl = new Uri(r.IconUrl), + LicenseUrl = new Uri(r.LicenseUrl), + ProjectUrl = new Uri(r.ProjectUrl), + RepositoryUrl = new Uri(r.ProjectUrl), // TODO + + Tags = r.Tags.ToArray(), + + Version = r.ParseVersion(), + + Dependencies = new List() + }) + .ToList(); + + return _builder.BuildPackages(packages); + } + + public async Task Search( + string searchTerm, + string targetFramework, + bool includePrerelease = true, + CancellationToken cancellationToken = default) + { + searchTerm = searchTerm?.Trim('\'') ?? ""; + targetFramework = targetFramework?.Trim('\'') ?? ""; + + // TODO: Order by + var search = new SearchRequest + { + Skip = 0, + Take = 20, + IncludePrerelease = includePrerelease, + IncludeSemVer2 = true, + Query = searchTerm + }; + + var response = await _search.SearchAsync(search, cancellationToken); + + // TODO: Undo + var packages = response + .Data + .Select(r => new Package + { + Id = r.PackageId, + Authors = r.Authors.ToArray(), + Description = r.Description, + Downloads = r.TotalDownloads, + Language = "English", // TODO + MinClientVersion = "1.2.3", // TODO + Published = DateTime.Now.AddDays(-1), // TODO + Summary = r.Summary, + + IconUrl = new Uri(r.IconUrl), + LicenseUrl = new Uri(r.LicenseUrl), + ProjectUrl = new Uri(r.ProjectUrl), + RepositoryUrl = new Uri(r.ProjectUrl), // TODO + + Tags = r.Tags.ToArray(), + + Version = r.ParseVersion(), + + // TODO: Need to load depedencies! + Dependencies = new List() + }) + .ToList(); + + return _builder.BuildPackages(packages); + } + + public async Task> Package(string id, CancellationToken cancellationToken) + { + // TODO: Accept semVerLevel=2.0.0 query parameter + id = id?.Trim('\''); + + var packages = await _packages.FindPackagesAsync(id, cancellationToken); + if (!packages.Any()) + { + return NotFound(); + } + + return _builder.BuildPackages(packages); + } + + public async Task> PackageVersion(string id, string version, CancellationToken cancellationToken) + { + if (!NuGetVersion.TryParse(version, out var nugetVersion)) + { + return BadRequest(); + } + + var package = await _packages.FindPackageOrNullAsync(id, nugetVersion, cancellationToken); + if (package == null) + { + return NotFound(); + } + + return _builder.BuildPackage(package); + } + } +} diff --git a/src/BaGet.Web/Extensions/IServiceCollectionExtensions.cs b/src/BaGet.Web/Extensions/IServiceCollectionExtensions.cs index 39964850d..36dbdc95e 100644 --- a/src/BaGet.Web/Extensions/IServiceCollectionExtensions.cs +++ b/src/BaGet.Web/Extensions/IServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static IServiceCollection AddBaGetWebApplication( .AddControllers() .AddApplicationPart(typeof(PackageContentController).Assembly) .SetCompatibilityVersion(CompatibilityVersion.Version_3_0) + .AddXmlSerializerFormatters() .AddJsonOptions(options => { options.JsonSerializerOptions.IgnoreNullValues = true; diff --git a/src/BaGet.Web/Pages/Package.cshtml.cs b/src/BaGet.Web/Pages/Package.cshtml.cs index 8841648e7..b6aed0961 100644 --- a/src/BaGet.Web/Pages/Package.cshtml.cs +++ b/src/BaGet.Web/Pages/Package.cshtml.cs @@ -107,7 +107,7 @@ public async Task OnGetAsync(string id, string version, CancellationToken cancel ? _url.GetPackageIconDownloadUrl(Package.Id, packageVersion) : Package.IconUrlString; LicenseUrl = Package.LicenseUrlString; - PackageDownloadUrl = _url.GetPackageDownloadUrl(Package.Id, packageVersion); + PackageDownloadUrl = _url.GetPackageDownloadUrl(Package); } private IReadOnlyList ToDependencyGroups(Package package) diff --git a/src/BaGet.Web/Routes.cs b/src/BaGet.Web/Routes.cs index ff31ad389..7bd6e2536 100644 --- a/src/BaGet.Web/Routes.cs +++ b/src/BaGet.Web/Routes.cs @@ -1,8 +1,8 @@ namespace BaGet.Web { - public class Routes + public static class Routes { - public const string IndexRouteName = "index"; + public const string V3IndexRouteName = "index"; public const string UploadPackageRouteName = "upload-package"; public const string UploadSymbolRouteName = "upload-symbol"; public const string DeleteRouteName = "delete"; @@ -19,5 +19,11 @@ public class Routes public const string PackageDownloadIconRouteName = "package-download-icon"; public const string SymbolDownloadRouteName = "symbol-download"; public const string PrefixedSymbolDownloadRouteName = "prefixed-symbol-download"; + + public const string V2IndexRouteName = "v2-service-index"; + public const string V2ListRouteName = "v2-list"; + public const string V2SearchRouteName = "v2-search"; + public const string V2PackageRouteName = "v2-package"; + public const string V2PackageVersionRouteName = "v2-package-version"; } } diff --git a/tests/BaGet.Tests/ApiIntegrationTests.cs b/tests/BaGet.Tests/ApiIntegrationTests.cs index 547f51b79..7aaab4b27 100644 --- a/tests/BaGet.Tests/ApiIntegrationTests.cs +++ b/tests/BaGet.Tests/ApiIntegrationTests.cs @@ -55,7 +55,7 @@ public async Task SearchReturnsOk() { ""id"": ""TestData"", ""version"": ""1.2.3"", - ""description"": ""Test description"", + ""description"": ""Hello world"", ""authors"": [ ""Test author"" ], @@ -63,9 +63,9 @@ public async Task SearchReturnsOk() ""licenseUrl"": """", ""projectUrl"": """", ""registration"": ""http://localhost/v3/registration/testdata/index.json"", - ""summary"": """", + ""summary"": ""Hello world"", ""tags"": [], - ""title"": """", + ""title"": ""TestData"", ""totalDownloads"": 0, ""versions"": [ { @@ -277,7 +277,7 @@ public async Task PackageMetadataReturnsOk() ""dependencies"": [] } ], - ""description"": ""Test description"", + ""description"": ""Hello world"", ""iconUrl"": """", ""language"": """", ""licenseUrl"": """", @@ -287,9 +287,9 @@ public async Task PackageMetadataReturnsOk() ""projectUrl"": """", ""published"": ""2020-01-01T00:00:00Z"", ""requireLicenseAcceptance"": false, - ""summary"": """", + ""summary"": ""Hello world"", ""tags"": [], - ""title"": """" + ""title"": ""TestData"" } } ] diff --git a/tests/BaGet.Tests/BaGetClientIntegrationTests.cs b/tests/BaGet.Tests/BaGetClientIntegrationTests.cs index c73aa376a..75c8fdaf6 100644 --- a/tests/BaGet.Tests/BaGetClientIntegrationTests.cs +++ b/tests/BaGet.Tests/BaGetClientIntegrationTests.cs @@ -65,7 +65,7 @@ public async Task SearchReturnsResults() Assert.Equal("TestData", result.PackageId); Assert.Equal("1.2.3", result.Version); - Assert.Equal("Test description", result.Description); + Assert.Equal("Hello world", result.Description); Assert.Equal("Test author", author); Assert.Equal(0, result.TotalDownloads); @@ -210,7 +210,7 @@ public async Task PackageMetadataReturnsOk() Assert.Equal("TestData", package.PackageId); Assert.Equal("1.2.3", package.Version); - Assert.Equal("Test description", package.Description); + Assert.Equal("Hello world", package.Description); Assert.Equal("Test author", package.Authors); Assert.True(package.Listed); } diff --git a/tests/BaGet.Tests/MirrorIntegrationTests.cs b/tests/BaGet.Tests/MirrorIntegrationTests.cs index 8f6141772..f12257f84 100644 --- a/tests/BaGet.Tests/MirrorIntegrationTests.cs +++ b/tests/BaGet.Tests/MirrorIntegrationTests.cs @@ -8,17 +8,39 @@ namespace BaGet.Tests { - public class MirrorIntegrationTests : IDisposable + public class V2UpstreamMirrorIntegrationTests : MirrorIntegrationTests + { + public V2UpstreamMirrorIntegrationTests(ITestOutputHelper output) + : base(output, v2Upstream: true) + { + } + } + + public class V3UpstreamMirrorIntegrationTests : MirrorIntegrationTests + { + public V3UpstreamMirrorIntegrationTests(ITestOutputHelper output) + : base(output, v2Upstream: false) + { + } + } + + public abstract class MirrorIntegrationTests : IDisposable { private readonly BaGetApplication _upstream; private readonly BaGetApplication _downstream; private readonly HttpClient _downstreamClient; private readonly Stream _packageStream; - public MirrorIntegrationTests(ITestOutputHelper output) + protected MirrorIntegrationTests(ITestOutputHelper output, bool v2Upstream) { _upstream = new BaGetApplication(output); - _downstream = new BaGetApplication(output, _upstream.CreateClient()); + _downstream = new BaGetApplication( + output, + new BaGetApplicationOptions + { + UpstreamClient = _upstream.CreateClient(), + EnableLegacyUpstream = v2Upstream, + }); _downstreamClient = _downstream.CreateClient(); _packageStream = TestResources.GetResourceStream(TestResources.Package); @@ -120,7 +142,7 @@ public async Task PackageMetadataIncludesUpstream() ""dependencies"": [] } ], - ""description"": ""Test description"", + ""description"": ""Hello world"", ""iconUrl"": """", ""language"": """", ""licenseUrl"": """", @@ -130,9 +152,9 @@ public async Task PackageMetadataIncludesUpstream() ""projectUrl"": """", ""published"": ""2020-01-01T00:00:00Z"", ""requireLicenseAcceptance"": false, - ""summary"": """", + ""summary"": ""Hello world"", ""tags"": [], - ""title"": """" + ""title"": ""TestData"" } } ] diff --git a/tests/BaGet.Tests/NuGetClientIntegrationTests.cs b/tests/BaGet.Tests/NuGetClientIntegrationTests.cs index e394ac95d..a1b9f1b73 100644 --- a/tests/BaGet.Tests/NuGetClientIntegrationTests.cs +++ b/tests/BaGet.Tests/NuGetClientIntegrationTests.cs @@ -35,13 +35,8 @@ public NuGetClientIntegrationTests(ITestOutputHelper output) _packageStream = TestResources.GetResourceStream(TestResources.Package); var sourceUri = new Uri(_app.Server.BaseAddress, "v3/index.json"); - var packageSource = new PackageSource(sourceUri.AbsoluteUri); - var providers = new List>(); - providers.Add(new Lazy(() => new HttpSourceResourceProviderTestHost(_client))); - providers.AddRange(Repository.Provider.GetCoreV3()); - - _repository = new SourceRepository(packageSource, providers); + _repository = TestableSourceRepository.Build(sourceUri, _client); _cache = new SourceCacheContext { NoCache = true, MaxAge = new DateTimeOffset(), DirectDownload = true }; _logger = NuGet.Common.NullLogger.Instance; _cancellationToken = CancellationToken.None; @@ -82,7 +77,7 @@ public async Task SearchReturnsResults() Assert.Equal("TestData", result.Identity.Id); Assert.Equal("1.2.3", result.Identity.Version.ToNormalizedString()); - Assert.Equal("Test description", result.Description); + Assert.Equal("Hello world", result.Description); Assert.Equal("Test author", result.Authors); Assert.Equal(0, result.DownloadCount); @@ -234,7 +229,7 @@ public async Task PackageMetadataReturnsOk() Assert.Equal("TestData", package.Identity.Id); Assert.Equal("1.2.3", package.Identity.Version.ToNormalizedString()); - Assert.Equal("Test description", package.Description); + Assert.Equal("Hello world", package.Description); Assert.Equal("Test author", package.Authors); Assert.True(package.IsListed); } diff --git a/tests/BaGet.Tests/Support/BaGetApplication.cs b/tests/BaGet.Tests/Support/BaGetApplication.cs index 9886e1bd0..1c0d96337 100644 --- a/tests/BaGet.Tests/Support/BaGetApplication.cs +++ b/tests/BaGet.Tests/Support/BaGetApplication.cs @@ -18,15 +18,28 @@ namespace BaGet.Tests { + public class BaGetApplicationOptions + { + /// + /// Null if upstreaming should be disabled. + /// + public HttpClient UpstreamClient { get; set; } + + /// + /// True if the upstream uses NuGet's V2 protocol. + /// + public bool EnableLegacyUpstream { get; set; } + } + public class BaGetApplication : WebApplicationFactory { private readonly ITestOutputHelper _output; - private readonly HttpClient _upstreamClient; + private readonly BaGetApplicationOptions _options; - public BaGetApplication(ITestOutputHelper output, HttpClient upstreamClient = null) + public BaGetApplication(ITestOutputHelper output, BaGetApplicationOptions options = null) { _output = output ?? throw new ArgumentNullException(nameof(output)); - _upstreamClient = upstreamClient; + _options = options ?? new BaGetApplicationOptions(); } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -41,6 +54,10 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) Directory.CreateDirectory(tempPath); + var upstreamUrl = _options.EnableLegacyUpstream + ? "http://localhost/api/v2" + : "http://localhost/v3/index.json"; + builder .UseStartup() .UseEnvironment("Production") @@ -64,8 +81,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { "Storage:Type", "FileSystem" }, { "Storage:Path", storagePath }, { "Search:Type", "Database" }, - { "Mirror:Enabled", _upstreamClient != null ? "true": "false" }, - { "Mirror:PackageSource", "http://localhost/v3/index.json" }, + { "Mirror:Enabled", _options.UpstreamClient != null ? "true": "false" }, + { "Mirror:Legacy", _options.EnableLegacyUpstream ? "true": "false" }, + { "Mirror:PackageSource", upstreamUrl }, }); }) .ConfigureServices((context, services) => @@ -77,9 +95,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) .Returns(DateTime.Parse("2020-01-01T00:00:00.000Z")); services.AddSingleton(time.Object); - if (_upstreamClient != null) + if (_options.UpstreamClient != null) { - services.AddSingleton(_upstreamClient); + services.AddSingleton(_options.UpstreamClient); + services.AddSingleton(provider => + new V2UpstreamClient( + TestableSourceRepository.Build(new Uri(upstreamUrl), _options.UpstreamClient), + provider.GetRequiredService>())); } // Setup the integration test database. diff --git a/tests/BaGet.Tests/Support/HttpSourceResourceProviderTestHost.cs b/tests/BaGet.Tests/Support/TestableHttpSourceResourceProvider.cs similarity index 73% rename from tests/BaGet.Tests/Support/HttpSourceResourceProviderTestHost.cs rename to tests/BaGet.Tests/Support/TestableHttpSourceResourceProvider.cs index 2420112a1..be1d95e44 100644 --- a/tests/BaGet.Tests/Support/HttpSourceResourceProviderTestHost.cs +++ b/tests/BaGet.Tests/Support/TestableHttpSourceResourceProvider.cs @@ -13,17 +13,18 @@ namespace BaGet.Tests /// /// Similar to official HttpSourceResourceProvider, but uses test host. /// - public class HttpSourceResourceProviderTestHost : ResourceProvider + public class TestableHttpSourceResourceProvider : ResourceProvider { // Only one HttpSource per source should exist. This is to reduce the number of TCP connections. private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); private readonly HttpClient _httpClient; - public HttpSourceResourceProviderTestHost(HttpClient httpClient) - : base(typeof(HttpSourceResource), - nameof(HttpSourceResource), - NuGetResourceProviderPositions.Last) + public TestableHttpSourceResourceProvider(HttpClient httpClient) + : base( + typeof(HttpSourceResource), + nameof(HttpSourceResource), + NuGetResourceProviderPositions.Last) { _httpClient = httpClient; } @@ -32,16 +33,16 @@ public override Task> TryCreate(SourceRepository sou { Debug.Assert(source.PackageSource.IsHttp, "HTTP source requested for a non-http source."); - HttpSourceResource curResource = null; + HttpSourceResource result = null; if (source.PackageSource.IsHttp) { - curResource = _cache.GetOrAdd( - source.PackageSource, + result = _cache.GetOrAdd( + source.PackageSource, packageSource => new HttpSourceResource(TestableHttpSource.Create(source, _httpClient))); } - return Task.FromResult(new Tuple(curResource != null, curResource)); + return Task.FromResult(new Tuple(result != null, result)); } } } diff --git a/tests/BaGet.Tests/Support/TestableSourceRepository.cs b/tests/BaGet.Tests/Support/TestableSourceRepository.cs new file mode 100644 index 000000000..4098c4c65 --- /dev/null +++ b/tests/BaGet.Tests/Support/TestableSourceRepository.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using NuGet.Configuration; +using NuGet.Protocol.Core.Types; + +namespace BaGet.Tests +{ + public static class TestableSourceRepository + { + public static SourceRepository Build(Uri source, HttpClient client) + { + var packageSource = new PackageSource(source.AbsoluteUri); + var providers = new List>(); + + var testableHttpProvider = new Lazy( + () => new TestableHttpSourceResourceProvider(client)); + + providers.Add(testableHttpProvider); + providers.AddRange(Repository.Provider.GetCoreV3()); + + return new SourceRepository(packageSource, providers); + } + } +} diff --git a/tests/BaGet.Tests/TestData/TestData.1.2.3.nupkg b/tests/BaGet.Tests/TestData/TestData.1.2.3.nupkg index 8ee37ba33..35cdf4d3b 100644 Binary files a/tests/BaGet.Tests/TestData/TestData.1.2.3.nupkg and b/tests/BaGet.Tests/TestData/TestData.1.2.3.nupkg differ