diff --git a/build/Shared/AotCompatibilityAttributes.cs b/build/Shared/AotCompatibilityAttributes.cs new file mode 100644 index 00000000000..4423a971a0c --- /dev/null +++ b/build/Shared/AotCompatibilityAttributes.cs @@ -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 diff --git a/build/Shared/NuGetFeatureFlags.cs b/build/Shared/NuGetFeatureFlags.cs new file mode 100644 index 00000000000..f51979b9913 --- /dev/null +++ b/build/Shared/NuGetFeatureFlags.cs @@ -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 _isLegacyJsonDeserializationEnabledByEnvironment = + new Lazy(() => IsLegacyJsonDeserializationEnabledByEnvironment(EnvironmentVariableWrapper.Instance)); + + /// Feature switch for legacy (Newtonsoft) JSON deserialization. Defaults to (STJ is the default). + [FeatureSwitchDefinition(UseLegacyJsonDeserializationSwitchName)] + internal static bool UseLegacyJsonDeserializationFeatureSwitch { get; } = + AppContext.TryGetSwitch(UseLegacyJsonDeserializationSwitchName, out bool value) && value; + + /// Returns when env var NUGET_USE_LEGACY_JSON_DESERIALIZATION is true. + /// + /// Pass (or omit) in production code to use the cached value, + /// avoiding repeated allocations on .NET Framework. Pass an explicit + /// only in tests to override the value. + /// + 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); + } + } +} diff --git a/src/NuGet.Core/NuGet.Protocol/Model/AutoCompleteModel.cs b/src/NuGet.Core/NuGet.Protocol/Model/AutoCompleteModel.cs new file mode 100644 index 00000000000..1a02756fd90 --- /dev/null +++ b/src/NuGet.Core/NuGet.Protocol/Model/AutoCompleteModel.cs @@ -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; } + } +} diff --git a/src/NuGet.Core/NuGet.Protocol/NuGet.Protocol.csproj b/src/NuGet.Core/NuGet.Protocol/NuGet.Protocol.csproj index 5ed856305a9..4e88888de0f 100644 --- a/src/NuGet.Core/NuGet.Protocol/NuGet.Protocol.csproj +++ b/src/NuGet.Core/NuGet.Protocol/NuGet.Protocol.csproj @@ -39,6 +39,8 @@ + + diff --git a/src/NuGet.Core/NuGet.Protocol/Resources/AutoCompleteResourceV3.cs b/src/NuGet.Core/NuGet.Protocol/Resources/AutoCompleteResourceV3.cs index 9a34a284623..51b9068f3d5 100644 --- a/src/NuGet.Core/NuGet.Protocol/Resources/AutoCompleteResourceV3.cs +++ b/src/NuGet.Core/NuGet.Protocol/Resources/AutoCompleteResourceV3.cs @@ -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() @@ -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> 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> 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( + new HttpSourceRequest(queryUri, logger), + async stream => + { + 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> 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, @@ -83,6 +127,25 @@ public override async Task> 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> VersionStartsWith( string packageId, string versionPrefix, diff --git a/src/NuGet.Core/NuGet.Protocol/Utility/JsonContext.cs b/src/NuGet.Core/NuGet.Protocol/Utility/JsonContext.cs index 2a181357a86..a8ad9d7462b 100644 --- a/src/NuGet.Core/NuGet.Protocol/Utility/JsonContext.cs +++ b/src/NuGet.Core/NuGet.Protocol/Utility/JsonContext.cs @@ -17,6 +17,7 @@ namespace NuGet.Protocol.Utility [JsonSerializable(typeof(HttpFileSystemBasedFindPackageByIdResource.FlatContainerVersionList))] [JsonSerializable(typeof(IReadOnlyList), TypeInfoPropertyName = "VulnerabilityIndex")] [JsonSerializable(typeof(CaseInsensitiveDictionary>), TypeInfoPropertyName = "VulnerabilityPage")] + [JsonSerializable(typeof(AutoCompleteModel))] internal partial class JsonContext : JsonSerializerContext { } diff --git a/test/NuGet.Core.Tests/NuGet.Protocol.Tests/AutoCompleteResourceV3Tests.cs b/test/NuGet.Core.Tests/NuGet.Protocol.Tests/AutoCompleteResourceV3Tests.cs index ca3fd189b31..83ca6f4a351 100644 --- a/test/NuGet.Core.Tests/NuGet.Protocol.Tests/AutoCompleteResourceV3Tests.cs +++ b/test/NuGet.Core.Tests/NuGet.Protocol.Tests/AutoCompleteResourceV3Tests.cs @@ -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; @@ -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 var responses = new Dictionary(); - 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(CancellationToken.None); + var repo = StaticHttpHandler.CreateSource("http://testsource.test/v3/index.json", Repository.Provider.GetCoreV3(), responses); + var resource = (AutoCompleteResourceV3)await repo.GetResourceAsync(CancellationToken.None); + var envReader = new Mock(); + 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()); diff --git a/test/NuGet.Core.Tests/NuGet.Protocol.Tests/NuGetFeatureFlagsTests.cs b/test/NuGet.Core.Tests/NuGet.Protocol.Tests/NuGetFeatureFlagsTests.cs new file mode 100644 index 00000000000..434b12464da --- /dev/null +++ b/test/NuGet.Core.Tests/NuGet.Protocol.Tests/NuGetFeatureFlagsTests.cs @@ -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 { [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 { [NuGetFeatureFlags.UseLegacyJsonDeserializationEnvVar] = value }); + + Assert.False(NuGetFeatureFlags.IsLegacyJsonDeserializationEnabledByEnvironment(env)); + } + } +} diff --git a/test/NuGet.Core.Tests/NuGet.Shared.Tests/NuGet.Shared.Tests.csproj b/test/NuGet.Core.Tests/NuGet.Shared.Tests/NuGet.Shared.Tests.csproj index ec64224afc9..9450cdf59cb 100644 --- a/test/NuGet.Core.Tests/NuGet.Shared.Tests/NuGet.Shared.Tests.csproj +++ b/test/NuGet.Core.Tests/NuGet.Shared.Tests/NuGet.Shared.Tests.csproj @@ -9,6 +9,8 @@ + +