Skip to content
Open
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
14 changes: 14 additions & 0 deletions build/Shared/AotCompatibilityAttributes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

#if !NET9_0_OR_GREATER
namespace System.Diagnostics.CodeAnalysis
{
[AttributeUsage(AttributeTargets.Property, Inherited = false)]
internal sealed class FeatureSwitchDefinitionAttribute : Attribute
{
public FeatureSwitchDefinitionAttribute(string switchName) => SwitchName = switchName;
public string SwitchName { get; }
}
}
#endif
40 changes: 40 additions & 0 deletions build/Shared/NuGetFeatureFlags.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Diagnostics.CodeAnalysis;
using NuGet.Common;

namespace NuGet.Shared
{
internal static class NuGetFeatureFlags
{
internal const string UseLegacyJsonDeserializationSwitchName = "NuGet.UseLegacyJsonDeserialization";
internal const string UseLegacyJsonDeserializationEnvVar = "NUGET_USE_LEGACY_JSON_DESERIALIZATION";

private static readonly Lazy<bool> _isLegacyJsonDeserializationEnabledByEnvironment =
new Lazy<bool>(() => IsLegacyJsonDeserializationEnabledByEnvironment(EnvironmentVariableWrapper.Instance));

/// <summary>Feature switch for legacy (Newtonsoft) JSON deserialization. Defaults to <see langword="false"/> (STJ is the default).</summary>
[FeatureSwitchDefinition(UseLegacyJsonDeserializationSwitchName)]
internal static bool UseLegacyJsonDeserializationFeatureSwitch { get; } =
AppContext.TryGetSwitch(UseLegacyJsonDeserializationSwitchName, out bool value) && value;

/// <summary>Returns <see langword="true"/> when env var <c>NUGET_USE_LEGACY_JSON_DESERIALIZATION</c> is <c>true</c>.</summary>
/// <param name="env">
/// Pass <see langword="null"/> (or omit) in production code to use the cached <see cref="Lazy{T}"/> value,
/// avoiding repeated allocations on .NET Framework. Pass an explicit <see cref="IEnvironmentVariableReader"/>
/// only in tests to override the value.
/// </param>
internal static bool IsLegacyJsonDeserializationEnabledByEnvironment(IEnvironmentVariableReader? env = null)
{
if (env is null)
{
return _isLegacyJsonDeserializationEnabledByEnvironment.Value;
}

string? envValue = env.GetEnvironmentVariable(UseLegacyJsonDeserializationEnvVar);
return string.Equals(envValue, "true", StringComparison.OrdinalIgnoreCase);
}
}
}
13 changes: 13 additions & 0 deletions src/NuGet.Core/NuGet.Protocol/Model/AutoCompleteModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Text.Json.Serialization;

namespace NuGet.Protocol.Model
{
internal sealed class AutoCompleteModel
{
[JsonPropertyName("data")]
public string[]? Data { get; set; }
}
}
2 changes: 2 additions & 0 deletions src/NuGet.Core/NuGet.Protocol/NuGet.Protocol.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
<Compile Include="$(SharedDirectory)\HashCodeCombiner.cs" />
<Compile Include="$(SharedDirectory)\NoAllocEnumerateExtensions.cs" />
<Compile Include="$(SharedDirectory)\NullableAttributes.cs" />
<Compile Include="$(SharedDirectory)\NuGetFeatureFlags.cs" />
<Compile Include="$(SharedDirectory)\AotCompatibilityAttributes.cs" />
<Compile Include="$(SharedDirectory)\SimplePool.cs" />
<Compile Include="$(SharedDirectory)\StringBuilderPool.cs" />
<Compile Include="$(SharedDirectory)\TaskResult.cs" />
Expand Down
93 changes: 78 additions & 15 deletions src/NuGet.Core/NuGet.Protocol/Resources/AutoCompleteResourceV3.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,25 @@
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using NuGet.Common;
using NuGet.Protocol.Core.Types;
using NuGet.Protocol.Model;
using NuGet.Protocol.Utility;
using NuGet.Shared;
using NuGet.Versioning;

namespace NuGet.Protocol
{
public class AutoCompleteResourceV3 : AutoCompleteResource
{
private readonly RegistrationResourceV3 _regResource;
private readonly ServiceIndexResourceV3 _serviceIndex;
private readonly HttpSource _client;
internal readonly RegistrationResourceV3 _regResource;
internal readonly ServiceIndexResourceV3 _serviceIndex;
internal readonly HttpSource _client;
private readonly IEnvironmentVariableReader _environmentVariableReader;

public AutoCompleteResourceV3(HttpSource client, ServiceIndexResourceV3 serviceIndex, RegistrationResourceV3 regResource)
: base()
Expand All @@ -30,31 +36,69 @@ public AutoCompleteResourceV3(HttpSource client, ServiceIndexResourceV3 serviceI
_client = client;
}

internal AutoCompleteResourceV3(HttpSource client, ServiceIndexResourceV3 serviceIndex, RegistrationResourceV3 regResource, IEnvironmentVariableReader environmentVariableReader)
: base()
{
_regResource = regResource;
_serviceIndex = serviceIndex;
_client = client;
_environmentVariableReader = environmentVariableReader;
}

public override async Task<IEnumerable<string>> IdStartsWith(
string packageIdPrefix,
bool includePrerelease,
Common.ILogger log,
CancellationToken token)
{
var searchUrl = _serviceIndex.GetServiceEntryUri(ServiceTypes.SearchAutocompleteService);

if (searchUrl == null)
if (NuGetFeatureFlags.UseLegacyJsonDeserializationFeatureSwitch || NuGetFeatureFlags.IsLegacyJsonDeserializationEnabledByEnvironment(_environmentVariableReader))
{
throw new FatalProtocolException(Strings.Protocol_MissingSearchService);
return await IdStartsWithNsjAsync(packageIdPrefix, includePrerelease, log, token);
}
else
{
return await IdStartsWithStjAsync(packageIdPrefix, includePrerelease, log, token);
}
}

// Construct the query
var queryUrl = new UriBuilder(searchUrl.AbsoluteUri);
var queryString =
"q=" + WebUtility.UrlEncode(packageIdPrefix) +
"&prerelease=" + includePrerelease.ToString(CultureInfo.CurrentCulture).ToLowerInvariant() +
"&semVerLevel=2.0.0";
private async Task<IEnumerable<string>> IdStartsWithStjAsync(
string packageIdPrefix,
bool includePrerelease,
Common.ILogger log,
CancellationToken token)
{
var queryUri = BuildQueryUri(packageIdPrefix, includePrerelease);
Common.ILogger logger = log ?? Common.NullLogger.Instance;

queryUrl.Query = queryString;
AutoCompleteModel results = await _client.ProcessStreamAsync(
Copy link
Copy Markdown

@richlander richlander Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am surprised that AutoCompleteModel is always non-null. Is that correct?

Does this library not have nullable enabled?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet. It's #nullable disabled for now, but it's a work in progress

new HttpSourceRequest(queryUri, logger),
async stream =>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what the team code style is, if they link lamdas defined ahead of the method call or within it.

{
if (stream == null)
{
return null;
}

return await JsonSerializer.DeserializeAsync(stream, JsonContext.Default.AutoCompleteModel, token);
},
logger,
token);

token.ThrowIfCancellationRequested();

return results?.Data?.Where(item => item != null && item.StartsWith(packageIdPrefix, StringComparison.OrdinalIgnoreCase))
?? [];
}

private async Task<IEnumerable<string>> IdStartsWithNsjAsync(
string packageIdPrefix,
bool includePrerelease,
Common.ILogger log,
CancellationToken token)
{
var queryUri = BuildQueryUri(packageIdPrefix, includePrerelease);
Common.ILogger logger = log ?? Common.NullLogger.Instance;

var queryUri = queryUrl.Uri;
var results = await _client.GetJObjectAsync(
new HttpSourceRequest(queryUri, logger),
logger,
Expand Down Expand Up @@ -83,6 +127,25 @@ public override async Task<IEnumerable<string>> IdStartsWith(
return outputs.Where(item => item.StartsWith(packageIdPrefix, StringComparison.OrdinalIgnoreCase));
}

private Uri BuildQueryUri(string packageIdPrefix, bool includePrerelease)
{
var searchUrl = _serviceIndex.GetServiceEntryUri(ServiceTypes.SearchAutocompleteService);

if (searchUrl == null)
{
throw new FatalProtocolException(Strings.Protocol_MissingSearchService);
}

// Construct the query
var queryUrl = new UriBuilder(searchUrl.AbsoluteUri);
queryUrl.Query =
"q=" + WebUtility.UrlEncode(packageIdPrefix) +
"&prerelease=" + includePrerelease.ToString(CultureInfo.CurrentCulture).ToLowerInvariant() +
"&semVerLevel=2.0.0";

return queryUrl.Uri;
}

public override async Task<IEnumerable<NuGetVersion>> VersionStartsWith(
string packageId,
string versionPrefix,
Expand Down
1 change: 1 addition & 0 deletions src/NuGet.Core/NuGet.Protocol/Utility/JsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ namespace NuGet.Protocol.Utility
[JsonSerializable(typeof(HttpFileSystemBasedFindPackageByIdResource.FlatContainerVersionList))]
[JsonSerializable(typeof(IReadOnlyList<V3VulnerabilityIndexEntry>), TypeInfoPropertyName = "VulnerabilityIndex")]
[JsonSerializable(typeof(CaseInsensitiveDictionary<IReadOnlyList<PackageVulnerabilityInfo>>), TypeInfoPropertyName = "VulnerabilityPage")]
[JsonSerializable(typeof(AutoCompleteModel))]
internal partial class JsonContext : JsonSerializerContext
{
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NuGet.Common;
using NuGet.Protocol.Core.Types;
using NuGet.Shared;
using NuGet.Test.Utility;
using Test.Utility;
using Xunit;
Expand All @@ -14,23 +17,27 @@ namespace NuGet.Protocol.Tests
{
public class AutoCompleteResourceV3Tests
{
[Fact]
public async Task AutoCompleteResourceV3_IdStartsWithAsync()
[Theory]
[InlineData("true")] // NSJ path
[InlineData("false")] // STJ path
public async Task IdStartsWith_BothPaths_ReturnsResultsAsync(string useNsj)
{
// Arrange
Comment thread
Nigusu-Allehu marked this conversation as resolved.
var responses = new Dictionary<string, string>();
const string sourceName = "http://testsource.com/v3/index.json";
responses.Add(sourceName, JsonData.IndexWithoutFlatContainer);
responses.Add("http://testsource.test/v3/index.json", JsonData.IndexWithoutFlatContainer);
responses.Add("https://api-v3search-0.nuget.org/autocomplete?q=newt&prerelease=true&semVerLevel=2.0.0",
JsonData.AutoCompleteEndpointNewtResult);

var repo = StaticHttpHandler.CreateSource(sourceName, Repository.Provider.GetCoreV3(), responses);
var resource = await repo.GetResourceAsync<AutoCompleteResource>(CancellationToken.None);
var repo = StaticHttpHandler.CreateSource("http://testsource.test/v3/index.json", Repository.Provider.GetCoreV3(), responses);
var resource = (AutoCompleteResourceV3)await repo.GetResourceAsync<AutoCompleteResource>(CancellationToken.None);

var envReader = new Mock<IEnvironmentVariableReader>();
envReader.Setup(e => e.GetEnvironmentVariable(NuGetFeatureFlags.UseLegacyJsonDeserializationEnvVar)).Returns(useNsj);
var testResource = new AutoCompleteResourceV3(resource._client, resource._serviceIndex, resource._regResource, envReader.Object);
var logger = new TestLogger();

// Act
var result = await resource.IdStartsWith("newt", true, logger, CancellationToken.None);
var result = await testResource.IdStartsWith("newt", true, logger, CancellationToken.None);

// Assert
Assert.Equal(10, result.Count());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Collections.Generic;
using NuGet.Shared;
using Test.Utility;
using Xunit;

namespace NuGet.Protocol.Tests
{
public class NuGetFeatureFlagsTests
{
[Fact]
public void UseLegacyJsonDeserializationFeatureSwitch_Default_ReturnsFalse()
{
Assert.False(NuGetFeatureFlags.UseLegacyJsonDeserializationFeatureSwitch);
}

[Fact]
public void IsLegacyJsonDeserializationEnabledByEnvironment_WhenEnvVarNotSet_ReturnsFalse()
{
Assert.False(NuGetFeatureFlags.IsLegacyJsonDeserializationEnabledByEnvironment(TestEnvironmentVariableReader.EmptyInstance));
}

[Theory]
[InlineData("true")]
[InlineData("True")]
[InlineData("TRUE")]
public void IsLegacyJsonDeserializationEnabledByEnvironment_WhenEnvVarSetToTrue_ReturnsTrue(string value)
{
var env = new TestEnvironmentVariableReader(
new Dictionary<string, string> { [NuGetFeatureFlags.UseLegacyJsonDeserializationEnvVar] = value });

Assert.True(NuGetFeatureFlags.IsLegacyJsonDeserializationEnabledByEnvironment(env));
}

[Theory]
[InlineData("false")]
[InlineData("0")]
[InlineData("1")]
[InlineData("anything")]
public void IsLegacyJsonDeserializationEnabledByEnvironment_WhenEnvVarSetToFalseOrUnrecognized_ReturnsFalse(string value)
{
var env = new TestEnvironmentVariableReader(
new Dictionary<string, string> { [NuGetFeatureFlags.UseLegacyJsonDeserializationEnvVar] = value });

Assert.False(NuGetFeatureFlags.IsLegacyJsonDeserializationEnabledByEnvironment(env));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<Compile Include="$(SharedDirectory)\*.cs" Exclude="bin\**;obj\**;**\*.xproj;packages\**" />
<Compile Remove="$(SharedDirectory)\IsExternalInit.cs" />
<Compile Remove="$(SharedDirectory)\RequiredModifierAttributes.cs" />
<Compile Remove="$(SharedDirectory)\AotCompatibilityAttributes.cs" />
<Compile Remove="$(SharedDirectory)\NuGetFeatureFlags.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\TestUtilities\Test.Utility\Test.Utility.csproj" />
Expand Down