diff --git a/eng/.docsettings.yml b/eng/.docsettings.yml index 95544f50c84d..8ea85037bbda 100644 --- a/eng/.docsettings.yml +++ b/eng/.docsettings.yml @@ -143,6 +143,7 @@ known_content_issues: - ['sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Blobs/README.md','azure-sdk-tools/issues/404'] - ['sdk/extensions/Azure.Extensions.AspNetCore.DataProtection.Keys/README.md','azure-sdk-tools/issues/404'] - ['sdk/extensions/Azure.Extensions.AspNetCore.Configuration.Secrets/README.md', 'azure-sdk-tools/issues/404'] + - ['sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/README.md','azure-sdk-tools/issues/404'] - ['sdk/search/README.md','azure-sdk-tools/issues/42'] - ['sdk/formrecognizer/Azure.AI.FormRecognizer/README.md','#5499'] - ['sdk/formrecognizer/Azure.AI.FormRecognizer/README.md','#11492'] diff --git a/eng/Packages.Data.props b/eng/Packages.Data.props index 609c9db97965..94da88b094cb 100644 --- a/eng/Packages.Data.props +++ b/eng/Packages.Data.props @@ -55,6 +55,7 @@ + @@ -142,6 +143,7 @@ + diff --git a/sdk/core/Azure.Core.TestFramework/src/RecordedTestAttribute.cs b/sdk/core/Azure.Core.TestFramework/src/RecordedTestAttribute.cs index b9594be5d426..f95a8fa3ad28 100644 --- a/sdk/core/Azure.Core.TestFramework/src/RecordedTestAttribute.cs +++ b/sdk/core/Azure.Core.TestFramework/src/RecordedTestAttribute.cs @@ -45,15 +45,23 @@ public override TestResult Execute(TestExecutionContext context) // Check the result if (IsTestFailedWithRecordingMismatch(context)) { + var originalResult = context.CurrentResult; context.CurrentResult = context.CurrentTest.MakeTestResult(); // Run the test again after setting the RecordedTestMode to Record SetRecordMode(context.TestObject as RecordedTestBase, RecordedTestMode.Record); context.CurrentResult = innerCommand.Execute(context); - // If the recording succeeded, set a warning result. - if (!IsTestFailedWithRecordingMismatch(context)) + // If the recording succeeded, set an error result. + if (context.CurrentResult.ResultState.Status == TestStatus.Passed) { - context.CurrentResult.SetResult(ResultState.Error, "Test failed playback, but was successfully re-recorded (it should pass if re-run). Please copy updated recording to SessionFiles."); + context.CurrentResult.SetResult(ResultState.Error, "Test failed playback, but was successfully re-recorded (it should pass if re-run). Please copy updated recordings to SessionFiles using `dotnet msbuild /t:UpdateSessionRecords`."); + } + else + { + context.CurrentResult.SetResult(context.CurrentResult.ResultState, + "Error while trying to re-record: " + Environment.NewLine + + context.CurrentResult.Message + Environment.NewLine + + "Original error: " + originalResult.Message, context.CurrentResult.Message); } // revert RecordTestMode to Playback @@ -71,7 +79,7 @@ private static bool IsTestFailedWithRecordingMismatch(TestExecutionContext conte _ => true }; - return failed && context.CurrentResult.Message.StartsWith(typeof(TestRecordingMismatchException).FullName); + return failed && context.CurrentResult.Message.Contains(typeof(TestRecordingMismatchException).FullName); } } diff --git a/sdk/core/Azure.Core.TestFramework/src/TestRecording.cs b/sdk/core/Azure.Core.TestFramework/src/TestRecording.cs index 0319b867da1f..6826c615f268 100644 --- a/sdk/core/Azure.Core.TestFramework/src/TestRecording.cs +++ b/sdk/core/Azure.Core.TestFramework/src/TestRecording.cs @@ -49,7 +49,7 @@ public TestRecording(RecordedTestMode mode, string sessionFile, RecordedTestSani { _session = Load(); } - catch (FileNotFoundException ex) + catch (Exception ex) when (ex is FileNotFoundException || ex is DirectoryNotFoundException) { throw new TestRecordingMismatchException(ex.Message, ex); } diff --git a/sdk/core/Microsoft.Extensions.Azure/api/Microsoft.Extensions.Azure.netstandard2.0.cs b/sdk/core/Microsoft.Extensions.Azure/api/Microsoft.Extensions.Azure.netstandard2.0.cs index a9be4f097bab..98c45d017157 100644 --- a/sdk/core/Microsoft.Extensions.Azure/api/Microsoft.Extensions.Azure.netstandard2.0.cs +++ b/sdk/core/Microsoft.Extensions.Azure/api/Microsoft.Extensions.Azure.netstandard2.0.cs @@ -19,6 +19,7 @@ internal AzureClientFactoryBuilder() { } public Microsoft.Extensions.Azure.AzureClientFactoryBuilder ConfigureDefaults(Microsoft.Extensions.Configuration.IConfiguration configuration) { throw null; } public Microsoft.Extensions.Azure.AzureClientFactoryBuilder ConfigureDefaults(System.Action configureOptions) { throw null; } public Microsoft.Extensions.Azure.AzureClientFactoryBuilder ConfigureDefaults(System.Action configureOptions) { throw null; } + public Microsoft.Extensions.Azure.AzureClientFactoryBuilder UseConfiguration(System.Func configurationProvider) { throw null; } public Microsoft.Extensions.Azure.AzureClientFactoryBuilder UseCredential(Azure.Core.TokenCredential tokenCredential) { throw null; } public Microsoft.Extensions.Azure.AzureClientFactoryBuilder UseCredential(System.Func tokenCredentialFactory) { throw null; } } diff --git a/sdk/core/Microsoft.Extensions.Azure/src/AzureClientFactoryBuilder.cs b/sdk/core/Microsoft.Extensions.Azure/src/AzureClientFactoryBuilder.cs index 5a1e57e84764..6b9332a07570 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/AzureClientFactoryBuilder.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/AzureClientFactoryBuilder.cs @@ -25,6 +25,7 @@ internal AzureClientFactoryBuilder(IServiceCollection serviceCollection) _serviceCollection = serviceCollection; _serviceCollection.AddOptions(); _serviceCollection.TryAddSingleton(); + _serviceCollection.TryAddSingleton(typeof(IAzureClientFactory<>), typeof(FallbackAzureClientFactory<>)); } IAzureClientBuilder IAzureClientFactoryBuilder.RegisterClientFactory(Func clientFactory) @@ -91,7 +92,7 @@ public AzureClientFactoryBuilder ConfigureDefaults(IConfiguration configuration) IAzureClientBuilder IAzureClientFactoryBuilderWithCredential.RegisterClientFactory(Func clientFactory, bool requiresCredential) { - var clientRegistration = new ClientRegistration(DefaultClientName, clientFactory); + var clientRegistration = new ClientRegistration(DefaultClientName, (options, credential) => clientFactory((TOptions)options, credential)); clientRegistration.RequiresTokenCredential = requiresCredential; _serviceCollection.AddSingleton(clientRegistration); @@ -128,5 +129,18 @@ public AzureClientFactoryBuilder UseCredential(Func(options => options.CredentialFactory = tokenCredentialFactory); return this; } + + /// + /// Sets the configuration instance that is used to resolve clients that were not explicitly registered. + /// + /// The delegate that returns a configuration instance that's used to resolve client configuration from. + /// This instance. + public AzureClientFactoryBuilder UseConfiguration(Func configurationProvider) + { + _serviceCollection.Configure(options => options.ConfigurationRootResolver = configurationProvider); + + return this; + } + } } \ No newline at end of file diff --git a/sdk/core/Microsoft.Extensions.Azure/src/AzureClientServiceCollectionExtensions.cs b/sdk/core/Microsoft.Extensions.Azure/src/AzureClientServiceCollectionExtensions.cs index 00aeca7fd093..86ba38094cb5 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/AzureClientServiceCollectionExtensions.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/AzureClientServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using System; +using Microsoft.Extensions.Configuration; namespace Microsoft.Extensions.Azure { diff --git a/sdk/core/Microsoft.Extensions.Azure/src/IAzureClientFactory.cs b/sdk/core/Microsoft.Extensions.Azure/src/IAzureClientFactory.cs index 5b3a9089382c..a469ab4b354d 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/IAzureClientFactory.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/IAzureClientFactory.cs @@ -16,4 +16,4 @@ public interface IAzureClientFactory /// An instance of . TClient CreateClient(string name); } -} \ No newline at end of file +} diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientBuilder.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientBuilder.cs index e4cc0a2e41fe..8405de050110 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientBuilder.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientBuilder.cs @@ -8,10 +8,10 @@ namespace Microsoft.Extensions.Azure { internal sealed class AzureClientBuilder: IAzureClientBuilder where TOptions : class { - public ClientRegistration Registration { get; } + public ClientRegistration Registration { get; } public IServiceCollection ServiceCollection { get; } - internal AzureClientBuilder(ClientRegistration clientRegistration, IServiceCollection serviceCollection) + internal AzureClientBuilder(ClientRegistration clientRegistration, IServiceCollection serviceCollection) { Registration = clientRegistration; ServiceCollection = serviceCollection; diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientFactory.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientFactory.cs index 95f388540d32..485364813f31 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientFactory.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientFactory.cs @@ -9,7 +9,7 @@ namespace Microsoft.Extensions.Azure { internal class AzureClientFactory: IAzureClientFactory { - private readonly Dictionary> _clientRegistrations; + private readonly Dictionary> _clientRegistrations; private readonly IServiceProvider _serviceProvider; @@ -22,10 +22,11 @@ internal class AzureClientFactory: IAzureClientFactory> clientsOptions, - IEnumerable> clientRegistrations, IOptionsMonitor monitor, + IEnumerable> clientRegistrations, + IOptionsMonitor monitor, EventSourceLogForwarder logForwarder) { - _clientRegistrations = new Dictionary>(); + _clientRegistrations = new Dictionary>(); foreach (var registration in clientRegistrations) { _clientRegistrations[registration.Name] = registration; @@ -39,7 +40,8 @@ public AzureClientFactory( public TClient CreateClient(string name) { - if (!_clientRegistrations.TryGetValue(name, out ClientRegistration registration)) + _logForwarder.Start(); + if (!_clientRegistrations.TryGetValue(name, out ClientRegistration registration)) { throw new InvalidOperationException($"Unable to find client registration with type '{typeof(TClient).Name}' and name '{name}'."); } diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientsGlobalOptions.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientsGlobalOptions.cs index 65ce601caa34..03884bdc98eb 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientsGlobalOptions.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/AzureClientsGlobalOptions.cs @@ -6,6 +6,7 @@ using Azure.Core; using Azure.Core.Pipeline; using Azure.Identity; +using Microsoft.Extensions.Configuration; namespace Microsoft.Extensions.Azure { @@ -13,5 +14,6 @@ internal class AzureClientsGlobalOptions { public Func CredentialFactory { get; set; } = _ => new DefaultAzureCredential(); public List> ConfigureOptionDelegates { get; } = new List>(); + public Func ConfigurationRootResolver { get; set; } } } \ No newline at end of file diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientFactory.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientFactory.cs index ea33aab420f7..c250191c3f92 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientFactory.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientFactory.cs @@ -15,9 +15,23 @@ namespace Microsoft.Extensions.Azure { internal static class ClientFactory { + private const string ServiceVersionParameterTypeName = "ServiceVersion"; + private const string ConnectionStringParameterName = "connectionString"; + public static object CreateClient(Type clientType, Type optionsType, object options, IConfiguration configuration, TokenCredential credential) { List arguments = new List(); + // Handle single values as connection strings + if (configuration is IConfigurationSection section && section.Value != null) + { + var connectionString = section.Value; + configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new[] + { + new KeyValuePair(ConnectionStringParameterName, connectionString) + }) + .Build(); + } foreach (var constructor in clientType.GetConstructors().OrderByDescending(c => c.GetParameters().Length)) { if (!IsApplicableConstructor(constructor, optionsType)) @@ -120,6 +134,73 @@ internal static TokenCredential CreateCredential(IConfiguration configuration, T return null; } + + internal static object CreateClientOptions(object version, Type optionsType) + { + ConstructorInfo parameterlessConstructor = null; + int versionParameterIndex = 0; + object[] constructorArguments = null; + + foreach (var constructor in optionsType.GetConstructors()) + { + var parameters = constructor.GetParameters(); + if (parameters.Length == 0) + { + parameterlessConstructor = constructor; + continue; + } + + bool allParametersHaveDefaultValue = true; + for (int i = 0; i < parameters.Length; i++) + { + ParameterInfo parameter = parameters[i]; + if (parameter.HasDefaultValue) + { + if (IsServiceVersionParameter(parameter)) + { + versionParameterIndex = i; + } + } + else + { + allParametersHaveDefaultValue = false; + break; + } + } + + if (allParametersHaveDefaultValue) + { + constructorArguments = new object[parameters.Length]; + + for (int i = 0; i < parameters.Length; i++) + { + constructorArguments[i] = parameters[i].DefaultValue; + } + } + } + + if (version != null) + { + if (constructorArguments != null) + { + constructorArguments[versionParameterIndex] = version; + return Activator.CreateInstance(optionsType, constructorArguments); + } + + throw new InvalidOperationException("Unable to find constructor that takes service version"); + } + + if (parameterlessConstructor != null) + { + return Activator.CreateInstance(optionsType); + } + + return Activator.CreateInstance(optionsType, constructorArguments); + } + + private static bool IsServiceVersionParameter(ParameterInfo parameter) => + parameter.ParameterType.Name == ServiceVersionParameterTypeName; + private static bool IsCredentialParameter(ParameterInfo parameter) { return parameter.ParameterType == typeof(TokenCredential); diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientOptionsFactory.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientOptionsFactory.cs index c2d3cbcac9f1..78d497a81012 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientOptionsFactory.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientOptionsFactory.cs @@ -11,14 +11,12 @@ namespace Microsoft.Extensions.Azure // Slightly adjusted copy of https://github.com/aspnet/Extensions/blob/master/src/Options/Options/src/OptionsFactory.cs internal class ClientOptionsFactory where TOptions : class { - private const string ServiceVersionParameterTypeName = "ServiceVersion"; - private readonly IEnumerable> _setups; private readonly IEnumerable> _postConfigures; - private readonly IEnumerable> _clientRegistrations; + private readonly IEnumerable> _clientRegistrations; - public ClientOptionsFactory(IEnumerable> setups, IEnumerable> postConfigures, IEnumerable> clientRegistrations) + public ClientOptionsFactory(IEnumerable> setups, IEnumerable> postConfigures, IEnumerable> clientRegistrations) { _setups = setups; _postConfigures = postConfigures; @@ -37,70 +35,9 @@ private TOptions CreateOptions(string name) } } - ConstructorInfo parameterlessConstructor = null; - int versionParameterIndex = 0; - object[] constructorArguments = null; - - foreach (var constructor in typeof(TOptions).GetConstructors()) - { - var parameters = constructor.GetParameters(); - if (parameters.Length == 0) - { - parameterlessConstructor = constructor; - continue; - } - - bool allParametersHaveDefaultValue = true; - for (int i = 0; i < parameters.Length; i++) - { - ParameterInfo parameter = parameters[i]; - if (parameter.HasDefaultValue) - { - if (IsServiceVersionParameter(parameter)) - { - versionParameterIndex = i; - } - } - else - { - allParametersHaveDefaultValue = false; - break; - } - } - - if (allParametersHaveDefaultValue) - { - constructorArguments = new object[parameters.Length]; - - for (int i = 0; i < parameters.Length; i++) - { - constructorArguments[i] = parameters[i].DefaultValue; - } - } - } - - if (version != null) - { - if (constructorArguments != null) - { - constructorArguments[versionParameterIndex] = version; - return (TOptions)Activator.CreateInstance(typeof(TOptions), constructorArguments); - } - - throw new InvalidOperationException("Unable to find constructor that takes service version"); - } - - if (parameterlessConstructor != null) - { - return Activator.CreateInstance(); - } - - return (TOptions)Activator.CreateInstance(typeof(TOptions), constructorArguments); + return (TOptions)ClientFactory.CreateClientOptions(version, typeof(TOptions)); } - private static bool IsServiceVersionParameter(ParameterInfo parameter) => - parameter.ParameterType.Name == ServiceVersionParameterTypeName; - /// /// Returns a configured instance with the given . /// diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientRegistration{TClient,TOptions}.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientRegistration{TClient}.cs similarity index 83% rename from sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientRegistration{TClient,TOptions}.cs rename to sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientRegistration{TClient}.cs index 3c89888c75f8..d926aedeab09 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientRegistration{TClient,TOptions}.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ClientRegistration{TClient}.cs @@ -7,13 +7,13 @@ namespace Microsoft.Extensions.Azure { - internal class ClientRegistration + internal class ClientRegistration { public string Name { get; set; } public object Version { get; set; } public bool RequiresTokenCredential { get; set; } - private readonly Func _factory; + private readonly Func _factory; private readonly object _cacheLock = new object(); @@ -21,13 +21,13 @@ internal class ClientRegistration private ExceptionDispatchInfo _cachedException; - public ClientRegistration(string name, Func factory) + public ClientRegistration(string name, Func factory) { Name = name; _factory = factory; } - public TClient GetClient(TOptions options, TokenCredential tokenCredential) + public TClient GetClient(object options, TokenCredential tokenCredential) { _cachedException?.Throw(); diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ConfigureClientCredentials.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ConfigureClientCredentials.cs index 6e62aa15c08d..f3b9b4d2e587 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ConfigureClientCredentials.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ConfigureClientCredentials.cs @@ -9,11 +9,11 @@ namespace Microsoft.Extensions.Azure { internal class ConfigureClientCredentials : IConfigureNamedOptions> { - private readonly ClientRegistration _registration; + private readonly ClientRegistration _registration; private readonly Func _credentialFactory; public ConfigureClientCredentials( - ClientRegistration registration, + ClientRegistration registration, Func credentialFactory) { _registration = registration; diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ConfigureClientOptions.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ConfigureClientOptions.cs index 10708f684d95..5490d1f263e0 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Internal/ConfigureClientOptions.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/ConfigureClientOptions.cs @@ -9,10 +9,10 @@ namespace Microsoft.Extensions.Azure internal class ConfigureClientOptions : IConfigureNamedOptions where TOptions : class { private readonly IServiceProvider _serviceProvider; - private readonly ClientRegistration _registration; + private readonly ClientRegistration _registration; private readonly Action _configureOptions; - public ConfigureClientOptions(IServiceProvider serviceProvider, ClientRegistration registration, Action configureOptions) + public ConfigureClientOptions(IServiceProvider serviceProvider, ClientRegistration registration, Action configureOptions) { _serviceProvider = serviceProvider; _registration = registration; diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/EventSourceLogForwarder.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/EventSourceLogForwarder.cs index e969cbabb934..fbb2ec0796e8 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Internal/EventSourceLogForwarder.cs +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/EventSourceLogForwarder.cs @@ -20,12 +20,16 @@ internal class EventSourceLogForwarder: IDisposable private readonly Func _formatMessage = FormatMessage; - private readonly AzureEventSourceListener _listener; + private AzureEventSourceListener _listener; public EventSourceLogForwarder(ILoggerFactory loggerFactory = null) { _loggerFactory = loggerFactory; - _listener = new AzureEventSourceListener((e, s) => LogEvent(e), EventLevel.Verbose); + } + + public void Start() + { + _listener ??= new AzureEventSourceListener((e, s) => LogEvent(e), EventLevel.Verbose); } private void LogEvent(EventWrittenEventArgs eventData) diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Internal/FallbackAzureClientFactory.cs b/sdk/core/Microsoft.Extensions.Azure/src/Internal/FallbackAzureClientFactory.cs new file mode 100644 index 000000000000..ca5915c0c116 --- /dev/null +++ b/sdk/core/Microsoft.Extensions.Azure/src/Internal/FallbackAzureClientFactory.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Azure.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Azure +{ + internal class FallbackAzureClientFactory: IAzureClientFactory + { + private readonly IOptionsMonitor _globalOptions; + private readonly IServiceProvider _serviceProvider; + private readonly EventSourceLogForwarder _logForwarder; + private readonly Dictionary> _clientRegistrations; + private readonly Type _clientOptionType; + + public FallbackAzureClientFactory( + IOptionsMonitor globalOptions, + IServiceProvider serviceProvider, + EventSourceLogForwarder logForwarder) + { + _globalOptions = globalOptions; + _serviceProvider = serviceProvider; + _logForwarder = logForwarder; + _clientRegistrations = new Dictionary>(); + + foreach (var constructor in typeof(TClient).GetConstructors(BindingFlags.Public | BindingFlags.Instance)) + { + var lastParameter = constructor.GetParameters().LastOrDefault(); + if (lastParameter != null && typeof(ClientOptions).IsAssignableFrom(lastParameter.ParameterType)) + { + _clientOptionType = lastParameter.ParameterType; + break; + } + } + + if (_clientOptionType == null) + { + throw new InvalidOperationException("Unable to detect the client option type"); + } + } + + public TClient CreateClient(string name) + { + _logForwarder.Start(); + + var globalOptions = _globalOptions.CurrentValue; + + FallbackClientRegistration registration; + lock (_clientRegistrations) + { + if (!_clientRegistrations.TryGetValue(name, out registration)) + { + var section = globalOptions.ConfigurationRootResolver?.Invoke(_serviceProvider).GetSection(name); + if (!section.Exists()) + { + throw new InvalidOperationException($"Unable to find a configuration section with the name {name} to configure the client with or the configuration root wasn't set."); + } + + registration = new FallbackClientRegistration( + name, + (options, credential) => (TClient) ClientFactory.CreateClient(typeof(TClient), _clientOptionType, options, section, credential), + section); + + _clientRegistrations.Add(name, registration); + } + } + + + return registration.GetClient( + GetClientOptions(globalOptions, registration.Configuration), + ClientFactory.CreateCredential(registration.Configuration) ?? globalOptions.CredentialFactory(_serviceProvider)); + } + + private object GetClientOptions(AzureClientsGlobalOptions globalOptions, IConfiguration section) + { + var clientOptions = (ClientOptions) ClientFactory.CreateClientOptions(null, _clientOptionType); + foreach (var globalConfigureOptions in globalOptions.ConfigureOptionDelegates) + { + globalConfigureOptions(clientOptions, _serviceProvider); + } + + section.Bind(clientOptions); + + return clientOptions; + } + + private class FallbackClientRegistration: ClientRegistration + { + public IConfiguration Configuration { get; } + + public FallbackClientRegistration(string name, Func factory, IConfiguration configuration) : base(name, factory) + { + Configuration = configuration; + } + } + } +} \ No newline at end of file diff --git a/sdk/core/Microsoft.Extensions.Azure/tests/AzureClientFactoryTests.cs b/sdk/core/Microsoft.Extensions.Azure/tests/AzureClientFactoryTests.cs index e0e8cea60f63..d781617af450 100644 --- a/sdk/core/Microsoft.Extensions.Azure/tests/AzureClientFactoryTests.cs +++ b/sdk/core/Microsoft.Extensions.Azure/tests/AzureClientFactoryTests.cs @@ -309,6 +309,47 @@ public void UsesCredentialFromConfiguration() Assert.AreEqual("ConfigurationTenantId", clientSecretCredential.TenantId); } + [Test] + public void CanCreateClientWithoutRegistration() + { + var configuration = GetConfiguration( + new KeyValuePair("TestClient:uri", "http://localhost/"), + new KeyValuePair("TestClient:clientId", "ConfigurationClientId"), + new KeyValuePair("TestClient:clientSecret", "ConfigurationClientSecret"), + new KeyValuePair("TestClient:tenantId", "ConfigurationTenantId")); + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddAzureClients(builder => builder.UseConfiguration(_ => configuration)); + + ServiceProvider provider = serviceCollection.BuildServiceProvider(); + IAzureClientFactory factory = provider.GetService>(); + TestClientWithCredentials client = factory.CreateClient("TestClient"); + + Assert.IsInstanceOf(client.Credential); + var clientSecretCredential = (ClientSecretCredential)client.Credential; + + Assert.AreEqual("http://localhost/", client.Uri.ToString()); + Assert.AreEqual("ConfigurationClientId", clientSecretCredential.ClientId); + Assert.AreEqual("ConfigurationClientSecret", clientSecretCredential.ClientSecret); + Assert.AreEqual("ConfigurationTenantId", clientSecretCredential.TenantId); + } + + [Test] + public void CanCreateClientWithoutRegistrationUsingConnectionString() + { + var configuration = GetConfiguration( + new KeyValuePair("TestClient", "http://localhost/")); + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddAzureClients(builder => builder.UseConfiguration(_ => configuration)); + + ServiceProvider provider = serviceCollection.BuildServiceProvider(); + IAzureClientFactory factory = provider.GetService>(); + TestClient client = factory.CreateClient("TestClient"); + + Assert.AreEqual("http://localhost/", client.ConnectionString); + } + [Test] public void SupportsSettingVersion() { diff --git a/sdk/core/Microsoft.Extensions.Azure/tests/LogForwarderTests.cs b/sdk/core/Microsoft.Extensions.Azure/tests/LogForwarderTests.cs index 5824a5e4bfe2..534d270a35bb 100644 --- a/sdk/core/Microsoft.Extensions.Azure/tests/LogForwarderTests.cs +++ b/sdk/core/Microsoft.Extensions.Azure/tests/LogForwarderTests.cs @@ -23,6 +23,7 @@ public void MapsLevelsCorrectly(EventLevel eventLevel, LogLevel logLevel) var loggerFactory = new MockLoggerFactory(); using (var forwarder = new EventSourceLogForwarder(loggerFactory)) { + forwarder.Start(); typeof(TestSource).GetMethod(eventLevel.ToString(), BindingFlags.Instance | BindingFlags.Public).Invoke(TestSource.Log, Array.Empty()); } @@ -35,6 +36,7 @@ public void MapsLevelsCorrectly(EventLevel eventLevel, LogLevel logLevel) public void WorksWithNullLoggerFactory() { using var forwarder = new EventSourceLogForwarder( null); + forwarder.Start(); TestSource.Log.Informational(); } diff --git a/sdk/extensions/Azure.Extensions.sln b/sdk/extensions/Azure.Extensions.sln index 1f8b4d7724e5..c83cf36d675b 100644 --- a/sdk/extensions/Azure.Extensions.sln +++ b/sdk/extensions/Azure.Extensions.sln @@ -1,27 +1,37 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30327.8 MinimumVisualStudioVersion = 15.0.26124.0 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Extensions.AspNetCore.DataProtection.Blobs", "Azure.Extensions.AspNetCore.DataProtection.Blobs", "{12C39FF8-57E3-4A24-AE05-42A104EC9C69}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Extensions.AspNetCore.DataProtection.Blobs", "Azure.Extensions.AspNetCore.DataProtection.Blobs\src\Azure.Extensions.AspNetCore.DataProtection.Blobs.csproj", "{70DAB56B-C642-480D-9F47-D490959A3431}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Extensions.AspNetCore.DataProtection.Blobs", "Azure.Extensions.AspNetCore.DataProtection.Blobs\src\Azure.Extensions.AspNetCore.DataProtection.Blobs.csproj", "{70DAB56B-C642-480D-9F47-D490959A3431}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Extensions.AspNetCore.DataProtection.Blobs.Tests", "Azure.Extensions.AspNetCore.DataProtection.Blobs\tests\Azure.Extensions.AspNetCore.DataProtection.Blobs.Tests.csproj", "{CE0F89E2-9894-43DE-A9F6-90C5D186DD84}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Extensions.AspNetCore.DataProtection.Blobs.Tests", "Azure.Extensions.AspNetCore.DataProtection.Blobs\tests\Azure.Extensions.AspNetCore.DataProtection.Blobs.Tests.csproj", "{CE0F89E2-9894-43DE-A9F6-90C5D186DD84}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Extensions.AspNetCore.DataProtection.Keys", "Azure.Extensions.AspNetCore.DataProtection.Keys", "{B358FBE9-0833-484D-B988-588B75AE99CF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Extensions.AspNetCore.DataProtection.Keys", "Azure.Extensions.AspNetCore.DataProtection.Keys\src\Azure.Extensions.AspNetCore.DataProtection.Keys.csproj", "{4324C3A5-67DA-4130-B506-69BDB282DB42}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Extensions.AspNetCore.DataProtection.Keys", "Azure.Extensions.AspNetCore.DataProtection.Keys\src\Azure.Extensions.AspNetCore.DataProtection.Keys.csproj", "{4324C3A5-67DA-4130-B506-69BDB282DB42}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Extensions.AspNetCore.DataProtection.Keys.Tests", "Azure.Extensions.AspNetCore.DataProtection.Keys\tests\Azure.Extensions.AspNetCore.DataProtection.Keys.Tests.csproj", "{0BE93DC0-97FC-48D6-BA9F-482BCF974D83}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Extensions.AspNetCore.DataProtection.Keys.Tests", "Azure.Extensions.AspNetCore.DataProtection.Keys\tests\Azure.Extensions.AspNetCore.DataProtection.Keys.Tests.csproj", "{0BE93DC0-97FC-48D6-BA9F-482BCF974D83}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Extensions.AspNetCore.Configuration.Secrets", "Azure.Extensions.AspNetCore.Configuration.Secrets", "{A6B68F68-8FDF-4D1F-9277-BABEE6904DCA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Extensions.AspNetCore.Configuration.Secrets", "Azure.Extensions.AspNetCore.Configuration.Secrets\src\Azure.Extensions.AspNetCore.Configuration.Secrets.csproj", "{8EFE342E-7C53-4489-B14B-EB813CE015B6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Extensions.AspNetCore.Configuration.Secrets", "Azure.Extensions.AspNetCore.Configuration.Secrets\src\Azure.Extensions.AspNetCore.Configuration.Secrets.csproj", "{8EFE342E-7C53-4489-B14B-EB813CE015B6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Extensions.AspNetCore.Configuration.Secrets.Tests", "Azure.Extensions.AspNetCore.Configuration.Secrets\tests\Azure.Extensions.AspNetCore.Configuration.Secrets.Tests.csproj", "{5F56D5D1-670D-4C34-BB1A-E4D46F0CD05E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Extensions.AspNetCore.Configuration.Secrets.Tests", "Azure.Extensions.AspNetCore.Configuration.Secrets\tests\Azure.Extensions.AspNetCore.Configuration.Secrets.Tests.csproj", "{5F56D5D1-670D-4C34-BB1A-E4D46F0CD05E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Core.TestFramework", "..\core\Azure.Core.TestFramework\src\Azure.Core.TestFramework.csproj", "{A1DF6702-2C7C-4A1E-B324-B574BF2BACCD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Core.TestFramework", "..\core\Azure.Core.TestFramework\src\Azure.Core.TestFramework.csproj", "{A1DF6702-2C7C-4A1E-B324-B574BF2BACCD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Azure", "..\core\Microsoft.Extensions.Azure\src\Microsoft.Extensions.Azure.csproj", "{AF8C46F0-00DF-4E9C-B99C-82DD361115DE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Azure.Tests", "..\core\Microsoft.Extensions.Azure\tests\Microsoft.Extensions.Azure.Tests.csproj", "{439AD388-D698-4A82-B509-D79D511624F5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.WebJobs.Extensions.Clients", "Microsoft.Azure.WebJobs.Extensions.Clients\src\Microsoft.Azure.WebJobs.Extensions.Clients.csproj", "{8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.WebJobs.Extensions.Clients.Tests", "Microsoft.Azure.WebJobs.Extensions.Clients\tests\Microsoft.Azure.WebJobs.Extensions.Clients.Tests.csproj", "{BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.WebJobs.Extensions.Clients.Samples", "Microsoft.Azure.WebJobs.Extensions.Clients\samples\Microsoft.Azure.WebJobs.Extensions.Clients.Samples.csproj", "{45CF248B-6CBA-4BF2-8E8D-44092A525709}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -32,9 +42,6 @@ Global Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {70DAB56B-C642-480D-9F47-D490959A3431}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {70DAB56B-C642-480D-9F47-D490959A3431}.Debug|Any CPU.Build.0 = Debug|Any CPU @@ -120,6 +127,69 @@ Global {A1DF6702-2C7C-4A1E-B324-B574BF2BACCD}.Release|x64.Build.0 = Release|Any CPU {A1DF6702-2C7C-4A1E-B324-B574BF2BACCD}.Release|x86.ActiveCfg = Release|Any CPU {A1DF6702-2C7C-4A1E-B324-B574BF2BACCD}.Release|x86.Build.0 = Release|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Debug|x64.ActiveCfg = Debug|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Debug|x64.Build.0 = Debug|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Debug|x86.ActiveCfg = Debug|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Debug|x86.Build.0 = Debug|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Release|Any CPU.Build.0 = Release|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Release|x64.ActiveCfg = Release|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Release|x64.Build.0 = Release|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Release|x86.ActiveCfg = Release|Any CPU + {AF8C46F0-00DF-4E9C-B99C-82DD361115DE}.Release|x86.Build.0 = Release|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Debug|x64.ActiveCfg = Debug|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Debug|x64.Build.0 = Debug|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Debug|x86.Build.0 = Debug|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Release|Any CPU.Build.0 = Release|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Release|x64.ActiveCfg = Release|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Release|x64.Build.0 = Release|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Release|x86.ActiveCfg = Release|Any CPU + {439AD388-D698-4A82-B509-D79D511624F5}.Release|x86.Build.0 = Release|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Debug|x64.ActiveCfg = Debug|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Debug|x64.Build.0 = Debug|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Debug|x86.ActiveCfg = Debug|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Debug|x86.Build.0 = Debug|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Release|Any CPU.Build.0 = Release|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Release|x64.ActiveCfg = Release|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Release|x64.Build.0 = Release|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Release|x86.ActiveCfg = Release|Any CPU + {8AF0DAC5-3F8A-49A7-AA9E-A05D589DD279}.Release|x86.Build.0 = Release|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Debug|x64.ActiveCfg = Debug|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Debug|x64.Build.0 = Debug|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Debug|x86.ActiveCfg = Debug|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Debug|x86.Build.0 = Debug|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Release|Any CPU.Build.0 = Release|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Release|x64.ActiveCfg = Release|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Release|x64.Build.0 = Release|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Release|x86.ActiveCfg = Release|Any CPU + {BFCD7A9E-27A9-448F-B98A-4C1A8808CFFB}.Release|x86.Build.0 = Release|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Debug|Any CPU.Build.0 = Debug|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Debug|x64.ActiveCfg = Debug|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Debug|x64.Build.0 = Debug|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Debug|x86.ActiveCfg = Debug|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Debug|x86.Build.0 = Debug|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Release|Any CPU.ActiveCfg = Release|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Release|Any CPU.Build.0 = Release|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Release|x64.ActiveCfg = Release|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Release|x64.Build.0 = Release|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Release|x86.ActiveCfg = Release|Any CPU + {45CF248B-6CBA-4BF2-8E8D-44092A525709}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {70DAB56B-C642-480D-9F47-D490959A3431} = {12C39FF8-57E3-4A24-AE05-42A104EC9C69} @@ -129,4 +199,7 @@ Global {8EFE342E-7C53-4489-B14B-EB813CE015B6} = {A6B68F68-8FDF-4D1F-9277-BABEE6904DCA} {5F56D5D1-670D-4C34-BB1A-E4D46F0CD05E} = {A6B68F68-8FDF-4D1F-9277-BABEE6904DCA} EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D0A9B115-40CC-478C-BDD7-76E2752E8BF2} + EndGlobalSection EndGlobal diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/CHANGELOG.md b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/CHANGELOG.md new file mode 100644 index 000000000000..4624755472c0 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release History + +## 1.0.0-beta.1 (Unreleased) + +- Initial release of the package. \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/Directory.Build.props b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/Directory.Build.props new file mode 100644 index 000000000000..805ca8beaf23 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/Directory.Build.props @@ -0,0 +1,7 @@ + + + true + + + + diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/README.md b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/README.md new file mode 100644 index 000000000000..8cd65b959357 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/README.md @@ -0,0 +1,69 @@ +# Azure client library integration for WebJobs/Azure.Functions + +Microsoft.Extensions.Azure.Core provides shared primitives to integrate Azure clients with ASP.NET Core [dependency injection][dependency_injection] and [configuration][configuration] systems. + +[Source code][source_root] | [Package (NuGet)][package] + +## Getting started + +### Install the package + +Install the ASP.NET Core integration library using [NuGet][nuget]: + +```powershell +dotnet add package Microsoft.Azure.WebJobs.Extensions.Clients --version 1.0.0-beta.1 +``` + +### Reference the client from a function + +Annotate a function parameter with an `AzureClient` attribute passing a connection name as a parameter. + +```C# Snippet:AzureClientInFunction +public static class Function1 +{ + [FunctionName("Function1")] + public static IActionResult Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req, + [AzureClient("StorageConnection")] BlobServiceClient client) + { + return new OkObjectResult(client.GetBlobContainers().ToArray()); + } +} +``` + +The connection name should correspond to a configuration section with a connection string or a set of connection parameters that correspond to a client constructor. + +For example to construct a BlobClient using a connection string use the following configuration: + +```json +{ + "StorageConnection": "UseDevelopmentStorage=true" +} +``` + +To construct a client using a `blobUri`: + +```json +{ + "StorageConnection": { + "blobUri": "https://{storage_account}.blob.core.windows.net/" + } +} +``` + +You can do the same via [Azure.Function settings in the portal][azure_function_settings] by setting `StorageConnection` or `StorageConnection__blobUri` application settings (*NOTE* configuration format uses [ASP.NET Core environment variable provider][aspnet_core_env_vars] syntax). + +## Contributing +This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct][code_of_conduct]. For more information see the Code of Conduct FAQ or contact opencode@microsoft.com with any additional questions or comments. + + + +[source_root]: https://github.com/Azure/azure-sdk-for-net/tree/master/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/ +[nuget]: https://www.nuget.org/ +[package]: https://www.nuget.org/packages/Microsoft.Extensions.Azure/ +[azure_function_settings]: https://docs.microsoft.com/azure/azure-functions/functions-how-to-use-azure-function-app-settings +[aspnet_core_env_vars]: https://docs.microsoft.com/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1#environment-variables \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/api/Microsoft.Azure.WebJobs.Extensions.Clients.netstandard2.0.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/api/Microsoft.Azure.WebJobs.Extensions.Clients.netstandard2.0.cs new file mode 100644 index 000000000000..2ec632d09abd --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/api/Microsoft.Azure.WebJobs.Extensions.Clients.netstandard2.0.cs @@ -0,0 +1,17 @@ +namespace Microsoft.Azure.WebJobs +{ + [Microsoft.Azure.WebJobs.Description.BindingAttribute] + [System.AttributeUsageAttribute(System.AttributeTargets.Parameter)] + public partial class AzureClientAttribute : System.Attribute, Microsoft.Azure.WebJobs.IConnectionProvider + { + public AzureClientAttribute(string connection) { } + public string Connection { get { throw null; } set { } } + } +} +namespace Microsoft.Extensions.Hosting +{ + public static partial class AzureClientsWebJobsBuilderExtensions + { + public static Microsoft.Azure.WebJobs.IWebJobsBuilder AddAzureClients(this Microsoft.Azure.WebJobs.IWebJobsBuilder builder) { throw null; } + } +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/samples/Function1.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/samples/Function1.cs new file mode 100644 index 000000000000..43948721d370 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/samples/Function1.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.AspNetCore.Http; +using Azure.Storage.Blobs; +using System.Linq; + +namespace Azure.Extensions.WebJobs.Sample +{ + #region Snippet:AzureClientInFunction + public static class Function1 + { + [FunctionName("Function1")] + public static IActionResult Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req, + [AzureClient("StorageConnection")] BlobServiceClient client) + { + return new OkObjectResult(client.GetBlobContainers().ToArray()); + } + } + #endregion +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/samples/Microsoft.Azure.WebJobs.Extensions.Clients.Samples.csproj b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/samples/Microsoft.Azure.WebJobs.Extensions.Clients.Samples.csproj new file mode 100644 index 000000000000..91348a8ae58e --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/samples/Microsoft.Azure.WebJobs.Extensions.Clients.Samples.csproj @@ -0,0 +1,22 @@ + + + netcoreapp3.1 + $(RequiredTargetFrameworks) + v3 + + + + + + + + + + + Always + + + Always + + + \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/samples/host.json b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/samples/host.json new file mode 100644 index 000000000000..ec3f8ddb7c84 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/samples/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Azure": "Information" + }, + "applicationInsights": { + "samplingExcludedTypes": "Request", + "samplingSettings": { + "isEnabled": true + } + } + }, + "StorageConnection": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;" +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientAttribute.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientAttribute.cs new file mode 100644 index 000000000000..3b0244702e56 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientAttribute.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Description; + +namespace Microsoft.Azure.WebJobs +{ + /// + /// + /// + [AttributeUsage(AttributeTargets.Parameter)] + [Binding] + public class AzureClientAttribute: Attribute, IConnectionProvider + { + /// + /// + /// + /// + public AzureClientAttribute(string connection) + { + Connection = connection; + } + + /// + public string Connection { get; set; } + } +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientsExtensionConfigProvider.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientsExtensionConfigProvider.cs new file mode 100644 index 000000000000..eaeaf409a018 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientsExtensionConfigProvider.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Config; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Hosting +{ + internal class AzureClientsExtensionConfigProvider : IExtensionConfigProvider + { + private readonly IServiceProvider _serviceProvider; + + public AzureClientsExtensionConfigProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public void Initialize(ExtensionConfigContext context) + { + context.AddBindingRule() + .BindToValueProvider((attribute, type) => Task.FromResult(CreateValueBinder(type, attribute))); + } + + private IValueBinder CreateValueBinder(Type type, AzureClientAttribute attribute) + { + return (IValueBinder)Activator.CreateInstance(typeof(AzureClientValueProvider<>).MakeGenericType(type), _serviceProvider, attribute.Connection); + } + + private class AzureClientValueProvider : IValueBinder + { + private readonly IServiceProvider _serviceProvider; + private readonly string _connection; + + public AzureClientValueProvider(IServiceProvider serviceProvider, string connection) + { + _serviceProvider = serviceProvider; + _connection = connection; + } + + public Task GetValueAsync() + { + return Task.FromResult( + (object)_serviceProvider + .GetRequiredService>() + .CreateClient(_connection)); + } + + public string ToInvokeString() => $"{typeof(TClient).Name} Connection: {_connection}"; + + public Type Type => typeof(TClient); + + public Task SetValueAsync(object value, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + } +} \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientsWebJobsBuilderExtensions.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientsWebJobsBuilderExtensions.cs new file mode 100644 index 000000000000..b1f116005007 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientsWebJobsBuilderExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Azure.Extensions.WebJobs; +using Microsoft.Azure.WebJobs; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Hosting +{ + /// + /// The extensions for Azure SDK client support. + /// + public static class AzureClientsWebJobsBuilderExtensions + { + /// + /// Adds support for and in WebJobs. + /// + /// + /// + /// + public static IWebJobsBuilder AddAzureClients(this IWebJobsBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddAzureClients(builder => + builder.UseConfiguration(provider => provider.GetRequiredService().GetWebJobsRootConfiguration())); + builder.AddExtension(); + + return builder; + } + } +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientsWebJobsStartup.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientsWebJobsStartup.cs new file mode 100644 index 000000000000..ef8b9e7ca908 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/AzureClientsWebJobsStartup.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Hosting; +using Microsoft.Extensions.Hosting; + +[assembly: WebJobsStartup(typeof(Azure.Extensions.WebJobs.AzureClientsWebJobsStartup))] +namespace Azure.Extensions.WebJobs +{ + internal class AzureClientsWebJobsStartup : Microsoft.Azure.WebJobs.Hosting.IWebJobsStartup + { + public void Configure(IWebJobsBuilder builder) + { + builder.AddAzureClients(); + } + } +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/Microsoft.Azure.WebJobs.Extensions.Clients.csproj b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/Microsoft.Azure.WebJobs.Extensions.Clients.csproj new file mode 100644 index 000000000000..31f0c59d7dad --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/src/Microsoft.Azure.WebJobs.Extensions.Clients.csproj @@ -0,0 +1,17 @@ + + + + $(RequiredTargetFrameworks) + + aspnetcore;dataprotection;azure;blob;key store + 1.0.0-beta.1 + true + $(NoWarn);AZC0001;CA1812 + + + + + + + + diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/AzureClientAttributeTests.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/AzureClientAttributeTests.cs new file mode 100644 index 000000000000..a6f307791bbf --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/AzureClientAttributeTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Core.TestFramework; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Azure.WebJobs.Host.TestCommon; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Azure.WebJobs.Tests; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Azure.WebJobs.Extensions.Clients.Tests +{ + public class AzureClientAttributeTests : RecordedTestBase + { + public AzureClientAttributeTests(bool isAsync) : base(isAsync) + { + Matcher = new RecordMatcher() + { + VolatileQueryParameters = + { + // Ignore KeyVault client API Version when matching + "api-version" + } + }; + } + + [RecordedTest] + public async Task CanInjectKeyVaultClient() + { + var host = new HostBuilder() + .ConfigureServices(services => services.AddAzureClients(builder => builder + .ConfigureDefaults(options => Recording.InstrumentClientOptions(options)) + .UseCredential(TestEnvironment.Credential))) + .ConfigureAppConfiguration(config => + { + config.AddInMemoryCollection(new Dictionary + { + { "AzureWebJobs:Connection:vaultUri", TestEnvironment.KeyVaultUrl } + }); + }) + .ConfigureDefaultTestHost(builder => + { + builder.AddAzureClients(); + }).Build(); + + var jobHost = host.GetJobHost(); + await jobHost.CallAsync(nameof(FunctionWithAzureClient.Run)); + } + + public class FunctionWithAzureClient + { + public async Task Run([AzureClient("Connection")] SecretClient keyClient) + { + await keyClient.SetSecretAsync("TestSecret", "Secret value"); + } + } + } +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/Microsoft.Azure.WebJobs.Extensions.Clients.Tests.csproj b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/Microsoft.Azure.WebJobs.Extensions.Clients.Tests.csproj new file mode 100644 index 000000000000..406a7bf62d70 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/Microsoft.Azure.WebJobs.Extensions.Clients.Tests.csproj @@ -0,0 +1,23 @@ + + + + $(RequiredTargetFrameworks) + true + + + + + + + + + + + + + + + + + + diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/SessionRecords/AzureClientAttributeTests/CanInjectKeyVaultClient.json b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/SessionRecords/AzureClientAttributeTests/CanInjectKeyVaultClient.json new file mode 100644 index 000000000000..1c0b4076cf5d --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/SessionRecords/AzureClientAttributeTests/CanInjectKeyVaultClient.json @@ -0,0 +1,93 @@ +{ + "Entries": [ + { + "RequestUri": "https://pakrymext.vault.azure.net/secrets/TestSecret?api-version=7.0", + "RequestMethod": "PUT", + "RequestHeaders": { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": [ + "azsdk-net-Security.KeyVault.Secrets/4.0.1", + "(.NET Core 4.6.29130.01; Microsoft Windows 10.0.19041 )" + ], + "x-ms-client-request-id": "2a212bde8ee6a733d305c4a33777c0b9", + "x-ms-return-client-request-id": "true" + }, + "RequestBody": null, + "StatusCode": 401, + "ResponseHeaders": { + "Cache-Control": "no-cache", + "Content-Length": "87", + "Content-Type": "application/json; charset=utf-8", + "Date": "Wed, 26 Aug 2020 19:22:40 GMT", + "Expires": "-1", + "Pragma": "no-cache", + "Strict-Transport-Security": "max-age=31536000;includeSubDomains", + "WWW-Authenticate": "Bearer authorization=\u0022https://login.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47\u0022, resource=\u0022https://vault.azure.net\u0022", + "X-AspNet-Version": "4.0.30319", + "X-Content-Type-Options": "nosniff", + "x-ms-keyvault-network-info": "conn_type=Ipv4;addr=167.220.2.219;act_addr_fam=InterNetwork;", + "x-ms-keyvault-region": "westus2", + "x-ms-keyvault-service-version": "1.1.44.0", + "x-ms-request-id": "5d13ca79-fd6b-4c23-a496-e2fdd3c85bc8", + "X-Powered-By": "ASP.NET" + }, + "ResponseBody": { + "error": { + "code": "Unauthorized", + "message": "Request is missing a Bearer or PoP token." + } + } + }, + { + "RequestUri": "https://pakrymext.vault.azure.net/secrets/TestSecret?api-version=7.0", + "RequestMethod": "PUT", + "RequestHeaders": { + "Accept": "application/json", + "Authorization": "Sanitized", + "Content-Length": "24", + "Content-Type": "application/json", + "User-Agent": [ + "azsdk-net-Security.KeyVault.Secrets/4.0.1", + "(.NET Core 4.6.29130.01; Microsoft Windows 10.0.19041 )" + ], + "x-ms-client-request-id": "2a212bde8ee6a733d305c4a33777c0b9", + "x-ms-return-client-request-id": "true" + }, + "RequestBody": { + "value": "Secret value" + }, + "StatusCode": 200, + "ResponseHeaders": { + "Cache-Control": "no-cache", + "Content-Length": "229", + "Content-Type": "application/json; charset=utf-8", + "Date": "Wed, 26 Aug 2020 19:22:42 GMT", + "Expires": "-1", + "Pragma": "no-cache", + "Strict-Transport-Security": "max-age=31536000;includeSubDomains", + "X-AspNet-Version": "4.0.30319", + "X-Content-Type-Options": "nosniff", + "x-ms-keyvault-network-info": "conn_type=Ipv4;addr=167.220.2.219;act_addr_fam=InterNetwork;", + "x-ms-keyvault-region": "westus2", + "x-ms-keyvault-service-version": "1.1.44.0", + "x-ms-request-id": "8f079617-6d14-4a4a-b53f-494698718ebf", + "X-Powered-By": "ASP.NET" + }, + "ResponseBody": { + "value": "Secret value", + "id": "https://pakrymext.vault.azure.net/secrets/TestSecret/532a5c91516443a5ae7eb9407f993879", + "attributes": { + "enabled": true, + "created": 1598469762, + "updated": 1598469762, + "recoveryLevel": "Recoverable\u002BPurgeable" + } + } + } + ], + "Variables": { + "AZURE_KEYVAULT_URL": "https://pakrymext.vault.azure.net", + "RandomSeed": "548087225" + } +} \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/SessionRecords/AzureClientAttributeTests/CanInjectKeyVaultClientAsync.json b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/SessionRecords/AzureClientAttributeTests/CanInjectKeyVaultClientAsync.json new file mode 100644 index 000000000000..a0f32df85b9e --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/SessionRecords/AzureClientAttributeTests/CanInjectKeyVaultClientAsync.json @@ -0,0 +1,54 @@ +{ + "Entries": [ + { + "RequestUri": "https://pakrymext.vault.azure.net/secrets/TestSecret?api-version=7.0", + "RequestMethod": "PUT", + "RequestHeaders": { + "Accept": "application/json", + "Authorization": "Sanitized", + "Content-Length": "24", + "Content-Type": "application/json", + "User-Agent": [ + "azsdk-net-Security.KeyVault.Secrets/4.0.1", + "(.NET Core 4.6.29130.01; Microsoft Windows 10.0.19041 )" + ], + "x-ms-client-request-id": "49b692b5ecad1611e064d51bbe308eff", + "x-ms-return-client-request-id": "true" + }, + "RequestBody": { + "value": "Secret value" + }, + "StatusCode": 200, + "ResponseHeaders": { + "Cache-Control": "no-cache", + "Content-Length": "229", + "Content-Type": "application/json; charset=utf-8", + "Date": "Wed, 26 Aug 2020 19:22:42 GMT", + "Expires": "-1", + "Pragma": "no-cache", + "Strict-Transport-Security": "max-age=31536000;includeSubDomains", + "X-AspNet-Version": "4.0.30319", + "X-Content-Type-Options": "nosniff", + "x-ms-keyvault-network-info": "conn_type=Ipv4;addr=167.220.2.219;act_addr_fam=InterNetwork;", + "x-ms-keyvault-region": "westus2", + "x-ms-keyvault-service-version": "1.1.44.0", + "x-ms-request-id": "235c697d-46cf-4a36-90ae-144e849b3238", + "X-Powered-By": "ASP.NET" + }, + "ResponseBody": { + "value": "Secret value", + "id": "https://pakrymext.vault.azure.net/secrets/TestSecret/4cc02b5d5a31462c804ec5ccf94f782b", + "attributes": { + "enabled": true, + "created": 1598469763, + "updated": 1598469763, + "recoveryLevel": "Recoverable\u002BPurgeable" + } + } + } + ], + "Variables": { + "AZURE_KEYVAULT_URL": "https://pakrymext.vault.azure.net", + "RandomSeed": "2137402530" + } +} \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/WebJobsTestEnvironment.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/WebJobsTestEnvironment.cs new file mode 100644 index 000000000000..406970449d38 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/WebJobsTestEnvironment.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core.TestFramework; + +namespace Microsoft.Extensions.Azure.WebJobs.Tests +{ + public class WebJobsTestEnvironment : TestEnvironment + { + public WebJobsTestEnvironment() : base("extensions") + { + } + + public string KeyVaultUrl => GetRecordedVariable("AZURE_KEYVAULT_URL"); + } +} \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/FakeActivator.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/FakeActivator.cs new file mode 100644 index 000000000000..80b7caeebf93 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/FakeActivator.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Azure.WebJobs.Host.TestCommon +{ + + public class FakeActivator : IJobActivator + { + public Dictionary _instances = new Dictionary(); + + public FakeActivator(params object[] objs) + { + foreach (var obj in objs) + { + Add(obj); + } + } + + public void Add(object o) + { + _instances[o.GetType()] = o; + } + + public T CreateInstance() + { + return (T)_instances[typeof(T)]; + } + } +} \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/FakeNameResolver.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/FakeNameResolver.cs new file mode 100644 index 000000000000..31982e9f498f --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/FakeNameResolver.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Azure.WebJobs.Host.TestCommon +{ + public class FakeNameResolver : INameResolver + { + public IDictionary _dict = new Dictionary(); + + public string Resolve(string name) + { + // some name resolvers can't handle null values + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(name)); + } + string value; + if (_dict.TryGetValue(name, out value)) + { + return value; + } + + return null; + } + + // Fluid method for adding entries. + public FakeNameResolver Add(string key, string value) + { + _dict[key] = value; + return this; + } + } +} \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/FakeTypeLocator.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/FakeTypeLocator.cs new file mode 100644 index 000000000000..608aae592a6e --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/FakeTypeLocator.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Azure.WebJobs.Host.TestCommon +{ + public class FakeTypeLocator : ITypeLocator + { + private readonly Type[] _types; + + public FakeTypeLocator(params Type[] types) + { + _types = types; + } + + public IReadOnlyList GetTypes() + { + return _types; + } + } +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/JobHost.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/JobHost.cs new file mode 100644 index 000000000000..313f1c596b6e --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/JobHost.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Executors; +using Microsoft.Azure.WebJobs.Host.Indexers; +using Microsoft.Extensions.Options; +using NUnit; +using NUnit.Framework; + +namespace Microsoft.Azure.WebJobs.Host.TestCommon +{ + public class JobHost : JobHost + { + private readonly IJobActivator _jobActivator; + + public JobHost( + IOptions options, + IJobHostContextFactory contextFactory, + IJobActivator jobActivator) + : base(options, contextFactory) + { + _jobActivator = jobActivator; + } + + public async Task CallAsync(string methodName, object arguments = null) + { + await base.CallAsync(methodName, arguments); + } + + public async Task CallAsync(string methodName, IDictionary arguments) + { + await base.CallAsync(typeof(TProgram).GetMethod(methodName), arguments); + } + + // Start listeners and run until the Task source is set. + public async Task RunTriggerAsync(TaskCompletionSource taskSource= null) + { + // Program was registered with the job activator, so we can get it + TProgram prog = _jobActivator.CreateInstance(); + if (taskSource == null) + { + var progResult = prog as IProgramWithResult; + taskSource = new TaskCompletionSource(); + progResult.TaskSource = taskSource; + } + + TResult result = default(TResult); + // Act + using (this) + { + await this.StartAsync(); + // Assert + result = await TestHelpers.AwaitWithTimeout(taskSource); + } + return result; + } + + // Helper for quickly testing indexing errors + public async Task AssertIndexingError(string methodName, string expectedErrorMessage) + { + try + { + // Indexing is lazy, so must actually try a call first. + await this.CallAsync(methodName); + } + catch (FunctionIndexingException e) + { + string functionName = typeof(TProgram).Name + "." + methodName; + Assert.AreEqual("Error indexing method '" + functionName + "'", e.Message); + Assert.True(e.InnerException.Message.Contains(expectedErrorMessage)); + return; + } + Assert.True(false, "Invoker should have failed"); + } + } + + // $$$ Meanth to simplify some tests - is this worth it? + public interface IProgramWithResult + { + TaskCompletionSource TaskSource { get; set; } + } +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/LogMessage.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/LogMessage.cs new file mode 100644 index 000000000000..8471e9540e17 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/LogMessage.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Host.TestCommon +{ + public class LogMessage + { + public LogLevel Level { get; set; } + + public EventId EventId { get; set; } + + public IEnumerable> State { get; set; } + + public Exception Exception { get; set; } + + public string FormattedMessage { get; set; } + + public string Category { get; set; } + + public DateTime Timestamp { get; set; } + + public override string ToString() => $"[{Timestamp.ToString("HH:mm:ss.fff")}] [{Category}] {FormattedMessage} {Exception}"; + + /// + /// Returns the value for the key in State. Will throw an exception if there is not + /// exactly one instance of this key in the dictionary. + /// + /// The type to cast the value to. + /// The key to look up. + /// The value. + public T GetStateValue(string key) + { + return (T)State.Single(p => p.Key == key).Value; + } + } +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestExceptionHandlerFactory.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestExceptionHandlerFactory.cs new file mode 100644 index 000000000000..70df9a5a258f --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestExceptionHandlerFactory.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Timers; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; + +namespace Microsoft.Azure.WebJobs.Host.TestCommon +{ + public class TestExceptionHandlerFactory : IWebJobsExceptionHandlerFactory + { + private TestExceptionHandler _handler = new TestExceptionHandler(); + + public IWebJobsExceptionHandler Create(IHost jobHost) => _handler; + + public class TestExceptionHandler : IWebJobsExceptionHandler + { + public void Initialize(JobHost host) + { + } + + public Task OnTimeoutExceptionAsync(ExceptionDispatchInfo exceptionInfo, TimeSpan timeoutGracePeriod) + { + Assert.True(false, $"Timeout exception in test exception handler: {exceptionInfo.SourceException}"); + return Task.CompletedTask; + } + + public Task OnUnhandledExceptionAsync(ExceptionDispatchInfo exceptionInfo) + { + Assert.True(false, $"Error in test exception handler: {exceptionInfo.SourceException}"); + return Task.CompletedTask; + } + } + } +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestHelpers.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestHelpers.cs new file mode 100644 index 000000000000..6ff7740b33cf --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestHelpers.cs @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Config; +using Microsoft.Azure.WebJobs.Host.Indexers; +using Microsoft.Azure.WebJobs.Host.Timers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NUnit.Framework; + +namespace Microsoft.Azure.WebJobs.Host.TestCommon +{ + public static class TestHelpers + { + public static IServiceCollection AddSingletonIfNotNull(this IServiceCollection services, T instance) where T : class + { + if (instance != null) + { + services.AddSingleton(instance); + } + + return services; + } + + /// + /// Helper that builds a test host to configure the options type specified. + /// + /// The options type to configure. + /// Delegate used to configure the target extension. + /// Set of test configuration values to apply. + /// + public static TOptions GetConfiguredOptions(Action configure, Dictionary configValues) where TOptions : class, new() + { + IHost host = new HostBuilder() + .ConfigureDefaultTestHost(b => + { + configure(b); + }) + .ConfigureAppConfiguration(cb => + { + cb.AddInMemoryCollection(configValues); + }) + .Build(); + + TOptions options = host.Services.GetRequiredService>().Value; + return options; + } + + // Test error if not reached within a timeout + public static Task AwaitWithTimeout(this TaskCompletionSource taskSource) + { + return taskSource.Task; + } + + // Test error if not reached within a timeout + public static TResult AwaitWithTimeout(this Task taskSource) + { + Await(() => taskSource.IsCompleted).Wait(); + return taskSource.Result; + } + + public static async Task Await(Func> condition, int timeout = 60 * 1000, int pollingInterval = 50, bool throwWhenDebugging = false, Func userMessageCallback = null) + { + DateTime start = DateTime.Now; + while (!await condition()) + { + await Task.Delay(pollingInterval); + + bool shouldThrow = !Debugger.IsAttached || (Debugger.IsAttached && throwWhenDebugging); + if (shouldThrow && (DateTime.Now - start).TotalMilliseconds > timeout) + { + string error = "Condition not reached within timeout."; + if (userMessageCallback != null) + { + error += " " + userMessageCallback(); + } + throw new ApplicationException(error); + } + } + } + + public static async Task Await(Func condition, int timeout = 60 * 1000, int pollingInterval = 50, bool throwWhenDebugging = false, Func userMessageCallback = null) + { + await Await(() => Task.FromResult(condition()), timeout, pollingInterval, throwWhenDebugging, userMessageCallback); + } + + public static void WaitOne(WaitHandle handle, int timeout = 60 * 1000) + { + bool ok = handle.WaitOne(timeout); + if (!ok) + { + // timeout. Event not signaled in time. + throw new ApplicationException("Condition not reached within timeout."); + } + } + + public static void SetField(object target, string fieldName, object value) + { + FieldInfo field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + if (field == null) + { + field = target.GetType().GetField($"<{fieldName}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); + } + field.SetValue(target, value); + } + + public static T New() + { + var constructor = typeof(T).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, new Type[] { }, null); + return (T)constructor.Invoke(null); + } + + + + // Test that we get an indexing error (FunctionIndexingException) + // functionName - the function name that has the indexing error. + // expectedErrorMessage - inner exception's message with details. + // Invoking func() should cause an indexing error. + public static void AssertIndexingError(Action func, string functionName, string expectedErrorMessage) + { + try + { + func(); // expected indexing error + } + catch (FunctionIndexingException e) + { + Assert.AreEqual("Error indexing method '" + functionName + "'", e.Message); + StringAssert.StartsWith(expectedErrorMessage, e.InnerException.Message); + return; + } + Assert.True(false, "Invoker should have failed"); + } + public static IHostBuilder ConfigureDefaultTestHost(this IHostBuilder builder, params Type[] types) + { + return builder.ConfigureDefaultTestHost(b => { }, types); + } + + public static IHostBuilder ConfigureDefaultTestHost(this IHostBuilder builder, Action configureWebJobs, params Type[] types) + { + return builder.ConfigureWebJobs(configureWebJobs) + .ConfigureServices(services => + { + services.AddSingleton(new FakeTypeLocator(types)); + + // Register this to fail a test if a background exception is thrown + services.AddSingleton(); + }) + .ConfigureTestLogger(); + } + + public static IHostBuilder ConfigureDefaultTestHost(this IHostBuilder builder, + TProgram instance, Action configureWebJobs) + { + return builder.ConfigureDefaultTestHost(configureWebJobs, typeof(TProgram)) + .ConfigureServices(services => + { + services.AddSingleton>(); + + services.AddSingleton(new FakeActivator(instance)); + }); + } + + public static IHostBuilder ConfigureDefaultTestHost(this IHostBuilder builder) + { + return builder.ConfigureDefaultTestHost(o => { }, typeof(TProgram)) + .ConfigureServices(services => + { + services.AddSingleton>(); + }); + } + + public static IHostBuilder ConfigureDefaultTestHost(this IHostBuilder builder, Action configureWebJobs, + INameResolver nameResolver = null, IJobActivator activator = null) + { + return builder.ConfigureDefaultTestHost(configureWebJobs, typeof(TProgram)) + .ConfigureServices(services => + { + services.AddSingleton>(); + + if (nameResolver != null) + { + services.AddSingleton(nameResolver); + } + + if (activator != null) + { + services.AddSingleton(activator); + } + }); + } + + public static IHostBuilder ConfigureTestLogger(this IHostBuilder builder) + { + return builder.ConfigureLogging(logging => + { + logging.AddProvider(new TestLoggerProvider()); + }); + } + + + public static IHostBuilder ConfigureTypeLocator(this IHostBuilder builder, params Type[] types) + { + return builder.ConfigureServices(services => + { + services.AddSingleton(new FakeTypeLocator(types)); + }); + } + + public static TestLoggerProvider GetTestLoggerProvider(this IHost host) + { + return host.Services.GetServices().OfType().Single(); + } + + public static TExtension GetExtension(this IHost host) + { + return host.Services.GetServices().OfType().SingleOrDefault(); + } + + public static JobHost GetJobHost(this IHost host) + { + return host.Services.GetService() as JobHost; + } + + public static JobHost GetJobHost(this IHost host) + { + return host.Services.GetService() as JobHost; + } + + public static async Task CallAsync(this JobHost host, string methodName, object arguments) + { + await host.CallAsync(typeof(T).GetMethod(methodName), arguments); + } + + public static async Task CallAsync(this JobHost host, string methodName) + { + await host.CallAsync(typeof(T).GetMethod(methodName)); + } + + public static TOptions GetOptions(this IHost host) where TOptions : class, new() + { + return host.Services.GetService>().Value; + } + +#pragma warning disable CS0618 // Type or member is obsolete + public static IJobHostMetadataProvider CreateMetadataProvider(this IHost host) + { + return host.Services.GetService(); +#pragma warning restore CS0618 // Type or member is obsolete + } + + public static List GetAssemblyReferences(Assembly assembly) + { + var assemblyRefs = assembly.GetReferencedAssemblies(); + var names = (from assemblyRef in assemblyRefs + orderby assemblyRef.Name.ToLowerInvariant() + select assemblyRef.Name).ToList(); + return names; + } + + public static void AssertPublicTypes(IEnumerable expected, Assembly assembly) + { + var publicTypes = (assembly.GetExportedTypes() + .Select(type => type.Name) + .OrderBy(n => n)); + + AssertPublicTypes(expected.ToArray(), publicTypes.ToArray()); + } + + public static void AssertPublicTypes(string[] expected, string[] actual) + { + var newlyIntroducedPublicTypes = actual.Except(expected).ToArray(); + + if (newlyIntroducedPublicTypes.Length > 0) + { + string message = string.Format("Found {0} unexpected public type{1}: \r\n{2}", + newlyIntroducedPublicTypes.Length, + newlyIntroducedPublicTypes.Length == 1 ? "" : "s", + string.Join("\r\n", newlyIntroducedPublicTypes)); + Assert.True(false, message); + } + + var missingPublicTypes = expected.Except(actual).ToArray(); + + if (missingPublicTypes.Length > 0) + { + string message = string.Format("missing {0} public type{1}: \r\n{2}", + missingPublicTypes.Length, + missingPublicTypes.Length == 1 ? "" : "s", + string.Join("\r\n", missingPublicTypes)); + Assert.True(false, message); + } + } + + public static IDictionary CreateInMemoryCollection() + { + return new Dictionary(); + } + + public static IDictionary AddSetting(this IDictionary dict, string name, string value) + { + dict.Add(name, value); + return dict; + } + + public static IConfiguration BuildConfiguration(this IDictionary dict) + { + return new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + } + public class TestProgram + { + } + } + +} diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestLogger.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestLogger.cs new file mode 100644 index 000000000000..1639d92eae19 --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestLogger.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Host.TestCommon +{ + public class TestLogger : ILogger + { + private readonly Action _logAction; + private IList _logMessages = new List(); + + // protect against changes to logMessages while enumerating + private object _syncLock = new object(); + + public TestLogger(string category, Action logAction = null) + { + Category = category; + _logAction = logAction; + } + + public string Category { get; private set; } + + public IDisposable BeginScope(TState state) + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public IList GetLogMessages() + { + lock (_syncLock) + { + return _logMessages.ToList(); + } + } + + public void ClearLogMessages() + { + lock (_syncLock) + { + _logMessages.Clear(); + } + } + + public virtual void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + var logMessage = new LogMessage + { + Level = logLevel, + EventId = eventId, + State = state as IEnumerable>, + Exception = exception, + FormattedMessage = formatter(state, exception), + Category = Category, + Timestamp = DateTime.UtcNow + }; + + lock (_syncLock) + { + _logMessages.Add(logMessage); + } + + _logAction?.Invoke(logMessage); + } + + public override string ToString() + { + return Category; + } + } +} \ No newline at end of file diff --git a/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestLoggerProvider.cs b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestLoggerProvider.cs new file mode 100644 index 000000000000..bfb6d5152b1a --- /dev/null +++ b/sdk/extensions/Microsoft.Azure.WebJobs.Extensions.Clients/tests/shared/TestLoggerProvider.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.WebJobs.Host.TestCommon +{ + public class TestLoggerProvider : ILoggerProvider + { + private readonly LoggerFilterOptions _filter; + private readonly Action _logAction; + private Dictionary _loggerCache { get; } = new Dictionary(); + + public TestLoggerProvider(Action logAction = null) + { + _filter = new LoggerFilterOptions(); + _logAction = logAction; + } + + public IList CreatedLoggers => _loggerCache.Values.ToList(); + + public ILogger CreateLogger(string categoryName) + { + if (!_loggerCache.TryGetValue(categoryName, out TestLogger logger)) + { + logger = new TestLogger(categoryName, _logAction); + _loggerCache.Add(categoryName, logger); + } + + return logger; + } + + public IEnumerable GetAllLogMessages() => CreatedLoggers.SelectMany(l => l.GetLogMessages()).OrderBy(p => p.Timestamp); + + public string GetLogString() => string.Join(Environment.NewLine, GetAllLogMessages()); + + public void ClearAllLogMessages() + { + foreach (TestLogger logger in CreatedLoggers) + { + logger.ClearLogMessages(); + } + } + + public void Dispose() + { + } + } +} diff --git a/sdk/extensions/ci.yml b/sdk/extensions/ci.yml index 49f0f27639bc..eda38fcc605c 100644 --- a/sdk/extensions/ci.yml +++ b/sdk/extensions/ci.yml @@ -33,3 +33,5 @@ extends: safeName: AzureExtensionsAspNetCoreDataProtectionKeys - name: Azure.Extensions.AspNetCore.Configuration.Secrets safeName: AzureExtensionsAspNetCoreConfigurationSecrets + - name: Microsoft.Azure.WebJobs.Extensions.Clients + safeName: MicrosoftAzureWebJobsExtensionsClients