Skip to content

Commit

Permalink
Add support for semVerLevel query parameter to V2 endpoints (#3714)
Browse files Browse the repository at this point in the history
* adding new optional semVerLevel query parameter to v2 odata endpoints

* adding new optional semVerLevel query parameter to v2 autocomplete endpoints

* Applying semVerLevel filter on v2 OData endpoints

* Use [FromUri] attribute on semVerLevel
(avoids having single quotes in the parameter value)

* Ensure navigation links on v2 feeds use normalized version

* Clarifying comment on Get(Id=,Version=) v2 API

* Properly default to semver2 inclusion on Get(Id=,Version=)

* Compare NormalizedVersion to be able to retrieve matching SemVer2 package versions for a given normalized version string.

* Code review feedback

* Update and fix broken test data
  • Loading branch information
xavierdecoster committed May 16, 2017
1 parent 166a7f5 commit 6055211
Show file tree
Hide file tree
Showing 20 changed files with 682 additions and 100 deletions.
52 changes: 51 additions & 1 deletion src/NuGetGallery.Core/SemVerLevelKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using NuGet.Versioning;

namespace NuGetGallery
Expand All @@ -15,6 +16,8 @@ namespace NuGetGallery
/// </summary>
public static class SemVerLevelKey
{
private static readonly NuGetVersion _semVer2Version = NuGetVersion.Parse("2.0.0");

/// <summary>
/// This could either indicate being SemVer1-compliant, or non-SemVer-compliant at all (e.g. System.Versioning pattern).
/// </summary>
Expand All @@ -31,7 +34,7 @@ public static class SemVerLevelKey
/// </summary>
/// <param name="originalVersion">The package's non-normalized, original version string.</param>
/// <param name="dependencies">The package's direct dependencies as defined in the package's manifest.</param>
/// <returns>Returns <c>null</c> when unknown; otherwise the identified SemVer-level.</returns>
/// <returns>Returns <c>null</c> when unknown; otherwise the identified SemVer-level key.</returns>
public static int? ForPackage(NuGetVersion originalVersion, IEnumerable<PackageDependency> dependencies)
{
if (originalVersion == null)
Expand Down Expand Up @@ -65,5 +68,52 @@ public static class SemVerLevelKey

return Unknown;
}

/// <summary>
/// Identifies the SemVer-level for a given semVerLevel version string.
/// </summary>
/// <param name="semVerLevel">The version string indicating the supported SemVer-level.</param>
/// <returns>
/// Returns <c>null</c> when unknown; otherwise the identified SemVer-level key.
/// </returns>
/// <remarks>
/// Older clients don't send the semVerLevel query parameter at all,
/// so we default to Unknown for backwards-compatibility.
/// </remarks>
public static int? ForSemVerLevel(string semVerLevel)
{
if (semVerLevel == null)
{
return Unknown;
}

NuGetVersion parsedVersion;
if (NuGetVersion.TryParse(semVerLevel, out parsedVersion))
{
return _semVer2Version <= parsedVersion ? SemVer2 : Unknown;
}
else
{
return Unknown;
}
}

/// <summary>
/// Indicates whether the provided SemVer-level key is compliant with the provided SemVer-level version string.
/// </summary>
/// <param name="semVerLevel">The SemVer-level string indicating the SemVer-level to comply with.</param>
/// <returns><c>True</c> if compliant; otherwise <c>false</c>.</returns>
public static Expression<Func<Package, bool>> IsPackageCompliantWithSemVerLevel(string semVerLevel)
{
// Note: we must return an expression that Linq to Entities is able to translate to SQL
var parsedSemVerLevelKey = ForSemVerLevel(semVerLevel);

if (parsedSemVerLevelKey == SemVer2)
{
return p => p.SemVerLevelKey == Unknown || p.SemVerLevelKey == parsedSemVerLevelKey;
}

return p => p.SemVerLevelKey == Unknown;
}
}
}
2 changes: 2 additions & 0 deletions src/NuGetGallery/App_Start/NuGetODataV2FeedConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public static IEdmModel GetEdmModel()
searchAction.Parameter<string>("searchTerm");
searchAction.Parameter<string>("targetFramework");
searchAction.Parameter<bool>("includePrerelease");
searchAction.Parameter<string>("semVerLevel");
searchAction.ReturnsCollectionFromEntitySet<V2FeedPackage>("Packages");

var findPackagesAction = builder.Action("FindPackagesById");
Expand All @@ -73,6 +74,7 @@ public static IEdmModel GetEdmModel()
getUpdatesAction.Parameter<bool>("includeAllVersions");
getUpdatesAction.Parameter<string>("targetFrameworks");
getUpdatesAction.Parameter<string>("versionConstraints");
getUpdatesAction.Parameter<string>("semVerLevel");
getUpdatesAction.ReturnsCollectionFromEntitySet<V2FeedPackage>("Packages");

var model = builder.GetEdmModel();
Expand Down
16 changes: 11 additions & 5 deletions src/NuGetGallery/Controllers/ApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public virtual async Task<ActionResult> GetPackage(string id, string version)
// some security paranoia about URL hacking somehow creating e.g. open redirects
// validate user input: explicit calls to the same validators used during Package Registrations
// Ideally shouldn't be necessary?
if (!PackageIdValidator.IsValidPackageId(id ?? ""))
if (!PackageIdValidator.IsValidPackageId(id ?? string.Empty))
{
return new HttpStatusCodeWithBodyResult(HttpStatusCode.BadRequest, "The format of the package id is invalid");
}
Expand Down Expand Up @@ -632,24 +632,30 @@ protected internal virtual Stream ReadPackageFromRequest()

[HttpGet]
[ActionName("PackageIDs")]
public virtual async Task<ActionResult> GetPackageIds(string partialId, bool? includePrerelease)
public virtual async Task<ActionResult> GetPackageIds(
string partialId,
bool? includePrerelease,
string semVerLevel = null)
{
var query = GetService<IAutoCompletePackageIdsQuery>();
return new JsonResult
{
Data = (await query.Execute(partialId, includePrerelease)).ToArray(),
Data = (await query.Execute(partialId, includePrerelease, semVerLevel)).ToArray(),
JsonRequestBehavior = JsonRequestBehavior.AllowGet
};
}

[HttpGet]
[ActionName("PackageVersions")]
public virtual async Task<ActionResult> GetPackageVersions(string id, bool? includePrerelease)
public virtual async Task<ActionResult> GetPackageVersions(
string id,
bool? includePrerelease,
string semVerLevel = null)
{
var query = GetService<IAutoCompletePackageVersionsQuery>();
return new JsonResult
{
Data = (await query.Execute(id, includePrerelease)).ToArray(),
Data = (await query.Execute(id, includePrerelease, semVerLevel)).ToArray(),
JsonRequestBehavior = JsonRequestBehavior.AllowGet
};
}
Expand Down
54 changes: 36 additions & 18 deletions src/NuGetGallery/Controllers/ODataV2CuratedFeedController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,47 +40,57 @@ public ODataV2CuratedFeedController(
_curatedFeedService = curatedFeedService;
}

// /api/v2/curated-feed/curatedFeedName/Packages
// /api/v2/curated-feed/curatedFeedName/Packages?semVerLevel=
[HttpGet]
[HttpPost]
[CacheOutput(NoCache = true)]
public IHttpActionResult Get(ODataQueryOptions<V2FeedPackage> options, string curatedFeedName)
public IHttpActionResult Get(
ODataQueryOptions<V2FeedPackage> options,
string curatedFeedName,
[FromUri] string semVerLevel = null)
{
if (!_entities.CuratedFeeds.Any(cf => cf.Name == curatedFeedName))
{
return NotFound();
}

var queryable = _curatedFeedService.GetPackages(curatedFeedName)
.Where(p => p.SemVerLevelKey == SemVerLevelKey.Unknown)
.Where(SemVerLevelKey.IsPackageCompliantWithSemVerLevel(semVerLevel))
.ToV2FeedPackageQuery(_configurationService.GetSiteRoot(UseHttps()), _configurationService.Features.FriendlyLicenses)
.InterceptWith(new NormalizeVersionInterceptor());

return QueryResult(options, queryable, MaxPageSize);
}

// /api/v2/curated-feed/curatedFeedName/Packages/$count
// /api/v2/curated-feed/curatedFeedName/Packages/$count?semVerLevel=
[HttpGet]
[CacheOutput(NoCache = true)]
public IHttpActionResult GetCount(ODataQueryOptions<V2FeedPackage> options, string curatedFeedName)
public IHttpActionResult GetCount(
ODataQueryOptions<V2FeedPackage> options,
string curatedFeedName,
[FromUri] string semVerLevel = null)
{
return Get(options, curatedFeedName).FormattedAsCountResult<V2FeedPackage>();
return Get(options, curatedFeedName, semVerLevel).FormattedAsCountResult<V2FeedPackage>();
}

// /api/v2/curated-feed/curatedFeedName/Packages(Id=,Version=)
[HttpGet]
[CacheOutput(ServerTimeSpan = NuGetODataConfig.GetByIdAndVersionCacheTimeInSeconds, Private = true, ClientTimeSpan = NuGetODataConfig.GetByIdAndVersionCacheTimeInSeconds)]
public async Task<IHttpActionResult> Get(ODataQueryOptions<V2FeedPackage> options, string curatedFeedName, string id, string version)
{
var result = await GetCore(options, curatedFeedName, id, version, return404NotFoundWhenNoResults: true);
var result = await GetCore(options, curatedFeedName, id, version, return404NotFoundWhenNoResults: true, semVerLevel: null);
return result.FormattedAsSingleResult<V2FeedPackage>();
}

// /api/v2/curated-feed/curatedFeedName/FindPackagesById()?id=
// /api/v2/curated-feed/curatedFeedName/FindPackagesById()?id=&semVerLevel=
[HttpGet]
[HttpPost]
[CacheOutput(ServerTimeSpan = NuGetODataConfig.GetByIdAndVersionCacheTimeInSeconds, Private = true, ClientTimeSpan = NuGetODataConfig.GetByIdAndVersionCacheTimeInSeconds)]
public async Task<IHttpActionResult> FindPackagesById(ODataQueryOptions<V2FeedPackage> options, string curatedFeedName, [FromODataUri]string id)
public async Task<IHttpActionResult> FindPackagesById(
ODataQueryOptions<V2FeedPackage> options,
string curatedFeedName,
[FromODataUri] string id,
[FromUri] string semVerLevel = null)
{
if (string.IsNullOrEmpty(curatedFeedName) || string.IsNullOrEmpty(id))
{
Expand All @@ -90,10 +100,16 @@ public async Task<IHttpActionResult> FindPackagesById(ODataQueryOptions<V2FeedPa
return QueryResult(options, emptyResult, MaxPageSize);
}

return await GetCore(options, curatedFeedName, id, version: null, return404NotFoundWhenNoResults: false);
return await GetCore(options, curatedFeedName, id, version: null, return404NotFoundWhenNoResults: false, semVerLevel: semVerLevel);
}

private async Task<IHttpActionResult> GetCore(ODataQueryOptions<V2FeedPackage> options, string curatedFeedName, string id, string version, bool return404NotFoundWhenNoResults)
private async Task<IHttpActionResult> GetCore(
ODataQueryOptions<V2FeedPackage> options,
string curatedFeedName,
string id,
string version,
bool return404NotFoundWhenNoResults,
string semVerLevel)
{
var curatedFeed = _entities.CuratedFeeds.FirstOrDefault(cf => cf.Name == curatedFeedName);
if (curatedFeed == null)
Expand All @@ -102,8 +118,8 @@ private async Task<IHttpActionResult> GetCore(ODataQueryOptions<V2FeedPackage> o
}

var packages = _curatedFeedService.GetPackages(curatedFeedName)
.Where(p => p.SemVerLevelKey == SemVerLevelKey.Unknown
&& p.PackageRegistration.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
.Where(SemVerLevelKey.IsPackageCompliantWithSemVerLevel(semVerLevel))
.Where(p => p.PackageRegistration.Id.Equals(id, StringComparison.OrdinalIgnoreCase));

if (!string.IsNullOrEmpty(version))
{
Expand Down Expand Up @@ -167,7 +183,7 @@ public IHttpActionResult GetPropertyFromPackages(string propertyName, string id,
return BadRequest("Querying property " + propertyName + " is not supported.");
}

// /api/v2/curated-feed/curatedFeedName/Search()?searchTerm=&targetFramework=&includePrerelease=
// /api/v2/curated-feed/curatedFeedName/Search()?searchTerm=&targetFramework=&includePrerelease=&semVerLevel=
[HttpGet]
[HttpPost]
[CacheOutput(ServerTimeSpan = NuGetODataConfig.SearchCacheTimeInSeconds, ClientTimeSpan = NuGetODataConfig.SearchCacheTimeInSeconds)]
Expand All @@ -176,7 +192,8 @@ public async Task<IHttpActionResult> Search(
string curatedFeedName,
[FromODataUri]string searchTerm = "",
[FromODataUri]string targetFramework = "",
[FromODataUri]bool includePrerelease = false)
[FromODataUri]bool includePrerelease = false,
[FromUri]string semVerLevel = null)
{
if (!_entities.CuratedFeeds.Any(cf => cf.Name == curatedFeedName))
{
Expand All @@ -203,7 +220,7 @@ public async Task<IHttpActionResult> Search(
// Perform actual search
var curatedFeed = _curatedFeedService.GetFeedByName(curatedFeedName, includePackages: false);
var packages = _curatedFeedService.GetPackages(curatedFeedName)
.Where(p => p.SemVerLevelKey == SemVerLevelKey.Unknown)
.Where(SemVerLevelKey.IsPackageCompliantWithSemVerLevel(semVerLevel))
.OrderBy(p => p.PackageRegistration.Id).ThenBy(p => p.Version);

// todo: search hijack should take queryOptions instead of manually parsing query options
Expand Down Expand Up @@ -247,9 +264,10 @@ public async Task<IHttpActionResult> SearchCount(
string curatedFeedName,
[FromODataUri]string searchTerm = "",
[FromODataUri]string targetFramework = "",
[FromODataUri]bool includePrerelease = false)
[FromODataUri]bool includePrerelease = false,
[FromUri]string semVerLevel = null)
{
var searchResults = await Search(options, curatedFeedName, searchTerm, targetFramework, includePrerelease);
var searchResults = await Search(options, curatedFeedName, searchTerm, targetFramework, includePrerelease, semVerLevel);
return searchResults.FormattedAsCountResult<V2FeedPackage>();
}
}
Expand Down
Loading

0 comments on commit 6055211

Please sign in to comment.