diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/IWebPubSubServiceClientFactory.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/IWebPubSubServiceClientFactory.cs new file mode 100644 index 000000000000..3d156955948d --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/IWebPubSubServiceClientFactory.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Messaging.WebPubSub; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub +{ + internal interface IWebPubSubServiceClientFactory + { + /// + /// Creates a WebPubSubServiceClient with fallback connection and hub resolution. + /// Priority for connection: + /// 1. attributeConnection (can be connection string or config section name) + /// 2. options (identity-based connection prioritized over connection string for security) + /// Priority for hub: attributeHub > options.Hub + /// + /// Connection from the attribute (can be connection string or config section name). + /// Hub from the attribute (highest priority). + /// A configured WebPubSubServiceClient instance. + WebPubSubServiceClient Create(string attributeConnection, string attributeHub); + } +} diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubConfigProvider.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubConfigProvider.cs index 6d2e6a8b34b0..4c1e6c2e39b9 100644 --- a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubConfigProvider.cs +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubConfigProvider.cs @@ -27,18 +27,24 @@ internal class WebPubSubConfigProvider : IExtensionConfigProvider, IAsyncConvert private readonly ILogger _logger; private readonly WebPubSubFunctionsOptions _options; private readonly IWebPubSubTriggerDispatcher _dispatcher; + private readonly IWebPubSubServiceClientFactory _clientFactory; + private readonly IOptionsMonitor _accessOptions; public WebPubSubConfigProvider( IOptions options, INameResolver nameResolver, ILoggerFactory loggerFactory, - IConfiguration configuration) + IConfiguration configuration, + IOptionsMonitor accessOptions, + IWebPubSubServiceClientFactory clientFactory) { _options = options.Value; _logger = loggerFactory.CreateLogger(LogCategories.CreateTriggerCategory("WebPubSub")); _nameResolver = nameResolver; _configuration = configuration; _dispatcher = new WebPubSubTriggerDispatcher(_logger, _options); + _accessOptions = accessOptions; + _clientFactory = clientFactory; } public void Initialize(ExtensionConfigContext context) @@ -48,16 +54,6 @@ public void Initialize(ExtensionConfigContext context) throw new ArgumentNullException(nameof(context)); } - if (string.IsNullOrEmpty(_options.ConnectionString)) - { - _options.ConnectionString = _nameResolver.Resolve(Constants.WebPubSubConnectionStringName); - } - - if (string.IsNullOrEmpty(_options.Hub)) - { - _options.Hub = _nameResolver.Resolve(Constants.HubNameStringName); - } - Exception webhookException = null; try { @@ -107,25 +103,53 @@ public Task ConvertAsync(HttpRequestMessage input, Cancella return _dispatcher.ExecuteAsync(input, cancellationToken); } - private void ValidateWebPubSubConnectionAttributeBinding(WebPubSubConnectionAttribute attribute, Type type) + internal WebPubSubService GetService(WebPubSubAttribute attribute) { - ValidateConnectionString( + var client = _clientFactory.Create( attribute.Connection, - $"{nameof(WebPubSubConnectionAttribute)}.{nameof(WebPubSubConnectionAttribute.Connection)}"); + attribute.Hub); + return new WebPubSubService(client); } private void ValidateWebPubSubAttributeBinding(WebPubSubAttribute attribute, Type type) { - ValidateConnectionString( - attribute.Connection, - $"{nameof(WebPubSubAttribute)}.{nameof(WebPubSubAttribute.Connection)}"); + ValidateWebPubSubConnectionCore(attribute.Connection, attribute.Hub, "WebPubSub"); } - internal WebPubSubService GetService(WebPubSubAttribute attribute) + private void ValidateWebPubSubConnectionAttributeBinding(WebPubSubConnectionAttribute attribute, Type type) + { + ValidateWebPubSubConnectionCore(attribute.Connection, attribute.Hub, "WebPubSubConnection"); + } + + private void ValidateWebPubSubConnectionCore(string attributeConnection, string attributeHub, string extensionType) { - var connectionString = Utilities.FirstOrDefault(attribute.Connection, _options.ConnectionString); - var hubName = Utilities.FirstOrDefault(attribute.Hub, _options.Hub); - return new WebPubSubService(connectionString, hubName); + var webPubSubAccessExists = true; + if (attributeConnection == null) + { + if (_accessOptions.CurrentValue.WebPubSubAccess == null) + { + webPubSubAccessExists = false; + } + } + else + { + if (!WebPubSubServiceAccessUtil.CanCreateFromIConfiguration(_configuration.GetSection(attributeConnection))) + { + webPubSubAccessExists = false; + } + } + if (!webPubSubAccessExists) + { + throw new InvalidOperationException( + $"Connection must be specified through one of the following:" + Environment.NewLine + + $" * Set '{extensionType}.Connection' property to the name of a config section that contains a Web PubSub connection." + Environment.NewLine + + $" * Set a Web PubSub connection under '{Constants.WebPubSubConnectionStringName}'."); + } + + if ((attributeHub ?? _accessOptions.CurrentValue.Hub) is null) + { + throw new InvalidOperationException($"Resolved 'Hub' value is null for extension '{extensionType}''"); + } } private IAsyncCollector CreateCollector(WebPubSubAttribute attribute) @@ -135,21 +159,13 @@ private IAsyncCollector CreateCollector(WebPubSubAttribute attr private WebPubSubConnection GetClientConnection(WebPubSubConnectionAttribute attribute) { - var hub = Utilities.FirstOrDefault(attribute.Hub, _options.Hub); - var service = new WebPubSubService(attribute.Connection, hub); + var client = _clientFactory.Create( + attribute.Connection, + attribute.Hub); + var service = new WebPubSubService(client); return service.GetClientConnection(attribute.UserId, clientProtocol: attribute.ClientProtocol); } - private void ValidateConnectionString(string attributeConnectionString, string attributeConnectionStringName) - { - var connectionString = Utilities.FirstOrDefault(attributeConnectionString, _options.ConnectionString); - - if (string.IsNullOrEmpty(connectionString)) - { - throw new InvalidOperationException($"The Service connection string must be set either via an '{Constants.WebPubSubConnectionStringName}' app setting, via an '{Constants.WebPubSubConnectionStringName}' environment variable, or directly in code via {nameof(WebPubSubFunctionsOptions)}.{nameof(WebPubSubFunctionsOptions.ConnectionString)} or {attributeConnectionStringName}."); - } - } - internal static void RegisterJsonConverter() { JsonConvert.DefaultSettings = () => new JsonSerializerSettings diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubJobsBuilderExtensions.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubJobsBuilderExtensions.cs index 875faa8fde06..c58058d7bd76 100644 --- a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubJobsBuilderExtensions.cs +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubJobsBuilderExtensions.cs @@ -4,7 +4,11 @@ using System; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.WebPubSub; +using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace Microsoft.Extensions.Hosting { @@ -20,6 +24,7 @@ public static class WebPubSubJobsBuilderExtensions /// . public static IWebJobsBuilder AddWebPubSub(this IWebJobsBuilder builder) { + ; if (builder == null) { throw new ArgumentNullException(nameof(builder)); @@ -27,6 +32,13 @@ public static IWebJobsBuilder AddWebPubSub(this IWebJobsBuilder builder) builder.AddExtension() .ConfigureOptions(ApplyConfiguration); + + // Register the options setup to read from default configuration section + builder.Services.AddSingleton, WebPubSubServiceAccessOptionsSetup>(); + + builder.Services.AddAzureClientsCore(); + builder.Services.TryAddSingleton(); + return builder; } diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccess.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccess.cs new file mode 100644 index 000000000000..ccd0edf4f2bc --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccess.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub; + +#nullable enable + +/// +/// Access information to Web PubSub service. +/// +internal class WebPubSubServiceAccess(Uri serviceEndpoint, WebPubSubServiceCredential credential) +{ + public Uri ServiceEndpoint { get; } = serviceEndpoint; + public WebPubSubServiceCredential Credential { get; } = credential; +} \ No newline at end of file diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccessOptions.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccessOptions.cs new file mode 100644 index 000000000000..b93eb433ab1c --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccessOptions.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub; + +internal class WebPubSubServiceAccessOptions +{ + public WebPubSubServiceAccess? WebPubSubAccess { get; set; } + public string? Hub { get; set; } +} diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccessOptionsSetup.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccessOptionsSetup.cs new file mode 100644 index 000000000000..b79dfe860d51 --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccessOptionsSetup.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub +{ + /// + /// Configures by reading from the default configuration section. + /// + internal class WebPubSubServiceAccessOptionsSetup : IConfigureOptions + { + private readonly IConfiguration _configuration; + private readonly AzureComponentFactory _azureComponentFactory; + private readonly INameResolver _nameResolver; + private readonly IOptionsMonitor _publicOptions; + + public WebPubSubServiceAccessOptionsSetup( + IConfiguration configuration, + AzureComponentFactory azureComponentFactory, + INameResolver nameResolver, + IOptionsMonitor publicOptions) + { + _configuration = configuration; + _azureComponentFactory = azureComponentFactory; + _nameResolver = nameResolver; + _publicOptions = publicOptions; + } + + public void Configure(WebPubSubServiceAccessOptions options) + { + var publicOptions = _publicOptions.CurrentValue; + + // WebPubSubFunctionsOptions.ConnectionString can be set via code only. Takes the highest priority. + if (!string.IsNullOrEmpty(publicOptions.ConnectionString)) + { + options.WebPubSubAccess = WebPubSubServiceAccessUtil.CreateFromConnectionString(publicOptions.ConnectionString); + } + else + { + var defaultSection = _configuration.GetSection(Constants.WebPubSubConnectionStringName); + if (WebPubSubServiceAccessUtil.CreateFromIConfiguration(defaultSection, _azureComponentFactory, out var access)) + { + options.WebPubSubAccess = access!; + } + } + + // Only configure Hub from the default config section if not already set + options.Hub = publicOptions.Hub ?? _nameResolver.Resolve(Constants.HubNameStringName); + } + } +} diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccessUtil.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccessUtil.cs new file mode 100644 index 000000000000..024d53407cc2 --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceAccessUtil.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub; + +internal static class WebPubSubServiceAccessUtil +{ + private const string EndpointPropertyName = "Endpoint"; + private const string AccessKeyPropertyName = "AccessKey"; + private const string PortPropertyName = "Port"; + private static readonly char[] KeyValueSeparator = { '=' }; + private static readonly char[] PropertySeparator = { ';' }; + + internal static WebPubSubServiceAccess CreateFromConnectionString(string connectionString) + { + if (string.IsNullOrEmpty(connectionString)) + { + throw new ArgumentNullException(nameof(connectionString)); + } + + var properties = connectionString.Split(PropertySeparator, StringSplitOptions.RemoveEmptyEntries); + + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in properties) + { + var kvp = property.Split(KeyValueSeparator, 2); + if (kvp.Length != 2) + continue; + + var key = kvp[0].Trim(); + if (dict.ContainsKey(key)) + { + throw new ArgumentException($"Duplicate properties found in connection string: {key}."); + } + + dict.Add(key, kvp[1].Trim()); + } + + if (!dict.TryGetValue(EndpointPropertyName, out var endpoint)) + { + throw new ArgumentException($"Required property not found in connection string: {EndpointPropertyName}."); + } + endpoint = endpoint.TrimEnd('/'); + + // AccessKey is optional when connection string is disabled. + dict.TryGetValue(AccessKeyPropertyName, out var accessKey); + + int? port = null; + if (dict.TryGetValue(PortPropertyName, out var rawPort)) + { + if (int.TryParse(rawPort, out var portValue) && portValue > 0 && portValue <= 0xFFFF) + { + port = portValue; + } + else + { + throw new ArgumentException($"Invalid Port value: {rawPort}"); + } + } + + var uriBuilder = new UriBuilder(endpoint); + if (port.HasValue) + { + uriBuilder.Port = port.Value; + } + + return new WebPubSubServiceAccess(uriBuilder.Uri, new KeyCredential(accessKey)); + } + + internal static bool CreateFromIConfiguration(IConfigurationSection section, AzureComponentFactory azureComponentFactory, out WebPubSubServiceAccess? result) + { + if (!string.IsNullOrEmpty(section.Value)) + { + result = CreateFromConnectionString(section.Value); + return true; + } + else + { + // Check if this is an identity-based connection (has serviceUri) + var serviceUri = section[Constants.ServiceUriKey]; + if (!string.IsNullOrEmpty(serviceUri)) + { + var endpoint = new Uri(serviceUri); + var tokenCrential = azureComponentFactory.CreateTokenCredential(section); + result = new WebPubSubServiceAccess(endpoint, new IdentityCredential(tokenCrential)); + return true; + } + } + result = null; + return false; + } + + internal static bool CanCreateFromIConfiguration(IConfigurationSection section) + { + if (!string.IsNullOrEmpty(section.Value)) + { + // Assume connection string exists. + return true; + } + else + { + // Check if this is an identity-based connection (has serviceUri) + var serviceUri = section[Constants.ServiceUriKey]; + if (!string.IsNullOrEmpty(serviceUri)) + { + // Identity-based connection + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceClientFactory.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceClientFactory.cs new file mode 100644 index 000000000000..51542c0164c9 --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceClientFactory.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using Azure; +using Azure.Messaging.WebPubSub; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub; + +internal class WebPubSubServiceClientFactory( + IConfiguration configuration, + AzureComponentFactory azureComponentFactory, + IOptions options) : IWebPubSubServiceClientFactory +{ + private readonly WebPubSubServiceAccessOptions _options = options.Value; + + /// + /// Creates a WebPubSubServiceClient with fallback connection and hub resolution. + /// Priority for connection: + /// 1. attributeConnection (can be connection string or config section name) + /// 2. options (identity-based connection prioritized over connection string) + /// Priority for hub: attributeHub > options.Hub. + /// + /// Connection from the attribute (can be connection string or config section name). + /// Hub from the attribute (highest priority). + /// A configured WebPubSubServiceClient instance. + public WebPubSubServiceClient Create(string attributeConnection, string attributeHub) + { + // Resolve hub with priority: attribute > options + var hub = attributeHub ?? _options.Hub; + + // Already validated + Debug.Assert(hub is not null); + + WebPubSubServiceAccess? access; + // Determine the connection source and create client accordingly + if (!string.IsNullOrEmpty(attributeConnection)) + { + if (WebPubSubServiceAccessUtil.CreateFromIConfiguration(configuration.GetSection(attributeConnection), azureComponentFactory, out var fromConfig)) + { + access = fromConfig; + } + else + { + // This should not happen because we have validated the attribute. + throw new InvalidOperationException( + $"Valid Web PubSub connection is missing."); + } + } + else if (_options.WebPubSubAccess != null) + { + access = _options.WebPubSubAccess; + } + else + { + // This should not happen because we have validated the attribute. + throw new InvalidOperationException( + $"Valid Web PubSub connection is missing."); + } + return CreateClient(access, hub); + } + + private static WebPubSubServiceClient CreateClient(WebPubSubServiceAccess access, string hub) + { + if (access.Credential is KeyCredential keyCredential) + { + return new WebPubSubServiceClient(access.ServiceEndpoint, hub, new AzureKeyCredential(keyCredential.AccessKey)); + } + if (access.Credential is IdentityCredential identityCredential) + { + return new WebPubSubServiceClient(access.ServiceEndpoint, hub, identityCredential.TokenCredential); + } + throw new InvalidOperationException($"Unsupported credential type {access.Credential.GetType().Name} for WebPubSubServiceClient."); + } +} diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceCredential.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceCredential.cs new file mode 100644 index 000000000000..be9ca482c440 --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Config/WebPubSubServiceCredential.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub; + +/// +/// Can be key-based credential, identity-based connection, or null (A connection string without access key provided to Web PubSub trigger) +/// +internal abstract class WebPubSubServiceCredential +{ + public abstract bool CanValidateSignature { get; } +} + +internal class KeyCredential(string accessKey) : WebPubSubServiceCredential +{ + public override bool CanValidateSignature => !string.IsNullOrEmpty(AccessKey); + public string AccessKey { get; } = accessKey; +} + +internal class IdentityCredential(TokenCredential tokenCredential) : WebPubSubServiceCredential +{ + public override bool CanValidateSignature => false; + + public TokenCredential TokenCredential { get; } = tokenCredential; +} diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Constants.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Constants.cs index ea164ab99822..bf315509f055 100644 --- a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Constants.cs +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Constants.cs @@ -13,6 +13,9 @@ internal static class Constants public const string HubNameStringName = "WebPubSubHub"; public const string WebPubSubValidationStringName = "WebPubSubValidation"; + // Identity-based connection configuration keys + public const string ServiceUriKey = "serviceUri"; + public const string MqttWebSocketSubprotocolValue = "mqtt"; public static class ContentTypes diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Microsoft.Azure.WebJobs.Extensions.WebPubSub.csproj b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Microsoft.Azure.WebJobs.Extensions.WebPubSub.csproj index 60fe1fb05c7b..245a5af33b4c 100644 --- a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Microsoft.Azure.WebJobs.Extensions.WebPubSub.csproj +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/Microsoft.Azure.WebJobs.Extensions.WebPubSub.csproj @@ -1,4 +1,4 @@ - + $(RequiredTargetFrameworks) @@ -6,8 +6,7 @@ Azure, WebPubSub Azure Functions extension for the WebPubSub service 1.10.0-beta.1 - - 1.9.0 + $(NoWarn);CS8632;CA1056;CA2227 true true @@ -25,6 +24,7 @@ + diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/WebPubSubAttribute.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/WebPubSubAttribute.cs index 7162a8791231..2051b125211b 100644 --- a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/WebPubSubAttribute.cs +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/WebPubSubAttribute.cs @@ -15,9 +15,8 @@ namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub public class WebPubSubAttribute : Attribute { /// - /// The connection of target Web PubSub service. + /// The connection name that resolves to the service endpoint URI or connection string. /// - [ConnectionString] public string Connection { get; set; } = Constants.WebPubSubConnectionStringName; /// diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/WebPubSubConnectionAttribute.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/WebPubSubConnectionAttribute.cs index 5dc6b970c12e..078de9b2cb78 100644 --- a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/WebPubSubConnectionAttribute.cs +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/src/WebPubSubConnectionAttribute.cs @@ -15,9 +15,8 @@ namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub public class WebPubSubConnectionAttribute : Attribute { /// - /// Target Web PubSub service connection string. + /// The configuration section name that resolves to the service endpoint URI or connection string. /// - [ConnectionString] public string Connection { get; set; } = Constants.WebPubSubConnectionStringName; /// diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/Common/TestAzureComponentFactory.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/Common/TestAzureComponentFactory.cs new file mode 100644 index 000000000000..28a827eda180 --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/Common/TestAzureComponentFactory.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub.Tests; + +internal static class TestAzureComponentFactory +{ + public static AzureComponentFactory Instance; + + static TestAzureComponentFactory() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddAzureClientsCore(); + Instance = serviceCollection.BuildServiceProvider() + .GetRequiredService(); + } +} diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/JobHostEndToEndTests.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/JobHostEndToEndTests.cs index d972729699a1..3f788b6b5b23 100644 --- a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/JobHostEndToEndTests.cs +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/JobHostEndToEndTests.cs @@ -17,62 +17,104 @@ public class JobHostEndToEndTests { private static readonly WebPubSubConnectionContext TestContext = CreateConnectionContext(); private static readonly BinaryData TestMessage = BinaryData.FromString("JobHostEndToEndTests"); - private static readonly Dictionary FuncConfiguration = new() + private static readonly Dictionary FuncConfiguration_WithGlobalConnectionString = new() { { Constants.WebPubSubConnectionStringName, "Endpoint=https://abc;AccessKey=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH;Version=1.0;" } }; + private static readonly Dictionary FuncConfiguration_WithGlobalIdentity = new() + { + { Constants.WebPubSubConnectionStringName+":serviceUri", "https://abc" } + }; + private static readonly Dictionary FuncConfiguration_WithLocalConnectionString = new() + { + { "LocalConnection", "Endpoint=https://abc;AccessKey=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGH;Version=1.0;" } + }; + private static readonly Dictionary FuncConfiguration_WithLocalIdentity = new() + { + { "LocalConnection:serviceUri", "https://abc" } + }; + + public static readonly IEnumerable FuncConfigurationWithGlobalConnection = + [ + [FuncConfiguration_WithGlobalConnectionString], + [FuncConfiguration_WithGlobalIdentity] + ]; + public static readonly IEnumerable FuncConfigurationWithLocalConnection = + [ + [FuncConfiguration_WithLocalConnectionString], + [FuncConfiguration_WithLocalIdentity] + ]; [TestCase] public async Task TestWebPubSubTrigger() { - var host = TestHelpers.NewHost(typeof(WebPubSubFuncs), configuration: FuncConfiguration); + var host = TestHelpers.NewHost(typeof(WebPubSubFuncs_GlobalConnection), configuration: FuncConfiguration_WithGlobalConnectionString); var args = new Dictionary { { "request", CreateTestTriggerEvent() } }; - await host.GetJobHost().CallAsync("WebPubSubFuncs.TestWebPubSubTrigger", args); + await host.GetJobHost().CallAsync(nameof(WebPubSubFuncs_GlobalConnection) + "." + nameof(WebPubSubFuncs_GlobalConnection.TestWebPubSubTrigger), args); } [TestCase] public void TestWebPubSubTrigger_InvalidBindingObject() { - var host = TestHelpers.NewHost(typeof(WebPubSubFuncs), configuration: FuncConfiguration); + var host = TestHelpers.NewHost(typeof(WebPubSubFuncs_GlobalConnection), configuration: FuncConfiguration_WithGlobalConnectionString); var args = new Dictionary { { "request", CreateTestTriggerEvent() } }; - var task = host.GetJobHost().CallAsync("WebPubSubFuncs.TestWebPubSubTriggerInvalid", args); + var task = host.GetJobHost().CallAsync(nameof(WebPubSubFuncs_GlobalConnection) + "." + nameof(WebPubSubFuncs_GlobalConnection.TestWebPubSubTriggerInvalid), args); var exception = Assert.ThrowsAsync(() => task); - Assert.AreEqual("Exception while executing function: WebPubSubFuncs.TestWebPubSubTriggerInvalid", exception.Message); + Assert.AreEqual($"Exception while executing function: {nameof(WebPubSubFuncs_GlobalConnection)}.{nameof(WebPubSubFuncs_GlobalConnection.TestWebPubSubTriggerInvalid)}", exception.Message); } [TestCase] - public async Task TestWebPubSubInputBinding() + public async Task TestWebPubSubInputBindingWithGlobalConnection() { - var host = TestHelpers.NewHost(typeof(WebPubSubFuncs), configuration: FuncConfiguration); + var host = TestHelpers.NewHost(typeof(WebPubSubFuncs_GlobalConnection), configuration: FuncConfiguration_WithGlobalConnectionString); + // Identity-based connection is not tested because it makes real network connection during the test. - await host.GetJobHost().CallAsync("WebPubSubFuncs.TestWebPubSubInputConnection"); + await host.GetJobHost().CallAsync(nameof(WebPubSubFuncs_GlobalConnection) + "." + nameof(WebPubSubFuncs_GlobalConnection.TestWebPubSubInputConnection)); - await host.GetJobHost().CallAsync("WebPubSubFuncs.TestMqttInputConnection"); + await host.GetJobHost().CallAsync(nameof(WebPubSubFuncs_GlobalConnection) + "." + nameof(WebPubSubFuncs_GlobalConnection.TestMqttInputConnection)); } [TestCase] - public void TestWebPubSubInputBinding_MissingConnectionString() + public void TestWebPubSubInputBinding_MissingConnection() { - var host = TestHelpers.NewHost(typeof(WebPubSubFuncs)); - var task = host.GetJobHost().CallAsync("WebPubSubFuncs.TestWebPubSubInputConnection"); + var host = TestHelpers.NewHost(typeof(WebPubSubFuncs_GlobalConnection)); + var task = host.GetJobHost().CallAsync(nameof(WebPubSubFuncs_GlobalConnection) + "." + nameof(WebPubSubFuncs_GlobalConnection.TestWebPubSubInputConnection)); var exception = Assert.ThrowsAsync(() => task); - Assert.AreEqual($"Error indexing method 'WebPubSubFuncs.TestWebPubSubInputConnection'", exception.Message); + Assert.AreEqual($"Error indexing method '{nameof(WebPubSubFuncs_GlobalConnection)}.{nameof(WebPubSubFuncs_GlobalConnection.TestWebPubSubInputConnection)}'", exception.Message); + } + + [TestCaseSource(nameof(FuncConfigurationWithGlobalConnection))] + public async Task TestWebPubSubOutputWithGlobalConnection(Dictionary config) + { + var host = TestHelpers.NewHost(typeof(WebPubSubFuncs_GlobalConnection), configuration: config); + + await host.GetJobHost().CallAsync(nameof(WebPubSubFuncs_GlobalConnection) + "." + nameof(WebPubSubFuncs_GlobalConnection.TestWebPubSubOutput)); + } + + [TestCaseSource(nameof(FuncConfigurationWithLocalConnection))] + public async Task TestWebPubSubOutputWithLocalConnection(Dictionary config) + { + var host = TestHelpers.NewHost(typeof(WebPubSubFuncs_LocalConnection), configuration: config); + + await host.GetJobHost().CallAsync(nameof(WebPubSubFuncs_LocalConnection) + "." + nameof(WebPubSubFuncs_LocalConnection.TestWebPubSubOutput)); } [TestCase] - public async Task TestWebPubSubOutput() + public async Task TestWebPubSubMissingHub() { - var host = TestHelpers.NewHost(typeof(WebPubSubFuncs), configuration: FuncConfiguration); + var host = TestHelpers.NewHost(typeof(WebPubSubMissingHubFuncs), configuration: FuncConfiguration_WithGlobalConnectionString); - await host.GetJobHost().CallAsync("WebPubSubFuncs.TestWebPubSubOutput"); + var task = host.GetJobHost().CallAsync(nameof(WebPubSubMissingHubFuncs) + "." + nameof(WebPubSubMissingHubFuncs.TestWebPubSubOutputMissingHub)); + var exception = Assert.ThrowsAsync(() => task); + Assert.AreEqual($"Error indexing method '{nameof(WebPubSubMissingHubFuncs)}.{nameof(WebPubSubMissingHubFuncs.TestWebPubSubOutputMissingHub)}'", exception.Message); } private static WebPubSubTriggerEvent CreateTestTriggerEvent() @@ -91,7 +133,7 @@ private static WebPubSubConnectionContext CreateConnectionContext() return new WebPubSubConnectionContext(WebPubSubEventType.User, "message", "testhub", "000000", "user1"); } - private sealed class WebPubSubFuncs + private sealed class WebPubSubFuncs_GlobalConnection { public static void TestWebPubSubTrigger( [WebPubSubTrigger("chat", WebPubSubEventType.System, "connect")] ConnectEventRequest request, @@ -112,6 +154,7 @@ public static void TestWebPubSubInputConnection( // Valid case use default url for verification. Assert.AreEqual("wss://abc/client/hubs/chat", connection.BaseUri.AbsoluteUri); } + public static void TestMqttInputConnection( [WebPubSubConnection(Hub = "chat", UserId = "aaa", ClientProtocol = WebPubSubClientProtocol.Mqtt)] WebPubSubConnection connection) { @@ -129,8 +172,24 @@ await operation.AddAsync(new SendToAllAction }); } - public static async Task TestWebPubSubOutputMissingHub( - [WebPubSub] IAsyncCollector operation) + public static Task TestResponse( + [HttpTrigger("get", "post")] HttpRequest req) + { + return Task.FromResult("test-response"); + } + } + + private sealed class WebPubSubFuncs_LocalConnection + { + public static void TestWebPubSubInputConnection( + [WebPubSubConnection(Hub = "chat", UserId = "aaa", Connection = "LocalConnection")] WebPubSubConnection connection) + { + // Valid case use default url for verification. + Assert.AreEqual("wss://abc/client/hubs/chat", connection.BaseUri.AbsoluteUri); + } + + public static async Task TestWebPubSubOutput( + [WebPubSub(Hub = "chat", Connection = "LocalConnection")] IAsyncCollector operation) { await operation.AddAsync(new SendToAllAction { @@ -138,11 +197,18 @@ await operation.AddAsync(new SendToAllAction DataType = WebPubSubDataType.Text }); } + } - public static Task TestResponse( - [HttpTrigger("get", "post")] HttpRequest req) + private sealed class WebPubSubMissingHubFuncs + { + public static async Task TestWebPubSubOutputMissingHub( + [WebPubSub] IAsyncCollector operation) { - return Task.FromResult("test-response"); + await operation.AddAsync(new SendToAllAction + { + Data = TestMessage, + DataType = WebPubSubDataType.Text + }); } } } diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/WebPubSubServiceAccessOptionsSetupTests.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/WebPubSubServiceAccessOptionsSetupTests.cs new file mode 100644 index 000000000000..1a792fd81f59 --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/WebPubSubServiceAccessOptionsSetupTests.cs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.Core; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub.Tests +{ + public class WebPubSubServiceAccessOptionsSetupTests + { + private const string TestConnectionString = "Endpoint=https://test.webpubsub.azure.com;AccessKey=test-key;"; + private const string TestServiceUri = "https://test.webpubsub.azure.com"; + private const string TestHub = "TestHub"; + + [Test] + public void Configure_WithConnectionStringInPublicOptions_SetsDefaultWebPubSubAccess() + { + // Arrange + var configuration = CreateConfiguration(); + var publicOptions = CreatePublicOptions(connectionString: TestConnectionString); + var azureComponentFactory = Mock.Of(); + var nameResolver = new Mock().Object; + + var setup = new WebPubSubServiceAccessOptionsSetup( + configuration, + azureComponentFactory, + nameResolver, + publicOptions); + + var options = new WebPubSubServiceAccessOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.IsNotNull(options.WebPubSubAccess); + Assert.AreEqual(new System.Uri(TestServiceUri), options.WebPubSubAccess.ServiceEndpoint); + } + + [Test] + public void Configure_WithConnectionStringInConfiguration_SetsDefaultWebPubSubAccess() + { + // Arrange + var configuration = CreateConfiguration(new Dictionary + { + { Constants.WebPubSubConnectionStringName, TestConnectionString } + }); + var publicOptions = CreatePublicOptions(); + var azureComponentFactory = Mock.Of(); + var nameResolver = new Mock().Object; + + var setup = new WebPubSubServiceAccessOptionsSetup( + configuration, + azureComponentFactory, + nameResolver, + publicOptions); + + var options = new WebPubSubServiceAccessOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.IsNotNull(options.WebPubSubAccess); + Assert.AreEqual(new System.Uri(TestServiceUri), options.WebPubSubAccess.ServiceEndpoint); + } + + [Test] + public void Configure_WithServiceUriInConfiguration_SetsDefaultWebPubSubAccess() + { + // Arrange + var mockCredential = Mock.Of(); + var configuration = CreateConfiguration(new Dictionary + { + { $"{Constants.WebPubSubConnectionStringName}:{Constants.ServiceUriKey}", TestServiceUri } + }); + var publicOptions = CreatePublicOptions(); + var azureComponentFactory = Mock.Of(f => + f.CreateTokenCredential(It.IsAny()) == mockCredential); + var nameResolver = new Mock().Object; + + var setup = new WebPubSubServiceAccessOptionsSetup( + configuration, + azureComponentFactory, + nameResolver, + publicOptions); + + var options = new WebPubSubServiceAccessOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.IsNotNull(options.WebPubSubAccess); + Assert.AreEqual(new System.Uri(TestServiceUri), options.WebPubSubAccess.ServiceEndpoint); + Assert.IsInstanceOf(options.WebPubSubAccess.Credential); + Assert.AreEqual(mockCredential, ((IdentityCredential)options.WebPubSubAccess.Credential).TokenCredential); + } + + [Test] + public void Configure_ConnectionStringTakesPrecedenceOverConfiguration() + { + // Arrange + var mockCredential = Mock.Of(); + var configuration = CreateConfiguration(new Dictionary + { + { $"{Constants.WebPubSubConnectionStringName}:{Constants.ServiceUriKey}", "https://other.webpubsub.azure.com" } + }); + var publicOptions = CreatePublicOptions(connectionString: TestConnectionString); + var azureComponentFactory = Mock.Of(f => + f.CreateTokenCredential(It.IsAny()) == mockCredential); + var nameResolver = new Mock().Object; + + var setup = new WebPubSubServiceAccessOptionsSetup( + configuration, + azureComponentFactory, + nameResolver, + publicOptions); + + var options = new WebPubSubServiceAccessOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.IsNotNull(options.WebPubSubAccess); + Assert.AreEqual(new System.Uri(TestServiceUri), options.WebPubSubAccess.ServiceEndpoint); + Assert.IsInstanceOf(options.WebPubSubAccess.Credential); + } + + [Test] + public void Configure_WithNoConnectionInfo_DoesNotSetDefaultWebPubSubAccess() + { + // Arrange + var configuration = CreateConfiguration(); + var publicOptions = CreatePublicOptions(); + var azureComponentFactory = Mock.Of(); + var nameResolver = new Mock().Object; + + var setup = new WebPubSubServiceAccessOptionsSetup( + configuration, + azureComponentFactory, + nameResolver, + publicOptions); + + var options = new WebPubSubServiceAccessOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.IsNull(options.WebPubSubAccess); + } + + [Test] + public void Configure_WithHubInPublicOptions_SetsHub() + { + // Arrange + var configuration = CreateConfiguration(); + var publicOptions = CreatePublicOptions(hub: TestHub); + var azureComponentFactory = Mock.Of(); + var nameResolver = new Mock().Object; + + var setup = new WebPubSubServiceAccessOptionsSetup( + configuration, + azureComponentFactory, + nameResolver, + publicOptions); + + var options = new WebPubSubServiceAccessOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.AreEqual(TestHub, options.Hub); + } + + [Test] + public void Configure_WithHubInNameResolver_SetsHub() + { + // Arrange + var configuration = CreateConfiguration(); + var publicOptions = CreatePublicOptions(); + var azureComponentFactory = Mock.Of(); + var mockNameResolver = new Mock(); + mockNameResolver.Setup(x => x.Resolve(Constants.HubNameStringName)).Returns(TestHub); + + var setup = new WebPubSubServiceAccessOptionsSetup( + configuration, + azureComponentFactory, + mockNameResolver.Object, + publicOptions); + + var options = new WebPubSubServiceAccessOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.AreEqual(TestHub, options.Hub); + } + + [Test] + public void Configure_HubInPublicOptionsTakesPrecedenceOverNameResolver() + { + // Arrange + var configuration = CreateConfiguration(); + var publicOptions = CreatePublicOptions(hub: TestHub); + var azureComponentFactory = Mock.Of(); + var mockNameResolver = new Mock(); + mockNameResolver.Setup(x => x.Resolve(Constants.HubNameStringName)).Returns("OtherHub"); + + var setup = new WebPubSubServiceAccessOptionsSetup( + configuration, + azureComponentFactory, + mockNameResolver.Object, + publicOptions); + + var options = new WebPubSubServiceAccessOptions(); + + // Act + setup.Configure(options); + + // Assert + Assert.AreEqual(TestHub, options.Hub); + } + + private static IConfiguration CreateConfiguration(Dictionary values = null) + { + var builder = new ConfigurationBuilder(); + if (values != null) + { + builder.AddInMemoryCollection(values); + } + return builder.Build(); + } + + private static IOptionsMonitor CreatePublicOptions( + string connectionString = null, + string hub = null) + { + var options = new WebPubSubFunctionsOptions + { + ConnectionString = connectionString, + Hub = hub + }; + + var mockOptionsMonitor = new Mock>(); + mockOptionsMonitor.Setup(x => x.CurrentValue).Returns(options); + return mockOptionsMonitor.Object; + } + } +} diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/WebPubSubServiceAccessUtilTests.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/WebPubSubServiceAccessUtilTests.cs new file mode 100644 index 000000000000..ce86eff82178 --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/WebPubSubServiceAccessUtilTests.cs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Azure.Core; +using Microsoft.Extensions.Configuration; +using Moq; +using NUnit.Framework; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub.Tests +{ + public class WebPubSubServiceAccessUtilTests + { + [Test] + public void CreateFromConnectionString_ThrowsArgumentNullException_WhenConnectionStringIsNull() + { + Assert.Throws(() => + WebPubSubServiceAccessUtil.CreateFromConnectionString(null)); + } + + [Test] + public void CreateFromConnectionString_ThrowsArgumentNullException_WhenConnectionStringIsEmpty() + { + Assert.Throws(() => + WebPubSubServiceAccessUtil.CreateFromConnectionString(string.Empty)); + } + + [Test] + public void CreateFromConnectionString_ThrowsArgumentException_WhenEndpointIsMissing() + { + var connectionString = "AccessKey=testkey"; + + var ex = Assert.Throws(() => + WebPubSubServiceAccessUtil.CreateFromConnectionString(connectionString)); + + Assert.That(ex.Message, Does.Contain("Required property not found in connection string: Endpoint")); + } + + [Test] + public void CreateFromConnectionString_SucceedsWithoutAccessKey() + { + var endpoint = "https://test.webpubsub.azure.com"; + var connectionString = $"Endpoint={endpoint}"; + + var result = WebPubSubServiceAccessUtil.CreateFromConnectionString(connectionString); + + Assert.IsNotNull(result); + Assert.AreEqual(new Uri(endpoint), result.ServiceEndpoint); + Assert.IsNull((result.Credential as KeyCredential).AccessKey); + } + + [Test] + public void CreateFromConnectionString_ParsesValidConnectionString() + { + var endpoint = "https://test.webpubsub.azure.com"; + var accessKey = "test-access-key"; + var connectionString = $"Endpoint={endpoint};AccessKey={accessKey}"; + + var result = WebPubSubServiceAccessUtil.CreateFromConnectionString(connectionString); + + Assert.IsNotNull(result); + Assert.AreEqual(new Uri(endpoint), result.ServiceEndpoint); + Assert.IsNotNull(result.Credential); + Assert.IsInstanceOf(result.Credential); + var keyCredential = (KeyCredential)result.Credential; + Assert.AreEqual(accessKey, keyCredential.AccessKey); + } + + [Test] + public void CreateFromConnectionString_ParsesWithCustomPort() + { + var endpoint = "https://test.webpubsub.azure.com"; + var accessKey = "test-access-key"; + var port = "8080"; + var connectionString = $"Endpoint={endpoint};AccessKey={accessKey};Port={port}"; + + var result = WebPubSubServiceAccessUtil.CreateFromConnectionString(connectionString); + + Assert.IsNotNull(result); + Assert.AreEqual(8080, result.ServiceEndpoint.Port); + Assert.AreEqual("test.webpubsub.azure.com", result.ServiceEndpoint.Host); + } + + [Test] + [TestCase("0")] + [TestCase("-1")] + [TestCase("65536")] + [TestCase("70000")] + [TestCase("abc")] + [TestCase("")] + public void CreateFromConnectionString_ThrowsArgumentException_WhenPortIsInvalid(string invalidPort) + { + var endpoint = "https://test.webpubsub.azure.com"; + var accessKey = "test-access-key"; + var connectionString = $"Endpoint={endpoint};AccessKey={accessKey};Port={invalidPort}"; + + var ex = Assert.Throws(() => + WebPubSubServiceAccessUtil.CreateFromConnectionString(connectionString)); + + Assert.That(ex.Message, Does.Contain($"Invalid Port value: {invalidPort}")); + } + + [Test] + [TestCase("1")] + [TestCase("80")] + [TestCase("443")] + [TestCase("8080")] + [TestCase("65535")] + public void CreateFromConnectionString_AcceptsValidPort(string validPort) + { + var endpoint = "https://test.webpubsub.azure.com"; + var accessKey = "test-access-key"; + var connectionString = $"Endpoint={endpoint};AccessKey={accessKey};Port={validPort}"; + + var result = WebPubSubServiceAccessUtil.CreateFromConnectionString(connectionString); + + Assert.IsNotNull(result); + Assert.AreEqual(int.Parse(validPort), result.ServiceEndpoint.Port); + } + + [Test] + public void CreateFromIConfiguration_ReturnsTrue_WhenConnectionStringValueExists() + { + var endpoint = "https://test.webpubsub.azure.com"; + var accessKey = "test-access-key"; + var connectionString = $"Endpoint={endpoint};AccessKey={accessKey}"; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "TestConnection", connectionString } + }) + .Build(); + + var section = configuration.GetSection("TestConnection"); + + var success = WebPubSubServiceAccessUtil.CreateFromIConfiguration(section, TestAzureComponentFactory.Instance, out var result); + + Assert.IsTrue(success); + Assert.IsNotNull(result); + Assert.AreEqual(new Uri(endpoint), result.ServiceEndpoint); + Assert.IsInstanceOf(result.Credential); + var keyCredential = (KeyCredential)result.Credential; + Assert.AreEqual(accessKey, keyCredential.AccessKey); + } + + [Test] + public void CreateFromIConfiguration_ReturnsTrue_WhenServiceUriExists() + { + var serviceUri = "https://test.webpubsub.azure.com"; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "TestConnection:serviceUri", serviceUri } + }) + .Build(); + + var section = configuration.GetSection("TestConnection"); + + var success = WebPubSubServiceAccessUtil.CreateFromIConfiguration(section, TestAzureComponentFactory.Instance, out var result); + + Assert.IsTrue(success); + Assert.IsNotNull(result); + Assert.AreEqual(new Uri(serviceUri), result.ServiceEndpoint); + Assert.IsInstanceOf(result.Credential); + var identityCredential = (IdentityCredential)result.Credential; + } + + [Test] + public void CreateFromIConfiguration_ReturnsFalse_WhenNoConnectionInfoExists() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "TestConnection:SomeOtherKey", "value" } + }) + .Build(); + + var section = configuration.GetSection("TestConnection"); + + var success = WebPubSubServiceAccessUtil.CreateFromIConfiguration(section, TestAzureComponentFactory.Instance, out var result); + + Assert.IsFalse(success); + Assert.IsNull(result); + } + + [Test] + public void CreateFromIConfiguration_PrefersConnectionStringOverServiceUri() + { + var endpoint = "https://connstring.webpubsub.azure.com"; + var accessKey = "test-access-key"; + var connectionString = $"Endpoint={endpoint};AccessKey={accessKey}"; + var serviceUri = "https://serviceuri.webpubsub.azure.com"; + var mockCredential = new Mock(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "TestConnection", connectionString }, + { "TestConnection:serviceUri", serviceUri } + }) + .Build(); + + var section = configuration.GetSection("TestConnection"); + + var success = WebPubSubServiceAccessUtil.CreateFromIConfiguration(section, TestAzureComponentFactory.Instance, out var result); + + Assert.IsTrue(success); + Assert.IsNotNull(result); + // Connection string value should be used, not serviceUri + Assert.AreEqual(new Uri(endpoint), result.ServiceEndpoint); + Assert.IsInstanceOf(result.Credential); + } + + [Test] + public void CanCreateFromIConfiguration_ReturnsTrue_WhenConnectionStringExists() + { + var connectionString = "Endpoint=https://test.webpubsub.azure.com;AccessKey=testkey"; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "TestConnection", connectionString } + }) + .Build(); + + var section = configuration.GetSection("TestConnection"); + + var result = WebPubSubServiceAccessUtil.CanCreateFromIConfiguration(section); + + Assert.IsTrue(result); + } + + [Test] + public void CanCreateFromIConfiguration_ReturnsTrue_WhenServiceUriExists() + { + var serviceUri = "https://test.webpubsub.azure.com"; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "TestConnection:serviceUri", serviceUri } + }) + .Build(); + + var section = configuration.GetSection("TestConnection"); + + var result = WebPubSubServiceAccessUtil.CanCreateFromIConfiguration(section); + + Assert.IsTrue(result); + } + + [Test] + public void CanCreateFromIConfiguration_ReturnsFalse_WhenNoConnectionInfoExists() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "TestConnection:SomeOtherKey", "value" } + }) + .Build(); + + var section = configuration.GetSection("TestConnection"); + + var result = WebPubSubServiceAccessUtil.CanCreateFromIConfiguration(section); + + Assert.IsFalse(result); + } + } +} diff --git a/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/WebPubSubServiceClientFactoryTests.cs b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/WebPubSubServiceClientFactoryTests.cs new file mode 100644 index 000000000000..f707e6ca31bd --- /dev/null +++ b/sdk/webpubsub/Microsoft.Azure.WebJobs.Extensions.WebPubSub/tests/WebPubSubServiceClientFactoryTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using NUnit.Framework; + +namespace Microsoft.Azure.WebJobs.Extensions.WebPubSub.Tests +{ + public class WebPubSubServiceClientFactoryTests + { + [TestCase("attributeHub", "globalHub", "attributeHub")] + [TestCase(null, "globalHub", "globalHub")] + public void TestHubInCreatedClient(string attributeHub, string globalHub, string expectedHub) + { + var configuration = new ConfigurationBuilder().Build(); + var options = new WebPubSubServiceAccessOptions + { + Hub = globalHub, + WebPubSubAccess = new WebPubSubServiceAccess( + new Uri("https://test.webpubsub.azure.com"), + new KeyCredential("test-key")) + }; + var factory = new WebPubSubServiceClientFactory( + configuration, + TestAzureComponentFactory.Instance, + Options.Create(options)); + var client = factory.Create(null, attributeHub); + Assert.AreEqual(expectedHub, client.Hub); + } + + [TestCase(null, "https://global.webpubsub.azure.com")] + [TestCase("CustomConnection", "https://custom.webpubsub.azure.com")] + public void TestWebPubSubEndpointInCreatedClient(string attributeConnection, string expectedEndpoint) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + [ + new KeyValuePair("CustomConnection:ServiceUri", "https://custom.webpubsub.azure.com"), + ]) + .Build(); + var options = new WebPubSubServiceAccessOptions + { + WebPubSubAccess = new WebPubSubServiceAccess( + new Uri("https://global.webpubsub.azure.com"), + new KeyCredential("global-key")) + }; + var factory = new WebPubSubServiceClientFactory( + configuration, + TestAzureComponentFactory.Instance, + Options.Create(options)); + var client = factory.Create(attributeConnection, "hub"); + Assert.AreEqual(new Uri(expectedEndpoint), client.Endpoint); + } + } +} \ No newline at end of file