diff --git a/examples/dotnet-diagnostics/DiagnosticsExample/App.xaml.cs b/examples/dotnet-diagnostics/DiagnosticsExample/App.xaml.cs index dacfd4562..6d1359d61 100644 --- a/examples/dotnet-diagnostics/DiagnosticsExample/App.xaml.cs +++ b/examples/dotnet-diagnostics/DiagnosticsExample/App.xaml.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using System.Diagnostics; using System.IO; using System.Windows; diff --git a/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml b/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml index d30b967a9..bb44e9860 100644 --- a/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml +++ b/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml @@ -20,8 +20,27 @@ See the License for the specific language governing permissions and limitations Loaded="Window_Loaded" DataContext="{Binding RelativeSource={RelativeSource Self}}"> - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml.cs b/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml.cs index 1168b982e..fd066ed27 100644 --- a/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml.cs +++ b/examples/dotnet-diagnostics/DiagnosticsExample/MainWindow.xaml.cs @@ -18,9 +18,8 @@ using System.Text.Json; using System.Windows; using Finos.Fdc3; -using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol; using Finos.Fdc3.Context; -using System.CodeDom; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; namespace DiagnosticsExample; @@ -42,7 +41,11 @@ public string DiagnosticsText DependencyProperty.Register("DiagnosticsText", typeof(string), typeof(MainWindow), new PropertyMetadata(string.Empty)); private readonly IDesktopAgent _desktopAgent; - private IListener _subscription; + private IChannel _appChannel; + private IPrivateChannel? _privateChannel; + private IListener? _privateChannelContextListener; + private IListener? _subscription; + private IListener _listener; public MainWindow() { @@ -81,80 +84,519 @@ private void Window_Loaded(object sender, RoutedEventArgs e) private async Task LogDiagnostics() { - var diag = await _messaging!.InvokeJsonServiceAsync("Diagnostics", new JsonSerializerOptions { WriteIndented = true }); - - if (diag == null) - { - return; - } - - await Dispatcher.InvokeAsync(() => DiagnosticsText += diag.ToString()); + var textBuilder = new StringBuilder(); - var result = - await _desktopAgent.GetAppMetadata(new MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier() - { AppId = "WPFExample" }); + try + { + var diag = await _messaging!.InvokeJsonServiceAsync("Diagnostics", new JsonSerializerOptions { WriteIndented = true }).ConfigureAwait(false); + if (diag != null) + { + textBuilder.AppendLine(diag.ToString()); + } - await Dispatcher.InvokeAsync(() => DiagnosticsText += "\n" + result.Description); + var result = await _desktopAgent.GetAppMetadata(new AppIdentifier() { AppId = "WPFExample" }).ConfigureAwait(false); + textBuilder.AppendLine(result.Description); + } + catch (Exception ex) + { + textBuilder.AppendLine(ex.Message); + } + finally + { + await Dispatcher.InvokeAsync(() => DiagnosticsText += textBuilder.ToString()); + } } private async void SubscribeButton_Click(object sender, RoutedEventArgs e) { + var textBuilder = new StringBuilder(); + DiagnosticsText = string.Empty; + try { - Dispatcher.Invoke(() => DiagnosticsText += "\n" + "Subscription is in working progress"); + await Task.Run(async () => + { + textBuilder.AppendLine("Subscription is in working progress"); + + await JoinToUserChannel().ConfigureAwait(false); + + _subscription = await _desktopAgent.AddContextListener("fdc3.instrument", + (context, contextMetadata) => + { + Dispatcher.Invoke(() => + DiagnosticsText += "\nContext received: " + context.Name + "; type: " + context.Type); + }).ConfigureAwait(false); + textBuilder.AppendLine("Subscription is done."); + }); + } + catch (Exception ex) + { + textBuilder.AppendLine($"AddContextListener failed: {ex.Message}, {ex}"); + } + finally + { + await Dispatcher.InvokeAsync(() => DiagnosticsText = textBuilder.ToString()); + } + } + + private async void BroadcastButton_Click(object sender, RoutedEventArgs e) + { + var textBuilder = new StringBuilder(); + DiagnosticsText = string.Empty; + + try + { await Task.Run(async () => { - if (await _desktopAgent.GetCurrentChannel() == null) + textBuilder.AppendLine("Broadcasting is in working progress"); + await JoinToUserChannel().ConfigureAwait(false); + + var instrument = new Instrument(new InstrumentID() { BBG = "test" }, $"{Guid.NewGuid()}"); + await _desktopAgent.Broadcast(instrument).ConfigureAwait(false); + + textBuilder.AppendLine("Context broadcasted"); + }); + } + catch (Exception ex) + { + textBuilder.AppendLine($"Broadcast failed: {ex.Message}"); + } + finally + { + await Dispatcher.InvokeAsync(() => DiagnosticsText = textBuilder.ToString()); + } + } + + private async void AppChannelBroadcastButton_Click(object sender, RoutedEventArgs e) + { + await Task.Run(async () => + { + Dispatcher.Invoke(() => + { + DiagnosticsText += "\nChecking if app is already joined to an app channel..."; + }); + + await JoinToAppChannel().ConfigureAwait(false); + + var instrument = new Instrument(new InstrumentID() { BBG = "app-channel-test" }, $"{Guid.NewGuid().ToString()}"); + await _appChannel.Broadcast(instrument).ConfigureAwait(false); + + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nContext broadcasted to AppChannel: instrument: {instrument.ID}; {instrument.Name}"; + }); + }); + } + + private async void AppChannelAddContextListenerButton_Click(object sender, RoutedEventArgs e) + { + await Task.Run(async () => + { + Dispatcher.Invoke(() => + { + DiagnosticsText += "\nChecking if app is already joined to an app channel..."; + }); + + await JoinToAppChannel().ConfigureAwait(false); + + var instrument = new Instrument(new InstrumentID() { BBG = "app-channel-test" }, $"{Guid.NewGuid().ToString()}"); + _listener = await _appChannel.AddContextListener("fdc3.instrument", (context, contextMetadata) => + { + Dispatcher.Invoke(() => + { + DiagnosticsText += "\nContext received from AppChannel: " + context.Name + "; type: " + context.Type; + }); + }).ConfigureAwait(false); + + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nContext listener is added to AppChannel: instrument: {instrument.ID}; {instrument.Name}"; + }); + }); + } + + private async void FindIntentButton_Click(object sender, RoutedEventArgs e) + { + await Task.Run(async () => + { + Dispatcher.Invoke(() => + { + DiagnosticsText += "\nFinding intent for ViewChart..."; + }); + + var result = await _desktopAgent.FindIntent("ViewChart").ConfigureAwait(false); + + Dispatcher.Invoke(() => DiagnosticsText += $"\nFindIntent is completed. Intent name: {result.Intent.Name}"); + + foreach (var app in result.Apps) + { + Dispatcher.Invoke(() => { - await _desktopAgent.JoinUserChannel("fdc3.channel.1"); + DiagnosticsText += $"\nIntent: {result.Intent.Name} is found for app: {app.AppId}"; + }); + } + }); + } + + private async void FindInstancesButton_Click(object sender, RoutedEventArgs e) + { + await Task.Run(async () => + { + var result = await _desktopAgent.GetAppMetadata(new AppIdentifier() { AppId = "WPFExample" }).ConfigureAwait(false); + + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nFinding instances for {result.AppId}..."; + }); + + var instances = await _desktopAgent.FindInstances(result).ConfigureAwait(false); + + foreach (var app in instances) + { + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nInstance found: app: {app.AppId}; FDC3 instanceId: {app.InstanceId}"; + }); + } + }); + } + + private async void FindIntentsByContextButton_Click(object sender, RoutedEventArgs e) + { + await Task.Run(async () => + { + var context = new Instrument(); + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nFinding intents by context: {context.Type}..."; + }); + + var appIntents = await _desktopAgent.FindIntentsByContext(context).ConfigureAwait(false); + + foreach (var appIntent in appIntents) + { + foreach (var app in appIntent.Apps) + { + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nIntent found: {appIntent.Intent.Name} for app: {app.AppId}"; + }); } + } + }); + } + private async void RaiseIntentForContextButton_Click(object sender, RoutedEventArgs e) + { + await Task.Run(async () => + { + var context = new Nothing(); + + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nRaising an intent for context: {context.Type}..."; + }); + + var intentResolution = await _desktopAgent.RaiseIntentForContext(context).ConfigureAwait(false); + + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nRaiseIntentForContext is completed. Intent name: {intentResolution.Intent} for app: {intentResolution.Source.AppId}. Awaiting for IntentResolution..."; + }); + + var intentResult = await intentResolution.GetResult().ConfigureAwait(false); + + if (intentResult != null + && intentResult is IChannel channel) + { + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nIntentResolution is completed. Channel returned: {channel.Id}..."; + }); + + if (channel is IPrivateChannel privateChannel) + { + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\n It is a private channel with id: {privateChannel.Id}..."; + }); + + _privateChannel = privateChannel; + _privateChannel.OnAddContextListener((ctx) => + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nContext listener was added in private channel: {privateChannel.Id} for context: {ctx}..."); + }); - _subscription = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => + _privateChannel.OnDisconnect(() => + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nDisconnected from private channel: {_privateChannel.Id}..."); + }); + + _privateChannel.OnUnsubscribe((ctx) => + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nUnsubscribed from private channel: {_privateChannel.Id} for context: {ctx}..."); + }); + } + } + else if (intentResult != null + && intentResult is IContext returnedContext) + { + Dispatcher.Invoke(() => { - Dispatcher.Invoke(() => DiagnosticsText += "\n" + "Context received: " + context.Name + "; type: " + context.Type); + DiagnosticsText += $"\nIntentResult is completed. Context returned: {returnedContext.Type}..."; }); + } + else + { + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nIntentResult is completed. It was handled by the app ..."; + }); + } + }); + } + + private async void AddIntentListenerButton_Click(object sender, RoutedEventArgs e) + { + try + { + await Task.Run(async () => + { + Dispatcher.Invoke(() => DiagnosticsText += "\nAdding intent listener for OpenDiagnostics..."); + + _listener = await _desktopAgent.AddIntentListener("OpenDiagnostics", async (context, contextMetadata) => + { + Dispatcher.Invoke(() => DiagnosticsText += "\n" + "Intent received: " + context.Name + "; type: " + context.Type); + await CreatePrivateChannelAsync(); + + return _privateChannel; + }).ConfigureAwait(false); }); + } + catch (Exception exception) + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nException was thrown: {exception.ToString()}"); + } + } + + private async Task CreatePrivateChannelAsync() + { + _privateChannel = await _desktopAgent.CreatePrivateChannel().ConfigureAwait(false); + Dispatcher.Invoke(() => DiagnosticsText += $"\nCreated private channel with id: {_privateChannel.Id}..."); + + _privateChannel.OnAddContextListener((ctx) => + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nContextListener added in private channel: {_privateChannel.Id} for context: {ctx}..."); + }); + + _privateChannel.OnDisconnect(() => + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nDisconnected from private channel: {_privateChannel.Id}..."); + }); + + _privateChannel.OnUnsubscribe((ctx) => + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nUnsubscribed from private channel: {_privateChannel.Id} for context: {ctx}..."); + }); + } - DiagnosticsText += "\n" + "Subscription is done."; + private async void RaiseIntentButton_Click(object sender, RoutedEventArgs e) + { + try + { + await Task.Run(async () => + { + Dispatcher.Invoke(() => DiagnosticsText += "\nRaising intent for fdc3.instrument..."); + + var resolution = await _desktopAgent.RaiseIntent("OpenDiagnostics", new Instrument(new InstrumentID() { BBG = "raise-intent-test" }, "Raise Intent Test")).ConfigureAwait(false); + + var result = await resolution.GetResult().ConfigureAwait(false); + + if (result == null) + { + Dispatcher.Invoke(() => DiagnosticsText += "\nIntent was handled by the app, no result returned."); + return; + } + else if (result is IChannel channel) + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nIntentResolution is completed. Channel returned: {channel.Id}..."); + + if (channel is IPrivateChannel privateChannel) + { + Dispatcher.Invoke(() => DiagnosticsText += $"\n It is a private channel with id: {privateChannel.Id}..."); + } + } + else if (result is IContext context) + { + Dispatcher.Invoke(() => DiagnosticsText += $"\n IntentResolution is completed. Context returned: {context.Type}..."); + } + }); } - catch (Exception ex) + catch (Exception exception) + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nRaiseIntent failed: {exception.ToString()}"); + } + } + + private async void OpenButton_Click(object sender, RoutedEventArgs e) + { + try { - await Dispatcher.InvokeAsync(() => + await Task.Run(async () => { - DiagnosticsText += $"\nAddContextListener failed: {ex.Message}, {ex.ToString()}"; + Dispatcher.Invoke(() => DiagnosticsText += "\nOpening app with fdc3.instrument..."); + + var appIdentifier = await _desktopAgent.Open(new AppIdentifier() { AppId = "WPFExample" }, new Instrument(new InstrumentID() { BBG = "open-test" }, "Open Test")).ConfigureAwait(false); + + Dispatcher.Invoke(() => DiagnosticsText += $"\nOpen is completed. AppId: {appIdentifier.AppId}, instanceId: {appIdentifier.InstanceId}..."); }); } + catch (Exception exception) + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nOpen failed: {exception.ToString()}"); + } } - private async void BroadcastButton_Click(object sender, RoutedEventArgs e) + private async void PrivateChannelBroadcastButton_Click(object sender, RoutedEventArgs e) { try { - Dispatcher.Invoke(() => + await Task.Run(async () => { - DiagnosticsText += "\nBroadcasting is in working progress"; + if (_privateChannel == null) + { + Dispatcher.Invoke(() => DiagnosticsText += "\nNo private channel to broadcast to. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!"); + return; + } + + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nBroadcasting to a private channel: {_privateChannel?.Id}"; + }); + + var instrument = new Instrument(new InstrumentID() { BBG = "private-channel-test" }, $"{Guid.NewGuid().ToString()}"); + await _privateChannel.Broadcast(instrument).ConfigureAwait(false); }); + } + catch (Exception exception) + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nPrivate channel broadcast failed: {exception.ToString()}"); + } + } + private async void PrivateChannelAddContextListenerButton_Click(object sender, RoutedEventArgs e) + { + try + { await Task.Run(async () => { - if (await _desktopAgent.GetCurrentChannel() == null) + if (_privateChannel == null) { - await _desktopAgent.JoinUserChannel("fdc3.channel.1"); + Dispatcher.Invoke(() => DiagnosticsText += "\nNo private channel to broadcast to. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!"); + return; } - var instrument = new Instrument(new InstrumentID() { BBG = "test" }, $"{Guid.NewGuid().ToString()}"); - await _desktopAgent.Broadcast(instrument); + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nAdding context Listener to a private channel: {_privateChannel?.Id}"; + }); + + _privateChannelContextListener = await _privateChannel.AddContextListener("fdc3.instrument", (context, contextMetadata) => + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nContext received from private channel for Context: {context}..."); + }).ConfigureAwait(false); }); + } + catch (Exception exception) + { + Dispatcher.Invoke(() => DiagnosticsText += $"\nPrivate channel broadcast failed: {exception.ToString()}"); + } + } + + private async void PrivateChannelDisconnectButton_Click(object sender, RoutedEventArgs e) + { + try + { + await Task.Run(() => + { + if (_privateChannel == null) + { + Dispatcher.Invoke(() => DiagnosticsText += "\nNo private channel to disconnect from. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!"); + return; + } - Dispatcher.Invoke(() => DiagnosticsText += "\nContext broadcasted"); + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nDisconnecting from a private channel: {_privateChannel?.Id}"; + }); + + _privateChannel.Disconnect(); + }); } - catch (Exception ex) + catch (Exception exception) { - await Dispatcher.InvokeAsync(() => + Dispatcher.Invoke(() => $"\nException was thrown: {exception.ToString()}"); + } + } + + private async void PrivateChannelUnsubscribeButton_Click(object sender, RoutedEventArgs e) + { + try + { + await Task.Run(async () => + { + if (_privateChannel == null) + { + Dispatcher.Invoke(() => DiagnosticsText += "\nNo private channel to unsubscribe. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!"); + return; + } + + if (_privateChannelContextListener == null) + { + Dispatcher.Invoke(() => DiagnosticsText += "\nNo context listener is registered on the private channel to unsubscribe. You should RaiseIntentForContext first and the new app should add its context listener to the private channel!"); + return; + } + + Dispatcher.Invoke(() => + { + DiagnosticsText += $"\nUnsubscribing from a private channel: {_privateChannel?.Id} with private channel context listener"; + }); + + _privateChannelContextListener.Unsubscribe(); + }); + } + catch (Exception exception) + { + Dispatcher.Invoke(() => $"\nException was thrown: {exception.ToString()}"); + } + } + + private async Task JoinToAppChannel() + { + if (_appChannel == null) + { + _appChannel = await _desktopAgent.GetOrCreateChannel("app-channel-1").ConfigureAwait(false); + + Dispatcher.Invoke(() => { - DiagnosticsText += $"\nBroadcast failed: {ex.Message}"; + DiagnosticsText += "\nJoined to AppChannel: app-channel-1..."; }); } } + + private async Task JoinToUserChannel() + { + if (await _desktopAgent.GetCurrentChannel() == null) + { + var channels = await _desktopAgent.GetUserChannels(); + var firstChannel = channels.FirstOrDefault(); + if (firstChannel == null) + { + await _desktopAgent.JoinUserChannel("fdc3.channel.1"); + return; + } + + await _desktopAgent.JoinUserChannel(firstChannel.Id); + } + } } diff --git a/examples/fdc3-appdirectory/apps-with-intents.json b/examples/fdc3-appdirectory/apps-with-intents.json index 68696c0c8..8b15b3992 100644 --- a/examples/fdc3-appdirectory/apps-with-intents.json +++ b/examples/fdc3-appdirectory/apps-with-intents.json @@ -33,6 +33,14 @@ "interop": { "intents": { "listensFor": { + "OpenDiagnostics": { + "name": "OpenDiagnostics", + "displayName": "OpenDiagnostics", + "contexts": [ + "fdc3.nothing", + "fdc3.instrument" + ] + } } } } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.sln b/src/fdc3/dotnet/DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.sln index 20a8c9dc0..03979a8bc 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.sln +++ b/src/fdc3/dotnet/DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.sln @@ -47,7 +47,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MorganStanley.ComposeUI.Uti EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TestHelpers", "TestHelpers", "{3E857ED6-80F3-41EA-A1A9-B74EB5D3A92C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DesktopAgentClientConsoleApp", "test\TestHelpers\DesktopAgentClientConsoleApp\DesktopAgentClientConsoleApp.csproj", "{C5B762AB-D9CA-45FE-A683-B190BC927CA9}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DesktopAgentClientConsoleApp", "test\TestHelpers\DesktopAgentClientConsoleApp\DesktopAgentClientConsoleApp.csproj", "{C5B762AB-D9CA-45FE-A683-B190BC927CA9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests", "test\IntegrationTests\MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests.csproj", "{77033EEF-BEFD-42D2-A266-F6B8F508E758}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -115,6 +117,10 @@ Global {C5B762AB-D9CA-45FE-A683-B190BC927CA9}.Debug|Any CPU.Build.0 = Debug|Any CPU {C5B762AB-D9CA-45FE-A683-B190BC927CA9}.Release|Any CPU.ActiveCfg = Release|Any CPU {C5B762AB-D9CA-45FE-A683-B190BC927CA9}.Release|Any CPU.Build.0 = Release|Any CPU + {77033EEF-BEFD-42D2-A266-F6B8F508E758}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77033EEF-BEFD-42D2-A266-F6B8F508E758}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77033EEF-BEFD-42D2-A266-F6B8F508E758}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77033EEF-BEFD-42D2-A266-F6B8F508E758}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -136,6 +142,7 @@ Global {87D74A9A-1FA7-40D5-BA5E-0AB195C4CDF9} = {81BDD623-4A89-48B0-91F2-731AB96BAF7B} {3E857ED6-80F3-41EA-A1A9-B74EB5D3A92C} = {4BC4D2DE-3E72-4912-B853-F19BB0D5F732} {C5B762AB-D9CA-45FE-A683-B190BC927CA9} = {3E857ED6-80F3-41EA-A1A9-B74EB5D3A92C} + {77033EEF-BEFD-42D2-A266-F6B8F508E758} = {4BC4D2DE-3E72-4912-B853-F19BB0D5F732} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C50D6270-78F7-4C27-9DCD-82D041C849D1} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/Directory.Build.props b/src/fdc3/dotnet/DesktopAgent.Client/src/Directory.Build.props index 67729cbb0..8c40b554c 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/Directory.Build.props +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/Directory.Build.props @@ -1,12 +1,13 @@  - - false + true - + - + + + \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/DependencyInjection/ServiceCollectionExtensions.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/DependencyInjection/ServiceCollectionExtensions.cs index 8dc20b417..9db7cbeea 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/DependencyInjection/ServiceCollectionExtensions.cs @@ -29,7 +29,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddFdc3DesktopAgentClient( this IServiceCollection serviceCollection) { - serviceCollection.AddSingleton(); + serviceCollection.AddTransient(); return serviceCollection; } } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/DesktopAgentClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/DesktopAgentClient.cs index fd9edf818..2f4f09f98 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/DesktopAgentClient.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/DesktopAgentClient.cs @@ -18,11 +18,15 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; using MorganStanley.ComposeUI.Messaging.Abstractions; namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure; +/// +/// Desktop Agent client implementation. +/// public class DesktopAgentClient : IDesktopAgent { private readonly IMessaging _messaging; @@ -30,16 +34,26 @@ public class DesktopAgentClient : IDesktopAgent private readonly ILogger _logger; private readonly string _appId; private readonly string _instanceId; - private readonly IChannelFactory _channelFactory; - private readonly IMetadataClient _metadataClient; + private IChannelFactory _channelFactory; + private IMetadataClient _metadataClient; + private IIntentsClient _intentsClient; + private IOpenClient _openClient; //This cache stores the top-level context listeners added through the `AddContextListener(...)` API. It stores their actions to be able to resubscribe them when joining a new channel and handle the last context based on the FDC3 standard. private readonly Dictionary> _contextListeners = new(); + private readonly ConcurrentDictionary _intentListeners = new(); private IChannel? _currentChannel; + private IContext? _openedAppContext; + private readonly SemaphoreSlim _currentChannelLock = new(1, 1); - private readonly Dictionary _userChannels = new(); + private readonly ConcurrentDictionary _userChannels = new(); + private readonly ConcurrentDictionary _appChannels = new(); + private readonly SemaphoreSlim _appChannelsLock = new(1, 1); + + private readonly TaskCompletionSource _initializationTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private string? _openedAppContextId; public DesktopAgentClient( IMessaging messaging, @@ -52,19 +66,83 @@ public DesktopAgentClient( _appId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.AppId)) ?? throw ThrowHelper.MissingAppId(string.Empty); _instanceId = Environment.GetEnvironmentVariable(nameof(AppIdentifier.InstanceId)) ?? throw ThrowHelper.MissingInstanceId(_appId, string.Empty); - _channelFactory = new ChannelFactory(_messaging, _instanceId, _loggerFactory); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("AppID: {AppId}; InstanceId: {InstanceId} is registered for the FDC3 client app.", _appId, _instanceId); + } + _metadataClient = new MetadataClient(_appId, _instanceId, _messaging, _loggerFactory.CreateLogger()); + _openClient = new OpenClient(_instanceId, _messaging, this, _loggerFactory.CreateLogger()); + + _ = Task.Run(() => InitializeAsync()); + } + + /// + /// Joins to a user channel if the channel id is initially defined for the app and requests to backend to return the context if the app was opened through fdc3.open call. + /// + /// + private async Task InitializeAsync() + { + try + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Initializing DesktopAgentClient..."); + } + + var channelId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.Fdc3ChannelId); + var openedAppContextId = Environment.GetEnvironmentVariable(Fdc3StartupParameters.OpenedAppContextId); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Retrieved startup parameters for channelId: {ChannelId}; openedAppContextId: {OpenedAppContext}.", channelId ?? "null", openedAppContextId ?? "null"); + } + + if (!string.IsNullOrEmpty(channelId)) + { + await JoinUserChannel(channelId!).ConfigureAwait(false); + } + + if (!string.IsNullOrEmpty(openedAppContextId)) + { + _openedAppContextId = openedAppContextId; + await GetOpenedAppContextAsync(openedAppContextId!).ConfigureAwait(false); + } + + _channelFactory = new ChannelFactory(_messaging, _instanceId, _openedAppContext, _loggerFactory); + _intentsClient = new IntentsClient(_messaging, _channelFactory, _instanceId, _loggerFactory); + + _initializationTaskCompletionSource.SetResult(_instanceId); + } + catch (Exception exception) + { + _initializationTaskCompletionSource.TrySetException(exception); + } } - //TODO: AddContextListener should be revisited when the Open is being implemented as the first context that should be handled is the context which is passed through the fdc3.open call. + /// + /// Adds a context listener for the specified context type. + /// + /// + /// + /// + /// public async Task AddContextListener(string? contextType, ContextHandler handler) where T : IContext { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + ContextListener listener = null; + await _currentChannelLock.WaitAsync().ConfigureAwait(false); + try { - await _currentChannelLock.WaitAsync().ConfigureAwait(false); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Checking if the app was opened via fdc3.open. OpenedAppContext exists: {IsNotNull}, current context listener's context type: {ContextType}, received app context's type via open call: {OpenedAppContextType}.", _openedAppContext != null, contextType, _openedAppContext?.Type); + } - listener = await _channelFactory.CreateContextListener(handler, _currentChannel, contextType); + listener = await _channelFactory.CreateContextListenerAsync(handler, _currentChannel, contextType); _contextListeners.Add( listener, @@ -74,11 +152,6 @@ public async Task AddContextListener(string? contextType, ContextH await HandleLastContextAsync(listener); }); - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Context listener added for context type: {ContextType}.", contextType ?? "null"); - } - return listener; } finally @@ -89,13 +162,44 @@ public async Task AddContextListener(string? contextType, ContextH } } - public Task AddIntentListener(string intent, IntentHandler handler) where T : IContext + /// + /// Adds an intent listener for the specified intent. + /// + /// + /// + /// + /// + public async Task AddIntentListener(string intent, IntentHandler handler) where T : IContext { - throw new NotImplementedException(); + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + if (_intentListeners.TryGetValue(intent, out var existingListener)) + { + return existingListener; + } + + var listener = await _intentsClient.AddIntentListenerAsync(intent, handler); + + if (!_intentListeners.TryAdd(intent, listener)) + { + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning("Failed to add intent listener to the internal collection: {Intent}.", intent); + } + } + + return listener; } + /// + /// Broadcasts the specified context to the current channel. + /// + /// + /// public async Task Broadcast(IContext context) { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + try { await _currentChannelLock.WaitAsync().ConfigureAwait(false); @@ -113,55 +217,152 @@ public async Task Broadcast(IContext context) } } - public Task CreatePrivateChannel() + /// + /// Creates a new private channel. + /// + /// + public async Task CreatePrivateChannel() { - throw new NotImplementedException(); + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + var privateChannel = await _channelFactory.CreatePrivateChannelAsync(); + + return privateChannel; } - public Task> FindInstances(IAppIdentifier app) + /// + /// Lists all instances of the specified app. + /// + /// + /// + public async Task> FindInstances(IAppIdentifier app) { - throw new NotImplementedException(); + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + var instances = await _metadataClient.FindInstancesAsync(app); + return instances; } - public Task FindIntent(string intent, IContext? context = null, string? resultType = null) + /// + /// Finds an app intent matching the specified parameters from the AppDirectory. + /// + /// + /// + /// + /// + public async Task FindIntent(string intent, IContext? context = null, string? resultType = null) { - throw new NotImplementedException(); + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + var result = await _intentsClient.FindIntentAsync(intent, context, resultType); + return result; } - public Task> FindIntentsByContext(IContext context, string? resultType = null) + /// + /// Lists all app intents matching the specified context and other parameters from the AppDirectory. + /// + /// + /// + /// + public async Task> FindIntentsByContext(IContext context, string? resultType = null) { - throw new NotImplementedException(); + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + var appIntents = await _intentsClient.FindIntentsByContextAsync(context, resultType); + return appIntents; } + /// + /// Retrieves metadata for the specified app. + /// + /// + /// public async Task GetAppMetadata(IAppIdentifier app) { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + var appMetadata = await _metadataClient.GetAppMetadataAsync(app); return appMetadata; } - public Task GetCurrentChannel() + /// + /// Retrieves the current user channel the app is connected to. + /// + /// + public async Task GetCurrentChannel() { - return Task.FromResult(_currentChannel); + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + return _currentChannel; } + /// + /// Retrieves implementation metadata of the current application. + /// + /// public async Task GetInfo() { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + var implementationMetadata = await _metadataClient.GetInfoAsync(); return implementationMetadata; } - public Task GetOrCreateChannel(string channelId) + /// + /// Creates or retrieves an application channel with the specified id. + /// + /// + /// + public async Task GetOrCreateChannel(string channelId) { - throw new NotImplementedException(); + try + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + await _appChannelsLock.WaitAsync().ConfigureAwait(false); + + if (_appChannels.TryGetValue(channelId, out var existingChannel)) + { + return existingChannel; + } + + var channel = await _channelFactory.CreateAppChannelAsync(channelId); + + if (!_appChannels.TryAdd(channelId, channel)) + { + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning("Failed to add app channel to the internal collection: {ChannelId}.", channelId); + } + } + + return channel; + } + finally + { + _appChannelsLock.Release(); + } } - public Task> GetUserChannels() + /// + /// Lists all user channels available. + /// + /// + public async Task> GetUserChannels() { - throw new NotImplementedException(); + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + var channels = await _channelFactory.GetUserChannelsAsync(); + return channels; } + /// + /// Joins to a user channel with the specified id. + /// + /// + /// public async Task JoinUserChannel(string channelId) { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + try { await _currentChannelLock.WaitAsync().ConfigureAwait(false); @@ -197,8 +398,14 @@ public async Task JoinUserChannel(string channelId) } } + /// + /// Leaves the current user channel. + /// + /// public async Task LeaveCurrentChannel() { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + try { await _currentChannelLock.WaitAsync().ConfigureAwait(false); @@ -223,21 +430,75 @@ public async Task LeaveCurrentChannel() } } - public Task Open(IAppIdentifier app, IContext? context = null) + /// + /// Opens the specified app. + /// + /// + /// + /// + public async Task Open(IAppIdentifier app, IContext? context = null) + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + var appIdentifier = await _openClient.OpenAsync(app, context); + return appIdentifier; + } + + /// + /// Raises the specified intent with the given context and optionally targets specific app to handle it. When multiple apps can handle the intent, the user will be prompted to choose one. + /// + /// + /// + /// + /// + public async Task RaiseIntent(string intent, IContext context, IAppIdentifier? app = null) { - throw new NotImplementedException(); + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + var intentResolution = await _intentsClient.RaiseIntentAsync(intent, context, app); + return intentResolution; } - public Task RaiseIntent(string intent, IContext context, IAppIdentifier? app = null) + /// + /// Raises an intent for the given context and optionally targets specific app to handle it. When multiple apps can handle the intent, the user will be prompted to choose one. + /// + /// + /// + /// + public async Task RaiseIntentForContext(IContext context, IAppIdentifier? app = null) { - throw new NotImplementedException(); + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + var intentResolution = await _intentsClient.RaiseIntentForContextAsync(context, app); + return intentResolution; } - public Task RaiseIntentForContext(IContext context, IAppIdentifier? app = null) + /// + /// Checks if the app was opened through fdc3.open and retrieves the context associated with the opened app. + /// + /// + /// + internal async ValueTask GetOpenedAppContextAsync(string openedAppContextId) { - throw new NotImplementedException(); + try + { + _openedAppContext = await _openClient.GetOpenAppContextAsync(openedAppContextId); + } + catch (Fdc3DesktopAgentException exception) + { + if (_logger.IsEnabled(LogLevel.Error)) + { + _logger.LogError(exception, "No context was received for the opened app."); + } + } } + /// + /// Handles the last context in the current channel for the specified context listener. + /// + /// + /// + /// private async Task HandleLastContextAsync( ContextListener? listener = null) where T : IContext diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelFactory.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelFactory.cs index fd962849f..eab12afd2 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelFactory.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IChannelFactory.cs @@ -31,7 +31,7 @@ internal interface IChannelFactory /// The channel to listen on. If null, the default channel is used. /// The context type to filter for. If null, all context types are received. /// A representing the asynchronous operation. - public ValueTask> CreateContextListener( + public ValueTask> CreateContextListenerAsync( ContextHandler contextHandler, IChannel? currentChannel = null, string? contextType = null) @@ -43,4 +43,31 @@ public ValueTask> CreateContextListener( /// The ID of the user channel to join. /// A representing the asynchronous operation. public ValueTask JoinUserChannelAsync(string channelId); + + /// + /// Retrieves all available user channels. + /// + /// A representing the asynchronous operation that returns an array of user channels. + public ValueTask> GetUserChannelsAsync(); + + /// + /// Gets or creates an application channel with the specified channel ID. + /// + /// + /// + public ValueTask CreateAppChannelAsync(string channelId); + + /// + /// Sends a request to the backend to check if the channel with the specified ID and type exists, and if so, returns it. + /// + /// + /// + /// + public ValueTask FindChannelAsync(string channelId, ChannelType channelType); + + /// + /// Creates a private channel by sending request to the backend. + /// + /// + public ValueTask CreatePrivateChannelAsync(); } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IIntentsClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IIntentsClient.cs new file mode 100644 index 000000000..aa395dea7 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IIntentsClient.cs @@ -0,0 +1,71 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using Finos.Fdc3; +using Finos.Fdc3.Context; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure; + +/// +/// Provides methods for interacting with FDC3 intents in the desktop agent client infrastructure. +/// +internal interface IIntentsClient +{ + /// + /// Finds an intent by name, optionally filtering by context and result type by sending the request to the backend. + /// + /// The name of the intent to find. + /// Optional context(type) to filter the intent search. + /// Optional result type to filter the intent search. + /// + /// A representing the asynchronous operation to find the intent. + /// + public ValueTask FindIntentAsync(string intent, IContext? context = null, string? resultType = null); + + /// + /// Finds all intents that can handle the specified context, optionally filtering by result type by sending the request to the backend. + /// + /// + /// + /// + public ValueTask> FindIntentsByContextAsync(IContext context, string? resultType = null); + + /// + /// Raises an intent for a given context, optionally targeting a specific application. If multiple applications can handle the request the container should allow the user to choose the right app. + /// + /// + /// + /// + public ValueTask RaiseIntentForContextAsync(IContext context, IAppIdentifier? app); + + /// + /// Adds an intent listener which handles the sent intents with the provided handler. + /// This registers the listener on the backend and returns an to manage the listener's lifecycle. + /// + /// + /// + /// + /// + public ValueTask AddIntentListenerAsync(string intent, IntentHandler handler) + where T : IContext; + + /// + /// Raises an intent, optionally targeting a specific application. If multiple applications can handle the request the container should allow the user to choose the right app. + /// + /// + /// + /// + /// + public ValueTask RaiseIntentAsync(string intent, IContext context, IAppIdentifier? app); +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IMetadataClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IMetadataClient.cs index 73c550d7e..3b2640d2f 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IMetadataClient.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IMetadataClient.cs @@ -37,4 +37,11 @@ internal interface IMetadataClient /// A representing the asynchronous operation to retrieve application metadata. /// public ValueTask GetAppMetadataAsync(IAppIdentifier appIdentifier); + + /// + /// Finds all instances of a specific FDC3 application. + /// + /// + /// + public ValueTask> FindInstancesAsync(IAppIdentifier appIdentifier); } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IOpenClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IOpenClient.cs new file mode 100644 index 000000000..85cd81546 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/IOpenClient.cs @@ -0,0 +1,42 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using Finos.Fdc3; +using Finos.Fdc3.Context; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure; + +/// +/// Provides functionality to open an application with an optional FDC3 context. +/// +internal interface IOpenClient +{ + /// + /// Opens the specified application, optionally passing an FDC3 context. + /// + /// The application identifier to open. + /// The optional FDC3 context to pass to the application. + /// + /// A representing the asynchronous operation, + /// with the identifier of the opened application as the result. + /// + public ValueTask OpenAsync(IAppIdentifier app, IContext? context); + + /// + /// Retrieves the sent context from the initiator if it was opened via the fdc3.open call. + /// + /// The context ID associated with the open app request. + /// + public ValueTask GetOpenAppContextAsync(string openAppContextId); +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelFactory.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelFactory.cs index 1c541d5a0..a60eafb3b 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelFactory.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ChannelFactory.cs @@ -12,11 +12,13 @@ * and limitations under the License. */ +using System.Collections.Concurrent; using System.Text.Json; using Finos.Fdc3; using Finos.Fdc3.Context; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; @@ -29,22 +31,26 @@ internal class ChannelFactory : IChannelFactory { private readonly IMessaging _messaging; private readonly string _instanceId; + private readonly IContext? _openedAppContext; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + private readonly ConcurrentDictionary _privateChannels = new(); public ChannelFactory( IMessaging messaging, string fdc3InstanceId, + IContext? openedAppContext = null, ILoggerFactory? loggerFactory = null) { _messaging = messaging; _instanceId = fdc3InstanceId; + _openedAppContext = openedAppContext; _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; _logger = _loggerFactory.CreateLogger(); } - public async ValueTask> CreateContextListener( + public async ValueTask> CreateContextListenerAsync( ContextHandler contextHandler, IChannel? currentChannel = null, string? contextType = null) @@ -57,6 +63,7 @@ public async ValueTask> CreateContextListener( contextHandler: contextHandler, messaging: _messaging, contextType: contextType, + openedAppContext: _openedAppContext, logger: _loggerFactory.CreateLogger>()); } @@ -72,6 +79,99 @@ public async ValueTask> CreateContextListener( } } + public async ValueTask CreateAppChannelAsync(string channelId) + { + var request = new CreateAppChannelRequest + { + InstanceId = _instanceId, + ChannelId = channelId, + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.CreateAppChannel, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (!response.Success) + { + throw ThrowHelper.ErrorResponseReceived(Fdc3DesktopAgentErrors.UnspecifiedReason); + } + + return new Channel( + channelId: channelId, + channelType: ChannelType.App, + messaging: _messaging, + instanceId: _instanceId, + displayMetadata: null, + loggerFactory: _loggerFactory); + } + + public async ValueTask> GetUserChannelsAsync() + { + var request = new GetUserChannelsRequest + { + InstanceId = _instanceId + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.GetUserChannels, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (response.Channels == null || !response.Channels.Any()) + { + throw ThrowHelper.DesktopAgentBackendDidNotResolveRequest(nameof(GetUserChannelsRequest), nameof(response.Channels), Fdc3DesktopAgentErrors.NoUserChannelSetFound); + } + + var channels = new List(); + + foreach (var channel in response.Channels) + { + if (channel.DisplayMetadata == null) + { + _logger.LogDebug("Skipping channel with missing ChannelId: {ChannelId} or the {NameOfDisplayMetadata}.", channel.Id, nameof(DisplayMetadata)); + } + + if (string.IsNullOrEmpty(channel.Id)) + { + _logger.LogDebug("Skipping channel with missing {NameOfChannelId}.", nameof(channel.Id)); + continue; + } + + var userChannel = new Channel( + channelId: channel.Id, + channelType: ChannelType.User, + instanceId: _instanceId, + messaging: _messaging, + displayMetadata: channel.DisplayMetadata, + loggerFactory: _loggerFactory); + + channels.Add(userChannel); + } + + return channels; + } + public async ValueTask JoinUserChannelAsync(string channelId) { var request = new JoinUserChannelRequest @@ -105,9 +205,136 @@ public async ValueTask JoinUserChannelAsync(string channelId) channelType: ChannelType.User, instanceId: _instanceId, messaging: _messaging, + openedAppContext: _openedAppContext, displayMetadata: response.DisplayMetadata, loggerFactory: _loggerFactory); return channel; } + + public async ValueTask FindChannelAsync(string channelId, ChannelType channelType) + { + var request = new FindChannelRequest + { + ChannelId = channelId, + ChannelType = channelType + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.FindChannel, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (!response.Found) + { + throw ThrowHelper.ChannelNotFound(channelId, channelType); + } + + if (channelType == ChannelType.Private) + { + return await JoinPrivateChannelAsync(channelId); + } + + //This is only called when raising an intent, the RaiseIntent logic. + return new Channel( + channelId: channelId, + channelType: channelType, + instanceId: _instanceId, + messaging: _messaging, + openedAppContext: _openedAppContext, + loggerFactory: _loggerFactory); + } + + public async ValueTask CreatePrivateChannelAsync() + { + var request = new CreatePrivateChannelRequest + { + InstanceId = _instanceId + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.CreatePrivateChannel, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.PrivateChannelCreationFailed(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + var channel = new PrivateChannel( + channelId: response.ChannelId!, + instanceId: _instanceId, + messaging: _messaging, + isOriginalCreator: true, + onDisconnect: () => _privateChannels.TryRemove(response.ChannelId!, out _), + displayMetadata: null, + loggerFactory: _loggerFactory); + + _privateChannels.TryAdd(response.ChannelId!, channel); + + return channel; + } + + private async ValueTask JoinPrivateChannelAsync(string channelId) + { + var request = new JoinPrivateChannelRequest + { + InstanceId = _instanceId, + ChannelId = channelId + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.JoinPrivateChannel, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (!response.Success) + { + throw ThrowHelper.PrivateChannelJoiningFailed(channelId); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Joined private channel {ChannelId}.", channelId); + } + + var channel = _privateChannels.GetOrAdd( + channelId, + new PrivateChannel( + channelId: channelId, + instanceId: _instanceId, + messaging: _messaging, + isOriginalCreator: false, + onDisconnect: () => _privateChannels.TryRemove(channelId, out _), + displayMetadata: null, + loggerFactory: _loggerFactory)); + + return channel; + } } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ContextListener.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ContextListener.cs index 63a9374cf..e159b5c32 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ContextListener.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/ContextListener.cs @@ -12,6 +12,7 @@ * and limitations under the License. */ +using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Nodes; using Finos.Fdc3; @@ -32,18 +33,20 @@ internal class ContextListener : IListener, IAsyncDisposable private readonly ContextHandler _contextHandler; private readonly string? _contextType; private readonly IMessaging _messaging; + private readonly T? _openedAppContext; private readonly ILogger> _logger; private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; - - private readonly SemaphoreSlim _serializedContextsLock = new(1, 1); - private readonly List _serializedContexts = new(); - private readonly SemaphoreSlim _subscriptionLock = new(1,1); private bool _isSubscribed = false; - private IAsyncDisposable? _subscription; + private IAsyncDisposable? _subscription; private string _contextListenerId; + private bool _isOpenedAppContextHandled = false; + + private readonly ConcurrentQueue _contexts = new(); + private Action>? _unsubscribeCallback; + public string? ContextType => _contextType; public ContextListener( @@ -51,12 +54,23 @@ public ContextListener( ContextHandler contextHandler, IMessaging messaging, string? contextType = null, + IContext? openedAppContext = null, ILogger>? logger = null) { _instanceId = instanceId ?? throw new ArgumentNullException(nameof(instanceId)); _contextHandler = contextHandler; _contextType = contextType; _messaging = messaging; + + if (openedAppContext != null && openedAppContext is T typedContext) + { + _openedAppContext = typedContext; + } + else + { + _isOpenedAppContextHandled = true; + } + _logger = logger ?? NullLogger>.Instance; } @@ -89,6 +103,11 @@ public void Unsubscribe() .GetResult(); _isSubscribed = false; + + if (_unsubscribeCallback != null) + { + _unsubscribeCallback(this); + } } finally { @@ -101,7 +120,6 @@ public async ValueTask SubscribeAsync(string channelId, ChannelType channelType, try { await _subscriptionLock.WaitAsync().ConfigureAwait(false); - await _serializedContextsLock.WaitAsync().ConfigureAwait(false); if (_isSubscribed) { @@ -111,6 +129,12 @@ public async ValueTask SubscribeAsync(string channelId, ChannelType channelType, await RegisterContextListenerAsync(channelId, channelType).ConfigureAwait(false); var topic = new ChannelTopics(channelId, channelType); + if (_openedAppContext != null && !_isOpenedAppContextHandled) + { + _contextHandler(_openedAppContext); + _isOpenedAppContextHandled = true; + } + _subscription = await _messaging.SubscribeAsync( topic.Broadcast, serializedContext => @@ -136,8 +160,13 @@ public async ValueTask SubscribeAsync(string channelId, ChannelType channelType, _logger.LogDebug("Context received: {SerializedContext}; Type to deserialize to: {Type}...", serializedContext, typeof(T)); } + if (!_isOpenedAppContextHandled) + { + _contexts.Enqueue(context!); + return new ValueTask(); + } + _contextHandler(context!); - _serializedContexts.Add(context!); return new ValueTask(); }, cancellationToken); @@ -152,7 +181,6 @@ public async ValueTask SubscribeAsync(string channelId, ChannelType channelType, finally { _subscriptionLock.Release(); - _serializedContextsLock.Release(); _logger.LogInformation("Context listener subscribed to channel {ChannelId} for context type {ContextType}.", channelId, _contextType); } @@ -181,7 +209,13 @@ public async Task HandleContextAsync(IContext context) return; } - _contextHandler((T) context); + if (!_isOpenedAppContextHandled) + { + _contexts.Enqueue(typedContext); + return; + } + + _contextHandler(typedContext); } finally { @@ -189,6 +223,26 @@ public async Task HandleContextAsync(IContext context) } } + internal Task SetOpenHandledAsync(bool handled) + { + _isOpenedAppContextHandled = handled; + return Task.CompletedTask; + } + + //TODO: decide if we want to handle the cached contexts after the context via the fdc3.open call is handled + private Task HandleCachedContextsAsync() + { + if (_isOpenedAppContextHandled) + { + while (_contexts.TryDequeue(out var context)) + { + _contextHandler(context); + } + } + + return Task.CompletedTask; + } + private async ValueTask RegisterContextListenerAsync( string channelId, ChannelType channelType) @@ -252,4 +306,9 @@ private async ValueTask UnregisterContextListenerAsync() _contextListenerId = null; } + + internal void SetUnsubscribeCallback(Action> unsubscribeCallback) + { + _unsubscribeCallback = unsubscribeCallback; + } } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentListener.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentListener.cs new file mode 100644 index 000000000..cc3046b37 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentListener.cs @@ -0,0 +1,265 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System.Text.Json; +using Finos.Fdc3; +using Finos.Fdc3.Context; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Messaging.Abstractions; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; + +internal class IntentListener : IListener + where T : IContext +{ + private readonly IMessaging _messaging; + private readonly string _intent; + private readonly string _instanceId; + private readonly IntentHandler _intentHandler; + private readonly ILogger> _logger; + private readonly SemaphoreSlim _semaphore = new(1, 1); + private bool _isRegistered = false; + private IAsyncDisposable _subscription; + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + + public IntentListener( + IMessaging messaging, + string intent, + string instanceId, + IntentHandler handler, + ILogger>? logger = null) + { + _messaging = messaging; + _intent = intent; + _instanceId = instanceId; + _intentHandler = handler; + _logger = logger ?? NullLogger>.Instance; + } + + public async ValueTask RegisterIntentHandlerAsync() + { + try + { + await _semaphore.WaitAsync(); + if (_isRegistered) + { + return; + } + + var topic = Fdc3Topic.RaiseIntentResolution(_intent, _instanceId); + + _subscription = await _messaging.SubscribeJsonAsync( + topic, + HandleIntentMessageAsync, + _jsonSerializerOptions); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("{NameOfIntentListener} is subscribed for {Intent} with instance id: {InstanceId} to topic: {Topic}.", nameof(IntentListener), _intent, _instanceId, topic); + } + + _isRegistered = true; + } + finally + { + _semaphore.Release(); + } + } + + public void Unsubscribe() + { + try + { + _semaphore.Wait(); + + var request = CreateUnsubscribeRequest(); + LogDebug("Unsubscribing intent listener for intent {Intent} and instanceId {InstanceId}...", _intent, _instanceId); + + var response = _messaging.InvokeJsonServiceAsync( + Fdc3Topic.AddIntentListener, + request, + _jsonSerializerOptions).GetAwaiter().GetResult(); + + ValidateUnsubscribeResponse(response); + + _subscription.DisposeAsync().GetAwaiter().GetResult(); + + LogDebug("Successfully unsubscribed intent listener for intent {Intent} and instanceId {InstanceId}.", _intent, _instanceId); + _isRegistered = false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while unsubscribing intent listener for intent {Intent} and instanceId {InstanceId}.", _intent, _instanceId); + throw; + } + finally + { + _semaphore.Release(); + } + } + + private IntentListenerRequest CreateUnsubscribeRequest() => + new IntentListenerRequest + { + Intent = _intent, + Fdc3InstanceId = _instanceId, + State = SubscribeState.Unsubscribe + }; + + private void ValidateUnsubscribeResponse(IntentListenerResponse? response) + { + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (response.Stored) + { + throw ThrowHelper.ListenerNotUnRegistered(_intent, _instanceId); + } + } + + private async Task SetRequestResultAsync(object? intentResult, StoreIntentResultRequest request, string messageId) + { + try + { + if (intentResult == null) //It is a simply void + { + LogDebug("The intent result is void for intent:{Intent} for message: {MessageId}.", _intent, messageId); + request.VoidResult = true; + } + else if (intentResult is Task resolvableTask) //It is a task with some return type + { + var resolvedIntentResult = await resolvableTask; + + if (resolvedIntentResult is IChannel channel) + { + LogDebug("The intent result is a channel for intent:{Intent} for message: {MessageId}. Channel: {Channel}", _intent, messageId, Serialize(channel)); + request.ChannelId = channel.Id; + request.ChannelType = channel.Type; + } + else if (resolvedIntentResult is IContext ctx) + { + var context = Serialize(ctx); + LogDebug("The intent result is a context for intent:{Intent} for message: {MessageId}. Context: {Context}", _intent, messageId, context); + request.Context = context; + } + else // it is a resolvable task with no return type + { + LogDebug("The intent result is void for intent:{Intent} for message: {MessageId}.", _intent, messageId); + request.VoidResult = true; + } + } + } + catch (Exception exception) + { + _logger.LogError(exception, "Error while resolving the intent..."); + } + } + + private async ValueTask HandleIntentMessageAsync(RaiseIntentResolutionRequest? receivedRequest) + { + if (receivedRequest == null) + { + LogError("Received null or invalid intent invocation request for intent {Intent}.", _intent); + throw ThrowHelper.MissingResponse(); + } + + var request = new StoreIntentResultRequest + { + MessageId = receivedRequest.MessageId, + Intent = _intent, + OriginFdc3InstanceId = _instanceId, + TargetFdc3InstanceId = receivedRequest.ContextMetadata.Source!.InstanceId!, //This should be defined + }; + + try + { + var context = Deserialize(receivedRequest.Context); + if (context == null) + { + LogError("Received null or invalid context for intent {Intent}. Context: {Context}...", _intent, receivedRequest.Context); + throw ThrowHelper.MissingContext(); + } + + if (ContextTypes.GetType(context.Type) != typeof(T)) + { + request.Error = ResolveError.IntentDeliveryFailed; + + LogDebug("The context type {ContextType} does not match the expected type {ExpectedType} for intent {Intent}. Context: {Context}...", context.Type, typeof(T).Name, _intent, Serialize(context)); + } + else + { + var intentResult = _intentHandler(context, receivedRequest.ContextMetadata); + + LogDebug("Resolved intent {Intent} with result: {Result}...", _intent, intentResult); + + await SetRequestResultAsync(intentResult, request, receivedRequest.MessageId); + } + } + catch (Exception exception) + { + _logger.LogError(exception, "Error while resolving the received intent: {Intent}.", _intent); + request.Error = ResultError.IntentHandlerRejected; + } + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.SendIntentResult, + request, + _jsonSerializerOptions); + + if (response == null) + { + LogError("Received null or invalid response when storing the intent result for intent {Intent}. Request: {Request}...", _intent, Serialize(request)); + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + LogError("Received error response when storing the intent result for intent {Intent}. Request: {Request}, Response: {Response}...", _intent, Serialize(request), Serialize(response)); + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (!response.Stored) + { + throw ThrowHelper.IntentResultStoreFailed(_intent, _instanceId); + } + } + + private TType? Deserialize(string json) => JsonSerializer.Deserialize(json, _jsonSerializerOptions); + + private string Serialize(TType obj) => JsonSerializer.Serialize(obj, _jsonSerializerOptions); + + private void LogDebug(string message, params object[] args) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug(message, args); + } + } + + private void LogError(string message, params object[] args) + { + _logger.LogError(message, args); + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentsClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentsClient.cs new file mode 100644 index 000000000..a5c9a2f46 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/IntentsClient.cs @@ -0,0 +1,290 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System.Text.Json; +using Finos.Fdc3; +using Finos.Fdc3.Context; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Messaging.Abstractions; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; +using IntentResolution = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol.IntentResolution; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; + +internal class IntentsClient : IIntentsClient +{ + private static int _messageIdCounter = 0; + + private readonly IMessaging _messaging; + private readonly IChannelFactory _channelFactory; + private readonly string _instanceId; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + + public IntentsClient( + IMessaging messaging, + IChannelFactory channelFactory, + string instanceId, + ILoggerFactory? loggerFactory = null) + { + _messaging = messaging; + _channelFactory = channelFactory; + _instanceId = instanceId; + _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + _logger = _loggerFactory.CreateLogger(); + } + + public async ValueTask AddIntentListenerAsync(string intent, IntentHandler handler) where T : IContext + { + var listener = new IntentListener( + _messaging, + intent, + _instanceId, + handler, + _loggerFactory.CreateLogger>()); + + await listener.RegisterIntentHandlerAsync(); + + var request = new IntentListenerRequest + { + Fdc3InstanceId = _instanceId, + Intent = intent, + State = SubscribeState.Subscribe + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.AddIntentListener, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (!response.Stored) + { + throw ThrowHelper.ListenerNotRegistered(intent, _instanceId); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Listener is registered..."); + } + + return listener; + } + + public async ValueTask FindIntentAsync(string intent, IContext? context = null, string? resultType = null) + { + var request = new FindIntentRequest + { + Fdc3InstanceId = _instanceId, + Intent = intent, + Context = context?.Type, + ResultType = resultType + }; + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Finding intent. Intent: {Intent}, Context Type: {ContextType}, Result Type: {ResultType}", intent, context?.Type, resultType); + } + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.FindIntent, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (response.AppIntent == null) + { + throw ThrowHelper.DesktopAgentBackendDidNotResolveRequest(nameof(FindIntentRequest), nameof(response.AppIntent), Fdc3DesktopAgentErrors.NoAppIntent); + } + + return response.AppIntent; + } + + public async ValueTask> FindIntentsByContextAsync(IContext context, string? resultType = null) + { + var request = new FindIntentsByContextRequest + { + Fdc3InstanceId = _instanceId, + Context = JsonSerializer.Serialize(context, _jsonSerializerOptions), + ResultType = resultType + }; + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Finding intents by context. Context Type: {ContextType}, Result Type: {ResultType}", context.Type, resultType); + } + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.FindIntentsByContext, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (response.AppIntents == null) + { + throw ThrowHelper.AppIntentMissingFromResponse("null", context.Type, resultType); + } + + return response.AppIntents; + } + + public async ValueTask RaiseIntentAsync(string intent, IContext context, IAppIdentifier? app) + { + var messageId = Interlocked.Increment(ref _messageIdCounter); + + var request = new RaiseIntentRequest + { + MessageId = messageId, + Fdc3InstanceId = _instanceId, + Intent = intent, + Context = JsonSerializer.Serialize(context, _jsonSerializerOptions), + }; + + if (app != null) + { + request.TargetAppIdentifier = new AppIdentifier + { + AppId = app.AppId, + InstanceId = app.InstanceId + }; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Request is created: {Request}", JsonSerializer.Serialize(request, _jsonSerializerOptions)); + } + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.RaiseIntent, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (string.IsNullOrEmpty(response.MessageId) + || string.IsNullOrEmpty(response.Intent) + || response.AppMetadata == null) + { + throw ThrowHelper.IntentResolutionIsNotDefined(context.Type, app?.AppId, app?.InstanceId, intent); + } + + var intentResolution = new IntentResolution( + response.MessageId!, + _messaging, + _channelFactory, + response.Intent!, + response.AppMetadata!, + _loggerFactory.CreateLogger()); + + return intentResolution; + } + + public async ValueTask RaiseIntentForContextAsync(IContext context, IAppIdentifier? app) + { + var messageId = Interlocked.Increment(ref _messageIdCounter); + + var request = new RaiseIntentForContextRequest + { + Fdc3InstanceId = _instanceId, + Context = JsonSerializer.Serialize(context, _jsonSerializerOptions), + MessageId = messageId + }; + + if (app != null) + { + request.TargetAppIdentifier = new AppIdentifier + { + AppId = app.AppId, + InstanceId = app.InstanceId + }; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Request is created: {Request}", JsonSerializer.Serialize(request, _jsonSerializerOptions)); + } + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.RaiseIntentForContext, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (string.IsNullOrEmpty(response.MessageId) + || string.IsNullOrEmpty(response.Intent) + || response.AppMetadata == null) + { + throw ThrowHelper.IntentResolutionIsNotDefined(context.Type, app?.AppId, app?.InstanceId); + } + + var intentResolution = new IntentResolution( + response.MessageId!, + _messaging, + _channelFactory, + response.Intent!, + response.AppMetadata!, + _loggerFactory.CreateLogger()); + + return intentResolution; + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/MetadataClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/MetadataClient.cs index 280137067..c42fdacdc 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/MetadataClient.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/MetadataClient.cs @@ -20,6 +20,7 @@ using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; using MorganStanley.ComposeUI.Messaging.Abstractions; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; @@ -43,6 +44,41 @@ public MetadataClient( _logger = logger ?? NullLogger.Instance; } + public async ValueTask> FindInstancesAsync(IAppIdentifier appIdentifier) + { + var request = new FindInstancesRequest + { + Fdc3InstanceId = _instanceId, + AppIdentifier = new AppIdentifier + { + AppId = appIdentifier.AppId, + InstanceId = appIdentifier.InstanceId, + } + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.FindInstances, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (response.Instances == null) + { + throw ThrowHelper.DesktopAgentBackendDidNotResolveRequest(nameof(FindInstancesRequest), nameof(response.Instances), Fdc3DesktopAgentErrors.NoInstanceFound); + } + + return response.Instances; + } + public async ValueTask GetAppMetadataAsync(IAppIdentifier appIdentifier) { var request = new GetAppMetadataRequest @@ -67,13 +103,13 @@ public async ValueTask GetAppMetadataAsync(IAppIdentifier appIdent if (response == null) { - _logger.LogError("{GetAppMetadataAsync} response is null returned by the server...", nameof(GetAppMetadataAsync)); + _logger.LogError("{Method} response is null returned by the server...", nameof(GetAppMetadataAsync)); throw ThrowHelper.MissingResponse(); } if (response.Error != null) { - _logger.LogError("{_appId} cannot return the {AppMetadata} for {AppId} due to: {Error}.", nameof(AppMetadata), appIdentifier.AppId, response.Error); + _logger.LogError("{AppId} cannot return the {AppMetadata} for {TargetAppId} due to: {Error}.", _appId, nameof(AppMetadata), appIdentifier.AppId, response.Error); throw ThrowHelper.ErrorResponseReceived(_appId, appIdentifier.AppId, nameof(AppMetadata), response.Error); } @@ -98,7 +134,7 @@ public async ValueTask GetInfoAsync() if (response == null) { - _logger.LogError("{GetInfoAsync} response is null returned by the server...", nameof(GetInfoAsync)); + _logger.LogError("{Method} response is null returned by the server...", nameof(GetInfoAsync)); throw ThrowHelper.MissingResponse(); } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/OpenClient.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/OpenClient.cs new file mode 100644 index 000000000..51a44b8ac --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/OpenClient.cs @@ -0,0 +1,155 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System.Text.Json; +using Finos.Fdc3; +using Finos.Fdc3.Context; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Messaging.Abstractions; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; + +internal class OpenClient : IOpenClient +{ + private readonly string _instanceId; + private readonly IMessaging _messaging; + private readonly IDesktopAgent _desktopAgent; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + + public OpenClient( + string instanceId, + IMessaging messaging, + IDesktopAgent desktopAgent, + ILogger? logger = null) + { + _instanceId = instanceId; + _messaging = messaging; + _desktopAgent = desktopAgent; + _logger = logger ?? NullLogger.Instance; + } + + /// + /// + /// + /// + /// + public async ValueTask GetOpenAppContextAsync(string openAppContextId) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("OpenClient: Retrieving open app context for app {InstanceId}...", _instanceId); + } + + if (string.IsNullOrEmpty(openAppContextId)) + { + throw ThrowHelper.MissingOpenAppContext(); + } + + var request = new GetOpenedAppContextRequest + { + ContextId = openAppContextId + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.GetOpenedAppContext, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (string.IsNullOrEmpty(response.Context)) + { + throw ThrowHelper.MissingOpenedAppContext(); + } + + var context = JsonSerializer.Deserialize(response.Context!, _jsonSerializerOptions); + + if (context == null) + { + throw ThrowHelper.MissingOpenedAppContext(); + } + + return context; + } + + /// + /// + /// + /// + /// + /// + public async ValueTask OpenAsync(IAppIdentifier app, IContext? context) + { + if (context != null + && string.IsNullOrEmpty(context.Type)) + { + throw ThrowHelper.MalformedContext(); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("OpenClient: Opening app {App} with context {Context}", app, context); + } + + var currentChannel = await _desktopAgent.GetCurrentChannel(); + + var request = new OpenRequest + { + InstanceId = _instanceId, + AppIdentifier = new AppIdentifier + { + AppId = app.AppId, + InstanceId = app.InstanceId + }, + Context = JsonSerializer.Serialize(context, _jsonSerializerOptions), + ChannelId = currentChannel?.Id + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.Open, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (response.AppIdentifier == null) + { + throw ThrowHelper.AppIdentifierNotRetrieved(); + } + + return response.AppIdentifier; + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelContextListenerEventListener.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelContextListenerEventListener.cs new file mode 100644 index 000000000..6eb0f9d3f --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelContextListenerEventListener.cs @@ -0,0 +1,93 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using Finos.Fdc3; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; + +internal delegate void PrivateChannelEventHandler(string? contextType); +internal delegate void PrivateChannelOnUnsubscribeHandler(PrivateChannelContextListenerEventListener privateChannelContextListener); + +internal class PrivateChannelContextListenerEventListener : IListener +{ + private PrivateChannelEventHandler _handler; + private PrivateChannelOnUnsubscribeHandler _unsubscribeCallback; + private readonly ILogger _logger; + private readonly SemaphoreSlim _semaphoreSlim = new(1, 1); + private bool _subscribed; + + public PrivateChannelContextListenerEventListener( + PrivateChannelEventHandler onContext, + PrivateChannelOnUnsubscribeHandler onUnsubscribe, + ILogger? logger = null) + { + _handler = onContext; + _unsubscribeCallback = onUnsubscribe; + _subscribed = true; + _logger = logger ?? NullLogger.Instance; + } + + public void Execute(string? contextType) + { + try + { + _semaphoreSlim.Wait(); + + if (_subscribed) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Invoking context listener for context type '{ContextType}'.", contextType); + } + + _handler(contextType); + } + } + finally + { + _semaphoreSlim.Release(); + } + } + + public void Unsubscribe() + { + UnsubscribeCore(true); + } + + internal void UnsubscribeCore(bool doCallback) + { + try + { + _semaphoreSlim.Wait(); + + _subscribed = false; + + if (doCallback) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Unsubscribing {NameOfPrivateChannelContextListenerEventListener}.", nameof(PrivateChannelContextListenerEventListener)); + } + + _unsubscribeCallback(this); + } + } + finally + { + _semaphoreSlim.Release(); + } + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelDisconnectEventListener.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelDisconnectEventListener.cs new file mode 100644 index 000000000..c1927fdcd --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/PrivateChannelDisconnectEventListener.cs @@ -0,0 +1,90 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using Finos.Fdc3; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; + +internal delegate void PrivateChannelDisconnectEventHandler(); +internal delegate void PrivateChannelUnsubscribeHandler(PrivateChannelDisconnectEventListener listener); + +internal class PrivateChannelDisconnectEventListener : IListener +{ + private readonly PrivateChannelDisconnectEventHandler _onDisconnect; + private readonly PrivateChannelUnsubscribeHandler _onUnsubscribe; + private readonly ILogger _logger; + private readonly SemaphoreSlim _semaphoreSlim = new(1, 1); + private bool _subscribed; + + public PrivateChannelDisconnectEventListener( + PrivateChannelDisconnectEventHandler onDisconnectEventHandler, + PrivateChannelUnsubscribeHandler onUnsubscribeHandler, + ILogger? logger = null) + { + _onDisconnect = onDisconnectEventHandler; + _onUnsubscribe = onUnsubscribeHandler; + _subscribed = true; + _logger = logger ?? NullLogger.Instance; + } + + public void Execute() + { + try + { + _semaphoreSlim.Wait(); + + if (!_subscribed) + { + return; + } + + _onDisconnect(); + } + finally + { + _semaphoreSlim.Release(); + } + } + + public void Unsubscribe() + { + UnsubscribeCore(true); + } + + internal void UnsubscribeCore(bool doCallback) + { + try + { + _semaphoreSlim.Wait(); + + _subscribed = false; + + if (doCallback) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Unsubscribing {NameOfPrivateChannelDisconnectEventHandler}.", nameof(PrivateChannelDisconnectEventHandler)); + } + + _onUnsubscribe(this); + } + } + finally + { + _semaphoreSlim.Release(); + } + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Channel.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/Channel.cs similarity index 89% rename from src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Channel.cs rename to src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/Channel.cs index 345cc5b23..867bd6366 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Channel.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/Channel.cs @@ -32,10 +32,10 @@ internal class Channel : IChannel private readonly ChannelType _channelType; private readonly string _instanceId; private readonly IMessaging _messaging; + private readonly IContext? _openedAppContext; private readonly DisplayMetadata? _displayMetadata; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; - private readonly SemaphoreSlim _lastContextLock = new(1,1); private IContext? _lastContext = null; private readonly ConcurrentDictionary _lastContexts = new(); @@ -46,6 +46,7 @@ public Channel( ChannelType channelType, IMessaging messaging, string instanceId, + IContext? openedAppContext = null, DisplayMetadata? displayMetadata = null, ILoggerFactory? loggerFactory = null) { @@ -53,6 +54,7 @@ public Channel( _channelType = channelType; _instanceId = instanceId; _messaging = messaging; + _openedAppContext = openedAppContext; _displayMetadata = displayMetadata; _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; _logger = _loggerFactory.CreateLogger(); @@ -64,20 +66,25 @@ public Channel( public IDisplayMetadata? DisplayMetadata => _displayMetadata; - public async Task AddContextListener(string? contextType, ContextHandler handler) where T : IContext + protected ILoggerFactory LoggerFactory => _loggerFactory; + protected IMessaging Messaging => _messaging; + protected string InstanceId => _instanceId; + + public virtual async Task AddContextListener(string? contextType, ContextHandler handler) where T : IContext { var listener = new ContextListener( instanceId: _instanceId, contextHandler: handler, messaging: _messaging, contextType: contextType, + openedAppContext: _openedAppContext, logger: _loggerFactory.CreateLogger>()); await listener.SubscribeAsync(_channelId, _channelType); return listener; } - public async Task Broadcast(IContext context) + public virtual async Task Broadcast(IContext context) { try { @@ -101,7 +108,7 @@ await _messaging.PublishJsonAsync( } } - public async Task GetCurrentContext(string? contextType) + public virtual async Task GetCurrentContext(string? contextType) { try { diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/IntentResolution.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/IntentResolution.cs new file mode 100644 index 000000000..a90951d74 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/IntentResolution.cs @@ -0,0 +1,109 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System.Text.Json; +using Finos.Fdc3; +using Finos.Fdc3.Context; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Messaging.Abstractions; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol; + +internal class IntentResolution : IIntentResolution +{ + private readonly string _messageId; + private readonly IMessaging _messaging; + private readonly IChannelFactory _channelFactory; + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + + public IntentResolution( + string messageId, + IMessaging messaging, + IChannelFactory channelFactory, + string intent, + IAppIdentifier source, + ILogger? logger = null) + { + _messageId = messageId; + _messaging = messaging; + _channelFactory = channelFactory; + Intent = intent; + Source = source; + _logger = logger ?? NullLogger.Instance; + } + + public IAppIdentifier Source { get; } + + private readonly ILogger _logger; + + public string Intent { get; } + + public string? Version { get; } + + public async Task GetResult() + { + var request = new GetIntentResultRequest + { + MessageId = _messageId, + TargetAppIdentifier = new AppIdentifier + { + AppId = Source.AppId, + InstanceId = Source.InstanceId + }, + Intent = Intent, + Version = Version + }; + + var response = await _messaging.InvokeJsonServiceAsync( + Fdc3Topic.GetIntentResult, + request, + _jsonSerializerOptions); + + if (response == null) + { + throw ThrowHelper.MissingResponse(); + } + + if (!string.IsNullOrEmpty(response.Error)) + { + throw ThrowHelper.ErrorResponseReceived(response.Error); + } + + if (!string.IsNullOrEmpty(response.ChannelId) + && response.ChannelType != null) + { + var channel = await _channelFactory.FindChannelAsync(response.ChannelId!, response.ChannelType.Value); + return channel; + } + else if (!string.IsNullOrEmpty(response.Context)) + { + var context = JsonSerializer.Deserialize(response.Context!, _jsonSerializerOptions); + return context; + } + else if (response.VoidResult != null + && response.VoidResult.Value) + { + _logger.LogDebug("The intent result is void for intent:{Intent} for message: {MessageId}.", Intent, _messageId); + + return null; + } + + throw ThrowHelper.IntentResolutionFailed(Intent, _messageId, Source); + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannel.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannel.cs new file mode 100644 index 000000000..a8fca2423 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannel.cs @@ -0,0 +1,554 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System.Text.Json; +using Finos.Fdc3; +using Finos.Fdc3.Context; +using Microsoft.Extensions.Logging; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Messaging.Abstractions; +using DisplayMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.DisplayMetadata; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol; + +internal class PrivateChannel : Channel, IPrivateChannel, IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly PrivateChannelTopics _privateChannelTopics; + private readonly Action _onDisconnect; + private readonly string _internalEventsTopic; + private readonly bool _isOriginalCreator; + private readonly string _remoteContextListenersService; + private readonly TaskCompletionSource _initializationTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly List _contextHandlers = new(); + private readonly List _addContextListenerHandlers = new(); + private readonly List _unsubscribeHandlers = new(); + private readonly List _disconnectHandlers = new(); + + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + + private IAsyncDisposable _subscription; + private IAsyncDisposable _serviceRegistration; + private bool _isDisconnected = false; + + public PrivateChannel( + string channelId, + IMessaging messaging, + string instanceId, + bool isOriginalCreator, + Action onDisconnect, + DisplayMetadata? displayMetadata = null, + ILoggerFactory? loggerFactory = null) + : base(channelId, ChannelType.Private, messaging, instanceId, null, displayMetadata, loggerFactory) + { + _privateChannelTopics = Fdc3Topic.PrivateChannel(channelId); + _internalEventsTopic = _privateChannelTopics.Events; + _isOriginalCreator = isOriginalCreator; + _remoteContextListenersService = _privateChannelTopics.GetContextHandlers(!isOriginalCreator); + _onDisconnect = onDisconnect; + _logger = LoggerFactory.CreateLogger(); + + Task.Run(() => InitializeAsync()); + } + + private async ValueTask InitializeAsync() + { + try + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Subscribing to private channel internal events for channel {ChannelId}, instance {InstanceId}, topic: {Topic}.", Id, InstanceId, _internalEventsTopic); + } + + _subscription = await Messaging.SubscribeAsync(_internalEventsTopic, HandleInternalEvent); + + + var topic = _privateChannelTopics.GetContextHandlers(_isOriginalCreator); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Registering remote private channel context handlers service for channel {ChannelId}, instance {InstanceId}, service: {Service}.", Id, InstanceId, topic); + } + + _serviceRegistration = await Messaging.RegisterServiceAsync(topic, HandleRemoteContextListener); + + _initializationTaskCompletionSource.SetResult(Id); + } + catch (Exception exception) + { + _initializationTaskCompletionSource.SetException(exception); + } + } + + public override async Task Broadcast(IContext context) + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + try + { + await _lock.WaitAsync().ConfigureAwait(false); + + if (_isDisconnected) + { + throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId); + } + + await base.Broadcast(context); + } + finally + { + _lock.Release(); + } + } + + public override async Task AddContextListener(string? contextType, ContextHandler handler) + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + try + { + await _lock.WaitAsync().ConfigureAwait(false); + + if (_isDisconnected) + { + throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId); + } + + var listener = await base.AddContextListener(contextType, handler) as ContextListener; + + if (listener != null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Setting unsubscribe callback for {ListenerType} when adding the listener on the {ChannelType}...", nameof(ContextListener), nameof(PrivateChannel)); + } + + listener.SetUnsubscribeCallback(contextListener => RemoveContextHandler(contextListener)); + + _contextHandlers.Add(listener); + + _ = FireContextHandlerAdded(contextType); + + return listener; + } + + throw ThrowHelper.PrivatChannelSubscribeFailure(contextType, Id, InstanceId); + } + finally + { + _lock.Release(); + } + } + + public async void Disconnect() + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + try + { + await _lock.WaitAsync().ConfigureAwait(false); + + if (_isDisconnected) + { + return; + } + + _isDisconnected = true; + foreach (var handler in _addContextListenerHandlers) + { + handler.UnsubscribeCore(false); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Unsubscribed add context listener handler on private channel {ChannelId}.", Id); + } + } + + foreach (var handler in _unsubscribeHandlers) + { + handler.UnsubscribeCore(false); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Unsubscribed unsubscribe handler on private channel {ChannelId}.", Id); + } + } + + foreach (var handler in _disconnectHandlers) + { + handler.UnsubscribeCore(false); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Unsubscribed disconnect handler on private channel {ChannelId}.", Id); + } + } + + _addContextListenerHandlers.Clear(); + _unsubscribeHandlers.Clear(); + _disconnectHandlers.Clear(); + + foreach (var listener in _contextHandlers) + { + //Fire and forget + listener.Unsubscribe(); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Unsubscribed context listener on private channel {ChannelId}.", Id); + } + } + + var request = PrivateChannelInternalEvents.Disconnected(InstanceId); + var serializedRequest = JsonSerializer.Serialize(request, _jsonSerializerOptions); + + await Messaging.PublishAsync(_internalEventsTopic, serializedRequest); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Private channel {ChannelId} disconnected. Request: {Request} has been sent to the backend...", Id, serializedRequest); + } + + _onDisconnect(); + } + finally + { + _lock.Release(); + } + } + + public IListener OnAddContextListener(Action handler) + { + try + { + _lock.Wait(); + + if (_isDisconnected) + { + throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId); + } + + var listener = new PrivateChannelContextListenerEventListener( + onContext: (contextType) => handler(contextType), + onUnsubscribe: RemoveAddContextListenerHandler, + logger: LoggerFactory.CreateLogger()); + + _addContextListenerHandlers.Add(listener); + + //Fire and forget + _ = ExecuteForRemoteContextHandlers(listener); + + return listener; + } + finally + { + _lock.Release(); + } + } + + public IListener OnDisconnect(Action handler) + { + try + { + _lock.Wait(); + + if (_isDisconnected) + { + throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId); + } + + var listener = new PrivateChannelDisconnectEventListener( + onDisconnectEventHandler: () => handler(), + onUnsubscribeHandler: RemoveDisconnectListenerHandler, + logger: LoggerFactory.CreateLogger()); + + _disconnectHandlers.Add(listener); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Added disconnect handler on private channel {ChannelId}.", Id); + } + + return listener; + } + finally + { + _lock.Release(); + } + } + + public IListener OnUnsubscribe(Action handler) + { + try + { + _lock.Wait(); + + if (_isDisconnected) + { + throw ThrowHelper.PrivateChannelDisconnected(Id, InstanceId); + } + + var listener = new PrivateChannelContextListenerEventListener( + onContext: (contextType) => handler(contextType), + onUnsubscribe: RemoveUnsubscribeListenerHandler, + logger: LoggerFactory.CreateLogger()); + + _unsubscribeHandlers.Add(listener); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Added unsubscribe handler on private channel {ChannelId}.", Id); + } + + return listener; + } + finally + { + _lock.Release(); + } + } + + private async ValueTask ExecuteForRemoteContextHandlers(PrivateChannelContextListenerEventListener listener) + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + try + { + var listeners = await Messaging.InvokeServiceAsync(_remoteContextListenersService); + + var remoteListeners = string.IsNullOrEmpty(listeners) + ? Array.Empty() + : JsonSerializer.Deserialize>(listeners!, _jsonSerializerOptions); + + foreach (var contextType in remoteListeners ?? Array.Empty()) + { + listener.Execute(contextType); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Executed remote context handler for context type {ContextType} on private channel {ChannelId} for remote listener.", contextType, Id); + } + } + } + catch (Exception exception) + { + _logger.LogError(exception, "Error executing remote context handlers on private channel: {ChannelId}, {InstanceId}.", Id, InstanceId); + return; + } + } + + private void RemoveUnsubscribeListenerHandler(PrivateChannelContextListenerEventListener listener) + { + try + { + _lock.Wait(); + + if (!_unsubscribeHandlers.Remove(listener)) + { + _logger.LogWarning("The {ListenerType} to remove was not found in the list of registered {ContextListenerType}.", nameof(PrivateChannelContextListenerEventListener), nameof(PrivateChannelContextListenerEventListener)); + } + } + finally + { + _lock.Release(); + } + } + + private void RemoveDisconnectListenerHandler(PrivateChannelDisconnectEventListener listener) + { + try + { + _lock.Wait(); + + if (!_disconnectHandlers.Remove(listener)) + { + _logger.LogWarning("The {ListenerType} to remove was not found in the list of registered {DisconnectListenerType}.", nameof(PrivateChannelDisconnectEventListener), nameof(PrivateChannelDisconnectEventListener)); + } + } + finally + { + _lock.Release(); + } + } + + private void RemoveAddContextListenerHandler(PrivateChannelContextListenerEventListener privateChannelContextListener) + { + try + { + _lock.Wait(); + + if (!_addContextListenerHandlers.Remove(privateChannelContextListener)) + { + _logger.LogWarning("The {ListenerType} to remove was not found in the list of registered {ContextListenerType}.", nameof(PrivateChannelContextListenerEventListener), nameof(PrivateChannelContextListenerEventListener)); + } + } + finally + { + _lock.Release(); + } + } + + private async ValueTask FireContextHandlerAdded(string? contextType) + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + var request = new PrivateChannelInternalEvents + { + ContextType = contextType, + Event = PrivateChannelInternalEventType.ContextListenerAdded, + InstanceId = InstanceId + }; + + var serializedRequest = JsonSerializer.Serialize(request, _jsonSerializerOptions); + + await Messaging.PublishAsync(_internalEventsTopic, serializedRequest); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Fired context handler added event for context type {ContextType} on private channel {ChannelId}. Request has been sent to backend: {Request}.", contextType, Id, serializedRequest); + } + } + + private void RemoveContextHandler(ContextListener contextListener) where T : IContext + { + try + { + _lock.Wait(); + + if (!_isDisconnected) + { + if (_contextHandlers.Remove(contextListener)) + { + _logger.LogWarning("The context listener for context type {ContextType} was removed from private channel {ChannelId}.", contextListener.ContextType, Id); + } + } + //Fire and forget + _ = FireUnsubscribed(contextListener.ContextType); + } + finally + { + _lock.Release(); + } + } + + private async ValueTask FireUnsubscribed(string? contextType) + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + var request = new PrivateChannelInternalEvents + { + ContextType = contextType, + Event = PrivateChannelInternalEventType.Unsubscribed, + InstanceId = InstanceId + }; + + var serializedRequest = JsonSerializer.Serialize(request, _jsonSerializerOptions); + + await Messaging.PublishAsync(_internalEventsTopic, serializedRequest); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Fired unsubscribed event for context type {ContextType} on private channel {ChannelId}. Request has been sent to backend: {Request}.", contextType, Id, serializedRequest); + } + } + + private async ValueTask HandleRemoteContextListener(string? request) + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + try + { + await _lock.WaitAsync().ConfigureAwait(false); + + var contextListeners = _contextHandlers.AsEnumerable(); + + return JsonSerializer.Serialize(contextListeners, _jsonSerializerOptions); + } + finally + { + _lock.Release(); + } + } + + private async ValueTask HandleInternalEvent(string payload) + { + try + { + await _lock.WaitAsync().ConfigureAwait(false); + + if (_isDisconnected + || string.IsNullOrEmpty(payload)) + { + return; + } + + var internalEvent = JsonSerializer.Deserialize(payload, _jsonSerializerOptions); + + if (internalEvent == null) + { + _logger.LogWarning("Received invalid internal event on private channel {ChannelId}: {Payload}", Id, payload); + return; + } + + if (internalEvent.InstanceId == InstanceId) + { + // Ignore events originating from this instance + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Ignoring internal event from same instance {InstanceId} on private channel {ChannelId}. Event: {Payload}", InstanceId, Id, payload); + } + + return; + } + + if (internalEvent.Event == PrivateChannelInternalEventType.ContextListenerAdded) + { + foreach (var handler in _addContextListenerHandlers) + { + handler.Execute(internalEvent.ContextType); + } + } + else if (internalEvent.Event == PrivateChannelInternalEventType.Unsubscribed) + { + foreach (var handler in _unsubscribeHandlers) + { + handler.Execute(internalEvent.ContextType); + } + } + else if (internalEvent.Event == PrivateChannelInternalEventType.Disconnected) + { + foreach (var handler in _disconnectHandlers) + { + handler.Execute(); + } + } + } + finally + { + _lock.Release(); + } + } + + public async ValueTask DisposeAsync() + { + await _initializationTaskCompletionSource.Task.ConfigureAwait(false); + + Disconnect(); + await _serviceRegistration.DisposeAsync().ConfigureAwait(false); + await _subscription.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannelInternalEventType.cs b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannelInternalEventType.cs new file mode 100644 index 000000000..75071a787 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/Infrastructure/Internal/Protocol/PrivateChannelInternalEventType.cs @@ -0,0 +1,22 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol; + +internal static class PrivateChannelInternalEventType +{ + public static string ContextListenerAdded = "contextListenerAdded"; + public static string Unsubscribed = "unsubscribed"; + public static string? Disconnected = "disconnected"; +} \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.csproj b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.csproj index b03a6e4f8..855565069 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.csproj +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.csproj @@ -2,7 +2,7 @@ netstandard2.0 - .Net Standard 2.0 Native client for FDC3 operations implementing the IDesktopAgentClient from the Finos.Fdc3 NuGet package. More Details: https://morganstanley.github.io/ComposeUI/ + .Net Standard 2.0 Native client for FDC3 operations implementing the IDesktopAgent from the Finos.Fdc3 NuGet package. More Details: https://morganstanley.github.io/ComposeUI/ true diff --git a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/README.md b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/README.md index 4544691ae..c88b419fc 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/README.md +++ b/src/fdc3/dotnet/DesktopAgent.Client/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client/README.md @@ -9,6 +9,22 @@ It enables .NET applications to interact with FDC3-compliant desktop agents, fac - Integrates with ComposeUI messaging abstraction (`IMessaging`), so it doesn't rely on any actual messaging implementation. - Extensible logging support via `ILogger`. + +## Target Framework +- .NET Standard 2.0 + +Compatible with .NET (Core), .NET Framework. + + +## Dependencies +- [Finos.Fdc3](https://www.nuget.org/packages/Finos.Fdc3) +- [Finos.Fdc3.AppDirectory](https://www.nuget.org/packages/Finos.Fdc3.AppDirectory) +- [Microsoft.Extensions.Logging.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.Logging.Abstractions) +- [System.Text.Json](https://www.nuget.org/packages/System.Text.Json) +- [MorganStanley.ComposeUI.Messaging.Abstractions](https://www.nuget.org/packages/MorganStanley.ComposeUI.Messaging.Abstractions) +- [MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared](https://www.nuget.org/packages/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared) + + ## Installation Add a reference to the NuGet package (if available) or include the project in your solution. @@ -131,6 +147,97 @@ You can get the metadata of an app using the GetAppMetadata method by providing var appMetadata = await desktopAgent.GetAppMetadata(new AppIdentifier("your-app-id", "your-instance-id")); ``` +### Getting User Channels +You can retrieve user channels by using: +```csharp +var channels = await desktopAgent.GetUserChannels(); +await desktopAgent.JoinUserChannel(channels[0].Id); +``` + +### Getting or Creating a App Channel +You can get or create an app channel by using: +```csharp +var appChannel = await desktopAgent.GetOrCreateChannel("your-app-channel-id"); +var listener = await appChannel.AddContextListener("fdc3.instrument", (ctx, ctxMetadata) => +{ + Console.WriteLine($"Received context on app channel: {ctx}"); +}); + +//Initiator shouldn't receive back the broadcasted context +await appChannel.Broadcast(context); +``` + +### Finding apps based on the specified intent +You can find/search for applications from the AppDirectory by using the `FindIntent` function: +```csharp +var apps = await desktopAgent.FindIntent("ViewChart", new Instrument(new InstrumentID { Ticker = "AAPL" }), "expected_resultType")); +``` + +### Finding instances for the specified app +You can find the currently FDC3 enabled instances for the specified app by using the `FindInstances` function: +```csharp +var instances = await desktopAgent.FindInstances("your-app-id"); +``` + +### Finding intents by context +You can find the apps that can handle for the specified context by using the `FindIntentsByContext` function: +```csharp +var context = new Instrument(new InstrumentID { Ticker = "AAPL" }, "Apple Inc."); +var appIntents = await desktopAgent.FindIntentsByContext(context); +``` + +### Raising intent for context +You can raise an intent for the specified context by using the `RaiseIntentForContext` function and return its result by using the `GetResult` of the returned `IIntentResolution`: +```csharp +var intentResolution = await desktopAgent.RaiseIntentForContext(context, appIdentifier); +var intentResult = await intentResolution.GetResult(); +``` + +### Adding Intent Listener +You can register an intent listener by using the `AddIntentListener` function: +```csharp +var currentChannel = await desktopAgent.GetCurrentChannel(); +var listener = await desktopAgent.AddIntentListener("ViewChart", async (ctx, ctxMetadata) => +{ + Console.WriteLine($"Received intent with context: {ctx}"); + return currentChannel; +}); +``` + +### Raising intents +You can raise an intent by using the `RaiseIntent` function and return its result by using the `GetResult` of the returned `IIntentResolution`: +```csharp +var intentResolution = await desktopAgent.RaiseIntent("ViewChart", context, appIdentifier); +var intentResult = await intentResolution.GetResult(); +``` + +### Opening an app +You can open an app by using the `Open` function: +```csharp +var appIdentifier = new AppIdentifier("your-app-id"); +var instrument = new Instrument(); + +var appInstance = await desktopAgent.Open(appIdentifier, instrument); +//The opened app should handle the context if it has registered a listener for that context type; if it does not register its context listener in time the open call will fail +``` + +### Creating Private Channel +You can create a private channel by using the `CreatePrivateChannel` function: +```csharp +var privateChannel = await desktopAgent.CreatePrivateChannel("your-private-channel-id"); +var contextListenerHandler = privateChannel.OnAddContextListener((ctx) => { + Console.WriteLine($"Private channel context listener has been added for context: {ctx}"); +}); + +var unsubscribeHandler = privateChannel.OnUnsubscribe((ctx) => { + Console.WriteLine($"Private channel context listener has been unsubscribed for context: {ctx}"); +}); + +var disconnectHandler = privateChannel.OnDisconnect(() => { + Console.WriteLine("Private channel has been disconnected"); +}); +``` + ## Documentation For more details, see the [ComposeUI documentation](https://morganstanley.github.io/ComposeUI/). diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs new file mode 100644 index 000000000..7bda5af23 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/EndToEndTests.cs @@ -0,0 +1,559 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MorganStanley.ComposeUI.Messaging.Client.WebSocket; +using MorganStanley.ComposeUI.ModuleLoader; +using Finos.Fdc3; +using FluentAssertions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using Finos.Fdc3.Context; +using MorganStanley.ComposeUI.Fdc3.AppDirectory; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol; +using DisplayMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.DisplayMetadata; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests.Helpers; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests; + +public class EndToEndTests : IAsyncLifetime +{ + private const string TestChannel = "fdc3.channel.1"; + + private readonly Uri _webSocketUri = new("ws://localhost:7098/ws"); + private ServiceProvider _clientServices; + private IHost _host; + private IModuleLoader _moduleLoader; + private readonly object _runningAppsLock = new(); + private readonly List _runningApps = []; + private IDisposable _runningAppsObserver; + private IDesktopAgent _desktopAgent; + public EndToEndTests() + { + var repoRoot = RootPathResolver.GetRepositoryRoot(); + Environment.SetEnvironmentVariable(Consts.COMPOSEUI_MODULE_REPOSITORY_ENVIRONMENT_VARIABLE_NAME, repoRoot, EnvironmentVariableTarget.Process); + } + + public async Task InitializeAsync() + { + Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), null); + Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), null); + + // Create the backend side + IHostBuilder hostBuilder = new HostBuilder(); + hostBuilder.ConfigureServices( + serviceCollection => + { + serviceCollection.AddMessageRouterServer( + s => s.UseWebSockets( + opt => + { + opt.RootPath = _webSocketUri.AbsolutePath; + opt.Port = _webSocketUri.Port; + })); + + serviceCollection.AddTransient(); + + serviceCollection.AddMessageRouter( + mr => mr + .UseServer()); + + serviceCollection.AddFdc3AppDirectory( + _ => _.Source = new Uri(@$"file:\\{Directory.GetCurrentDirectory()}\testAppDirectory.json")); + + serviceCollection.AddModuleLoader(); + + serviceCollection.AddFdc3DesktopAgent( + fdc3 => + { + fdc3.Configure(builder => + { + builder.ChannelId = TestChannel; + builder.IntentResultTimeout = TimeSpan.FromSeconds(3); + builder.ListenerRegistrationTimeout = TimeSpan.FromSeconds(3); + }); + }); + + serviceCollection.AddMessageRouterMessagingAdapter(); + }); + + _host = hostBuilder.Build(); + await _host.StartAsync(); + + // Create a client acting in place of an application + _clientServices = new ServiceCollection() + .AddMessageRouter( + mr => mr.UseWebSocket( + new MessageRouterWebSocketOptions + { + Uri = _webSocketUri + })) + .AddMessageRouterMessagingAdapter() + .AddFdc3DesktopAgentClient() + .BuildServiceProvider(); + + _moduleLoader = _host.Services.GetRequiredService(); + + _runningAppsObserver = _moduleLoader.LifetimeEvents.Subscribe( + lifetimeEvent => + { + lock (_runningAppsLock) + { + switch (lifetimeEvent.EventType) + { + case LifetimeEventType.Started: + _runningApps.Add(lifetimeEvent.Instance); + break; + + case LifetimeEventType.Stopped: + _runningApps.Remove(lifetimeEvent.Instance); + break; + } + } + }); + + var instanceId = Guid.NewGuid().ToString(); + + var instance = await _moduleLoader.StartModule(new StartRequest("appId1", new Dictionary() { { "Fdc3InstanceId", instanceId } })); + + Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), "appId1"); + Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), instanceId); + + _desktopAgent = _clientServices.GetRequiredService(); + } + + public async Task DisposeAsync() + { + List runningApps; + _runningAppsObserver?.Dispose(); + lock (_runningAppsLock) + { + runningApps = _runningApps.Reverse().ToList(); + } + + foreach (var instance in runningApps) + { + await _moduleLoader.StopModule(new StopRequest(instance.InstanceId)); + } + + await _clientServices.DisposeAsync(); + await _host.StopAsync(); + _host.Dispose(); + + Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), null); + Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), null); + } + + [Fact] + public async Task GetAppMetadata_throws_error_as_error_response_received() + { + var action = async () => await _desktopAgent.GetAppMetadata(new AppIdentifier { AppId = "nonExistingApp" }); + await action.Should() + .ThrowAsync() + .WithMessage("*nonExistingApp*"); + } + + [Fact] + public async Task GetAppMetadata_returns_AppMetadata() + { + var result = await _desktopAgent.GetAppMetadata(app: new AppIdentifier { AppId = "appId1" }); + result.Should().BeEquivalentTo(TestAppDirectoryData.DefaultApp1); + } + + [Fact] + public async Task GetInfo_returns_ImplementationMetadata() + { + var result = await _desktopAgent.GetInfo(); + + result.AppMetadata.Should().NotBeNull(); + result.AppMetadata.AppId.Should().Be("appId1"); + } + + [Fact] + public async Task GetInfo_throws_error_as_instance_id_not_found() + { + Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), null); + Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), null); + + Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), "nonExistentAppId"); + Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), Guid.NewGuid().ToString()); + + var desktopAgent = _clientServices.GetRequiredService(); + + var action = async () => await desktopAgent.GetInfo(); + + await action.Should() + .ThrowAsync() + .WithMessage($"*{Fdc3DesktopAgentErrors.MissingId}*"); + } + + [Fact] + public async Task JoinUserChannel_joins_to_a_user_channel() + { + await _desktopAgent.JoinUserChannel("fdc3.channel.1"); + var currentChannel = await _desktopAgent.GetCurrentChannel(); + + currentChannel.Id.Should().Be("fdc3.channel.1"); + } + + [Fact] + public async Task JoinUserChannel_joins_to_a_user_channel_and_registers_already_added_top_level_context_listeners() + { + var resultContexts = new List(); + + var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. The app broadcasts an instrument context after it joined to the fdc3.channel.1. + //We need to wait somehow for the module to finish up the broadcast + await Task.Delay(2000); + + var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); + var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); + + await _desktopAgent.JoinUserChannel("fdc3.channel.1"); + var currentChannel = await _desktopAgent.GetCurrentChannel(); + + currentChannel.Id.Should().Be("fdc3.channel.1"); + + resultContexts.Should().HaveCount(2); + } + + [Fact] + public async Task LeaveCurrentChannel_leaves_the_joined_channel() + { + await _desktopAgent.JoinUserChannel("fdc3.channel.1"); + var currentChannel = await _desktopAgent.GetCurrentChannel(); + + currentChannel.Id.Should().Be("fdc3.channel.1"); + + await _desktopAgent.LeaveCurrentChannel(); + currentChannel = await _desktopAgent.GetCurrentChannel(); + + currentChannel.Should().BeNull(); + } + + [Fact] + public async Task AddContextListener_can_be_registered_multiple_times() + { + var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { }); + var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { }); + + listener1.Should().NotBeNull(); + listener2.Should().NotBeNull(); + } + + [Fact] + public async Task AddContextListener_can_be_registered_but_they_do_not_receive_anything_until_they_joined_to_a_channel() + { + var resultContexts = new List(); + + var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. The app broadcasts an instrument context after it joined to the fdc3.channel.1. + var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); + var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); + + listener1.Should().NotBeNull(); + listener2.Should().NotBeNull(); + resultContexts.Should().HaveCount(0); + } + + [Fact] + public async Task Broadcast_is_not_retrieved_to_the_same_instance() + { + var resultContexts = new List(); + + var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. The app broadcasts an instrument context after it joined to the fdc3.channel.1. + //We need to wait somehow for the module to finish up the broadcast + await Task.Delay(2000); + + await _desktopAgent.JoinUserChannel("fdc3.channel.1"); + + var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); + var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); + var currentChannel = await _desktopAgent.GetCurrentChannel(); + + await _desktopAgent.Broadcast(new Instrument(new InstrumentID { Ticker = $"test-instrument-{Guid.NewGuid().ToString()}" }, "test-name")); + + currentChannel.Id.Should().Be("fdc3.channel.1"); + resultContexts.Should().HaveCount(2); //not 4 + } + + [Fact] + public async Task Broadcast_fails_on_not_joined_to_channel() + { + var action = async () => await _desktopAgent.Broadcast(new Instrument(new InstrumentID { Ticker = $"test-instrument-{Guid.NewGuid().ToString()}" }, "test-name")); + + await action.Should() + .ThrowAsync() + .WithMessage($"*No current channel to broadcast the context to.*"); + } + + [Fact] + public async Task GetUserChannels_returns_UserChannels() + { + var result = await _desktopAgent.GetUserChannels(); + result.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task GetOrCreateChannel_returns_channel() + { + var result = await _desktopAgent.GetOrCreateChannel("app-channel-1"); + + result.Id.Should().Be("app-channel-1"); + result.Type.Should().Be(ChannelType.App); + } + + [Fact] + public async Task GetOrCreateChannel_throws_error_as_error_response_received() + { + var action = async () => await _desktopAgent.GetOrCreateChannel(string.Empty); + await action.Should() + .ThrowAsync() + .WithMessage($"*{ChannelError.CreationFailed}*"); + } + + [Fact] + public async Task GetOrCreateChannel_creates_channel_and_client_able_to_broadcast_and_receive_context() + { + var resultContexts = new List(); + + var appChannel = await _desktopAgent.GetOrCreateChannel("app-channel-1"); + + var listener = await appChannel.AddContextListener("fdc3.instrument", (context, metadata) => + { + resultContexts.Add(context); + }); + + var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only + //We need to wait somehow for the module to finish up the broadcast + await Task.Delay(2000); + + await appChannel.Broadcast(new Instrument(new InstrumentID { Ticker = $"test-instrument-1" }, "test-name1")); + + appChannel.Id.Should().Be("app-channel-1"); + + resultContexts.Should().BeEquivalentTo(new List() { new Instrument(new InstrumentID { Ticker = $"test-instrument-2" }, "test-name2") }); + } + + [Fact] + public async Task FindIntent_returns_AppIntent() + { + var result = await _desktopAgent.FindIntent("intent1"); + + result.Apps.Should().HaveCount(2); + + result.Apps.First().AppId.Should().Be("appId1"); + result.Apps.First().InstanceId.Should().BeNull(); + + //We are starting the app1 instance for each tests, so the second app in the list should have the instanceId defined + result.Apps.ElementAt(1).AppId.Should().Be("appId1"); + result.Apps.ElementAt(1).InstanceId.Should().NotBeNull(); + } + + [Fact] + public async Task FindIntent_throws_when_intent_not_found() + { + var act = async () => await _desktopAgent.FindIntent("notExistent"); + + await act.Should().ThrowAsync() + .WithMessage($"*{ResolveError.NoAppsFound}*"); + } + + [Fact] + public async Task FindInstances_returns_AppIdentifier() + { + var result = await _desktopAgent.FindInstances(new AppIdentifier { AppId = "appId1" }); + + result.Should().HaveCount(1); + } + + [Fact] + public async Task FindInstances_throws_error_when_app_not_found() + { + var act = async () => await _desktopAgent.FindInstances(new AppIdentifier { AppId = "notExistent" }); + + await act.Should().ThrowAsync() + .WithMessage($"*{ResolveError.NoAppsFound}*"); + } + + [Fact] + public async Task FindIntentsByContext_returns_AppIdentifier() + { + var context = new Nothing(); + + var result = await _desktopAgent.FindIntentsByContext(context); + result.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task FindIntentsByContext_throws_when_not_app_can_handle_the_context() + { + var context = new Currency(new CurrencyID { CURRENCY_ISOCODE = "USD" }); + + var act = async () => await _desktopAgent.FindIntentsByContext(context); + + await act.Should().ThrowAsync() + .WithMessage($"*{ResolveError.NoAppsFound}*"); + } + + [Fact] + public async Task RaiseIntentForContext_raises_intent_and_got_resolved_as_channel() + { + var fdc3InstanceId = Guid.NewGuid().ToString(); + var appId = "appId1-native"; + var module = await _moduleLoader.StartModule(new StartRequest(appId, new Dictionary() { { "Fdc3InstanceId", fdc3InstanceId } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only + //We need to wait somehow for the module to finish up the listener registration + await Task.Delay(2000); + + var result = await _desktopAgent.RaiseIntentForContext(new Instrument(new InstrumentID { Ticker = "AAPL" }, "Apple Inc."), new AppIdentifier { AppId = appId, InstanceId = fdc3InstanceId }); + result.Should().NotBeNull(); + + var channel = await result.GetResult(); + + channel.Should().BeAssignableTo(); + } + + [Fact] + public async Task RaiseIntentForContext_raises_intent_throws_when_no_app_could_handle_the_request() + { + var act = async () => await _desktopAgent.RaiseIntentForContext(new Chart(new Instrument[] { new Instrument() })); + + await act.Should().ThrowAsync() + .WithMessage($"*{ResolveError.NoAppsFound}*"); + } + + [Fact] + public async Task RaiseIntentForContext_raises_intent_throws_when_listener_not_registered() + { + var fdc3InstanceId = Guid.NewGuid().ToString(); + var appId = "appId1-native"; + var module = await _moduleLoader.StartModule(new StartRequest(appId, new Dictionary() { { "Fdc3InstanceId", fdc3InstanceId } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only + //We need to wait somehow for the module to finish up the listener registration + await Task.Delay(2000); + + var act = async () => await _desktopAgent.RaiseIntentForContext(new Country(), new AppIdentifier { AppId = appId, InstanceId = fdc3InstanceId }); + + await act.Should().ThrowAsync() + .WithMessage($"*{ResolveError.IntentDeliveryFailed}*"); + } + + [Fact] + public async Task AddIntentListener_able_to_handle_raised_intent() + { + var handledContexts = new List(); + + var listener = await _desktopAgent.AddIntentListener("TestInstrument", (ctx, ctxM) => + { + handledContexts.Add(ctx); + return Task.FromResult(null!); + }); + + var module = await _moduleLoader.StartModule( + new StartRequest( + "appId1-native", + new Dictionary() + { + { "Fdc3InstanceId", Guid.NewGuid().ToString() }, + })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only! + + await Task.Delay(3000); //We need to wait somehow for the module to finish up its actions + + handledContexts.Should().HaveCount(1); + } + + [Fact] + public async Task RaiseIntent_raises_intent_and_returns_IntentResolution() + { + var fdc3InstanceId = Guid.NewGuid().ToString(); + var appId = "appId1-native"; + var module = await _moduleLoader.StartModule(new StartRequest(appId, new Dictionary() { { "Fdc3InstanceId", fdc3InstanceId } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only + //We need to wait somehow for the module to finish up the listener registration + await Task.Delay(2000); + + var resolution = await _desktopAgent.RaiseIntent("ViewInstrument", new Instrument(new InstrumentID { Ticker = "AAPL" }, "Apple Inc."), new AppIdentifier { AppId = appId, InstanceId = fdc3InstanceId }); + resolution.Should().NotBeNull(); + + var result = await resolution.GetResult(); + + result.Should().BeAssignableTo(); + } + + [Fact] + public async Task RaiseIntent_throws_as_no_apps_found_to_handle() + { + var act = async () => await _desktopAgent.RaiseIntent("notExistentIntent", new Instrument(new InstrumentID { Ticker = "AAPL" }, "Apple Inc.")); + + await act.Should().ThrowAsync() + .WithMessage($"*{ResolveError.NoAppsFound}*"); + } + + [Fact] + public async Task RaiseIntent_throws_when_no_listener_registered() + { + var fdc3InstanceId = Guid.NewGuid().ToString(); + var appId = "appId1-native"; + var module = await _moduleLoader.StartModule(new StartRequest(appId, new Dictionary() { { "Fdc3InstanceId", fdc3InstanceId } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only + //We need to wait somehow for the module to finish up the listener registration + await Task.Delay(2000); + + var act = async () => await _desktopAgent.RaiseIntent("TestInstrument2", new Country(), new AppIdentifier { AppId = appId, InstanceId = fdc3InstanceId }); + + await act.Should().ThrowAsync() + .WithMessage($"*{ResolveError.IntentDeliveryFailed}*"); + } + + [Fact] + public async Task Open_throws_when_no_context_listener_registered_to_handle_context_when_opening() + { + var act = async () => await _desktopAgent.Open(new AppIdentifier { AppId = "appId1-native" }, new Chart(new Instrument[] { })); + + await act.Should().ThrowAsync() + .WithMessage($"*{OpenError.AppTimeout}*"); + } + + [Fact] + public async Task Open_returns_AppIdentifier() + { + var result = await _desktopAgent.Open(new AppIdentifier { AppId = "appId1-native" }); + + result.AppId.Should().Be("appId1-native"); + result.InstanceId.Should().NotBeNull(); + } + + [Fact] + public async Task CreatePrivateChannel_creates_private_channel() + { + var channel = await _desktopAgent.CreatePrivateChannel(); + channel.Should().NotBeNull(); + } + + [Fact] + public async Task RaiseIntent_creates_private_channel_and_client_app_receives_context_listener_registration() + { + var fdc3InstanceId = Guid.NewGuid().ToString(); + var appId = "appId1-native"; + var module = await _moduleLoader.StartModule(new StartRequest(appId, new Dictionary() { { "Fdc3InstanceId", fdc3InstanceId } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. For test only + + var appChannel2 = await _desktopAgent.GetOrCreateChannel("app-channel-for-private-channel-test"); + var contextListenerAdded = false; + var appChannelListener = await appChannel2.AddContextListener(ContextTypes.Nothing, (ctx, ctxM) => + { + //This should be triggered only when the private channel is ready from client test app and its OnAddContextListener has been triggered. + contextListenerAdded = true; + }); + + //We need to wait somehow for the module to finish up the listener registration, raiseIntentForContext in the test app will fail which will take some time to finish + await Task.Delay(5000); + + var resolution = await _desktopAgent.RaiseIntent("TestInstrument", new Contact(), new AppIdentifier { AppId = appId, InstanceId = fdc3InstanceId }); + resolution.Should().NotBeNull(); + + var result = await resolution.GetResult(); + result.Should().BeAssignableTo(); + + var privateChannel = result as IPrivateChannel; + + var privateChannelContextListener = await privateChannel!.AddContextListener(ContextTypes.Nothing, (ctx, ctxM) => { }); + + //Waiting for the client app to receive that a context listener has been added + await Task.Delay(1000); + + contextListenerAdded.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Helpers/MessageRouterStartupAction.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/Helpers/MessageRouterStartupAction.cs similarity index 95% rename from src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Helpers/MessageRouterStartupAction.cs rename to src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/Helpers/MessageRouterStartupAction.cs index fa7de12ba..1766425da 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Helpers/MessageRouterStartupAction.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/Helpers/MessageRouterStartupAction.cs @@ -16,7 +16,7 @@ using MorganStanley.ComposeUI.Messaging.Server.WebSocket; using MorganStanley.ComposeUI.ModuleLoader; -namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Helpers; +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests.Helpers; internal sealed class MessageRouterStartupAction : IStartupAction { diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Helpers/TestAppDirectoryData.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/Helpers/TestAppDirectoryData.cs similarity index 90% rename from src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Helpers/TestAppDirectoryData.cs rename to src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/Helpers/TestAppDirectoryData.cs index 3627f9d24..eb639bf36 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Helpers/TestAppDirectoryData.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/Helpers/TestAppDirectoryData.cs @@ -14,7 +14,7 @@ using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol; -namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Helpers; +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests.Helpers; internal static class TestAppDirectoryData { diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests.csproj b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests.csproj new file mode 100644 index 000000000..df13ea39f --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.IntegrationTests.csproj @@ -0,0 +1,47 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/testAppDirectory.json b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/testAppDirectory.json new file mode 100644 index 000000000..8e4b70394 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/IntegrationTests/testAppDirectory.json @@ -0,0 +1,112 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +{ + "applications": [ + { + "appId": "appId1", + "name": "app1", + "type": 1, + "details": { + "url": "http://customurl.com" + }, + "interop": { + "intents": { + "raises": { + "intent1": [ + "singleContext", + "nosuchcontext", + "onlyApp3Context", + "noAppShouldReturn" + ], + "intent2": [ + "multipleContext" + ], + "noIntentShouldHandle": [], + "noAppShouldReturn": [ + "singleContext" + ], + "testIntent": [ + "singleContext" + ], + "intentWithChannelResult": [ + "channelContext" + ] + }, + "listensFor": { + "intent1": { + "name": "intent1", + "displayName": "Intent resolved only by app 1", + "contexts": [ + "singleContext" + ], + "resultType": "resultType1" + }, + "TestInstrument": { + "name": "TestInstrument", + "displayName": "TestInstrument", + "contexts": [ + "fdc3.valuation" + ] + } + } + } + } + }, + { + "appId": "appId1-native", + "name": "app1", + "type": "native", + "details": { + "path": "..\\..\\..\\..\\TestHelpers\\DesktopAgentClientConsoleApp\\bin\\Release\\net8.0\\DesktopAgentClientConsoleApp.exe" + }, + "interop": { + "intents": { + "raises": { + "TestInstrument": [ + "fdc3.instrument" + ], + "ViewInstrument": [ + "fdc3.valuation" + ] + }, + "listensFor": { + "ViewInstrument": { + "name": "ViewInstrument", + "displayName": "ViewInstrument", + "contexts": [ + "fdc3.instrument" + ] + }, + "TestInstrument": { + "name": "TestInstrument", + "displayName": "TestInstrument", + "contexts": [ + "fdc3.valuation", + "fdc3.contact" + ] + }, + "TestInstrument2": { + "name": "TestInstrument2", + "displayName": "TestInstrument2", + "contexts": [ + "fdc3.country" + ] + } + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/EndToEndTests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/EndToEndTests.cs deleted file mode 100644 index 7aca010ee..000000000 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/EndToEndTests.cs +++ /dev/null @@ -1,281 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using MorganStanley.ComposeUI.Messaging.Client.WebSocket; -using MorganStanley.ComposeUI.ModuleLoader; -using Finos.Fdc3; -using FluentAssertions; -using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; -using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; -using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure; -using MorganStanley.ComposeUI.Messaging.Abstractions; -using Finos.Fdc3.Context; -using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Helpers; -using MorganStanley.ComposeUI.Fdc3.AppDirectory; - -namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests; - -public class EndToEndTests : IAsyncLifetime -{ - private const string TestChannel = "fdc3.channel.1"; - - private readonly Uri _webSocketUri = new("ws://localhost:7098/ws"); - private ServiceProvider _clientServices; - private IHost _host; - private IModuleLoader _moduleLoader; - private readonly object _runningAppsLock = new(); - private readonly List _runningApps = []; - private IDisposable _runningAppsObserver; - private IDesktopAgent _desktopAgent; - public EndToEndTests() - { - var repoRoot = RootPathResolver.GetRepositoryRoot(); - Environment.SetEnvironmentVariable(Consts.COMPOSEUI_MODULE_REPOSITORY_ENVIRONMENT_VARIABLE_NAME, repoRoot, EnvironmentVariableTarget.Process); - } - - public async Task InitializeAsync() - { - // Create the backend side - IHostBuilder hostBuilder = new HostBuilder(); - hostBuilder.ConfigureServices( - serviceCollection => - { - serviceCollection.AddMessageRouterServer( - s => s.UseWebSockets( - opt => - { - opt.RootPath = _webSocketUri.AbsolutePath; - opt.Port = _webSocketUri.Port; - })); - - serviceCollection.AddTransient(); - - serviceCollection.AddMessageRouter( - mr => mr - .UseServer()); - - serviceCollection.AddFdc3AppDirectory( - _ => _.Source = new Uri(@$"file:\\{Directory.GetCurrentDirectory()}\testAppDirectory.json")); - - serviceCollection.AddModuleLoader(); - - serviceCollection.AddFdc3DesktopAgent( - fdc3 => - { - fdc3.Configure(builder => { builder.ChannelId = TestChannel; }); - }); - - serviceCollection.AddMessageRouterMessagingAdapter(); - }); - - _host = hostBuilder.Build(); - await _host.StartAsync(); - - // Create a client acting in place of an application - _clientServices = new ServiceCollection() - .AddMessageRouter( - mr => mr.UseWebSocket( - new MessageRouterWebSocketOptions - { - Uri = _webSocketUri - })) - .AddMessageRouterMessagingAdapter() - .AddFdc3DesktopAgentClient() - .BuildServiceProvider(); - - _moduleLoader = _host.Services.GetRequiredService(); - - _runningAppsObserver = _moduleLoader.LifetimeEvents.Subscribe( - lifetimeEvent => - { - lock (_runningAppsLock) - { - switch (lifetimeEvent.EventType) - { - case LifetimeEventType.Started: - _runningApps.Add(lifetimeEvent.Instance); - break; - - case LifetimeEventType.Stopped: - _runningApps.Remove(lifetimeEvent.Instance); - break; - } - } - }); - - var instance = await _moduleLoader.StartModule(new StartRequest("appId1")); - var fdc3StartupProperties = instance.GetProperties().FirstOrDefault(); - - Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), "appId1"); - Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), fdc3StartupProperties!.InstanceId); - - _desktopAgent = _clientServices.GetRequiredService(); - } - - public async Task DisposeAsync() - { - List runningApps; - _runningAppsObserver?.Dispose(); - lock (_runningAppsLock) - { - runningApps = _runningApps.Reverse().ToList(); - } - - foreach (var instance in runningApps) - { - await _moduleLoader.StopModule(new StopRequest(instance.InstanceId)); - } - - await _clientServices.DisposeAsync(); - await _host.StopAsync(); - _host.Dispose(); - } - - [Fact] - public async Task GetAppMetadata_throws_error_as_error_response_received() - { - var action = async () => await _desktopAgent.GetAppMetadata(new Shared.Protocol.AppIdentifier { AppId = "nonExistingApp" }); - await action.Should() - .ThrowAsync() - .WithMessage("*nonExistingApp*"); - } - - [Fact] - public async Task GetAppMetadata_returns_AppMetadata() - { - var result = await _desktopAgent.GetAppMetadata(new Shared.Protocol.AppIdentifier { AppId = "appId1" }); - result.Should().NotBeNull(); - result.Should().BeEquivalentTo(TestAppDirectoryData.DefaultApp1); - } - - [Fact] - public async Task GetInfo_returns_ImplementationMetadata() - { - var result = await _desktopAgent.GetInfo(); - result.Should().NotBeNull(); - result.AppMetadata.Should().NotBeNull(); - result.AppMetadata.AppId.Should().Be("appId1"); - } - - [Fact] - public async Task GetInfo_throws_error_as_instance_id_not_found() - { - Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), "nonExistentAppId"); - Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), Guid.NewGuid().ToString()); - - var desktopAgent = new DesktopAgentClient(_clientServices.GetRequiredService()); - - var action = async() => await desktopAgent.GetInfo(); - - await action.Should() - .ThrowAsync() - .WithMessage($"*{Fdc3DesktopAgentErrors.MissingId}*"); - } - - [Fact] - public async Task JoinUserChannel_joins_to_a_user_channel() - { - await _desktopAgent.JoinUserChannel("fdc3.channel.1"); - var currentChannel = await _desktopAgent.GetCurrentChannel(); - - currentChannel.Should().NotBeNull(); - currentChannel.Id.Should().Be("fdc3.channel.1"); - } - - [Fact] - public async Task JoinUserChannel_joins_to_a_user_channel_and_registers_already_added_top_level_context_listeners() - { - var resultContexts = new List(); - - var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. The app broadcasts an instrument context after it joined to the fdc3.channel.1. - //We need to wait somehow for the module to finish up the broadcast - await Task.Delay(2000); - - var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); - var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); - - await _desktopAgent.JoinUserChannel("fdc3.channel.1"); - var currentChannel = await _desktopAgent.GetCurrentChannel(); - - currentChannel.Should().NotBeNull(); - currentChannel.Id.Should().Be("fdc3.channel.1"); - - resultContexts.Should().HaveCount(2); - - await _moduleLoader.StopModule(new StopRequest(module.InstanceId)); - } - - [Fact] - public async Task LeaveCurrentChannel_leaves_the_joined_channel() - { - await _desktopAgent.JoinUserChannel("fdc3.channel.1"); - var currentChannel = await _desktopAgent.GetCurrentChannel(); - - currentChannel.Should().NotBeNull(); - currentChannel.Id.Should().Be("fdc3.channel.1"); - - await _desktopAgent.LeaveCurrentChannel(); - currentChannel = await _desktopAgent.GetCurrentChannel(); - - currentChannel.Should().BeNull(); - } - - [Fact] - public async Task AddContextListener_can_be_registered_multiple_times() - { - var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { }); - var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { }); - - listener1.Should().NotBeNull(); - listener2.Should().NotBeNull(); - } - - [Fact] - public async Task AddContextListener_can_be_registered_but_they_do_not_receive_anything_until_they_joined_to_a_channel() - { - var resultContexts = new List(); - - var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. The app broadcasts an instrument context after it joined to the fdc3.channel.1. - var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); - var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); - - listener1.Should().NotBeNull(); - listener2.Should().NotBeNull(); - resultContexts.Should().HaveCount(0); - - await _moduleLoader.StopModule(new StopRequest(module.InstanceId)); - } - - [Fact] - public async Task Broadcast_is_not_retrieved_to_the_same_instance() - { - var resultContexts = new List(); - - var module = await _moduleLoader.StartModule(new StartRequest("appId1-native", new Dictionary() { { "Fdc3InstanceId", Guid.NewGuid().ToString() } })); // This will ensure that the DesktopAgent backend knows its an FDC3 enabled module. The app broadcasts an instrument context after it joined to the fdc3.channel.1. - //We need to wait somehow for the module to finish up the broadcast - await Task.Delay(2000); - - await _desktopAgent.JoinUserChannel("fdc3.channel.1"); - - var listener1 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); - var listener2 = await _desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContexts.Add(context); }); - var currentChannel = await _desktopAgent.GetCurrentChannel(); - - await _desktopAgent.Broadcast(new Instrument(new InstrumentID { Ticker = $"test-instrument-{Guid.NewGuid().ToString()}" }, "test-name")); - - currentChannel.Should().NotBeNull(); - currentChannel.Id.Should().Be("fdc3.channel.1"); - resultContexts.Should().HaveCount(2); //not 4 - - await _moduleLoader.StopModule(new StopRequest(module.InstanceId)); - } - - [Fact] - public async Task Broadcast_fails_on_not_joined_to_channel() - { - var action = async () => await _desktopAgent.Broadcast(new Instrument(new InstrumentID { Ticker = $"test-instrument-{Guid.NewGuid().ToString()}" }, "test-name")); - - await action.Should() - .ThrowAsync() - .WithMessage($"*No current channel to broadcast the context to.*"); - } -} \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/DesktopAgentClient.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/DesktopAgentClient.Tests.cs index 1b1407e57..c32da578d 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/DesktopAgentClient.Tests.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/DesktopAgentClient.Tests.cs @@ -16,16 +16,22 @@ using Finos.Fdc3; using Finos.Fdc3.Context; using FluentAssertions; +using Microsoft.Extensions.Logging; using Moq; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol; using MorganStanley.ComposeUI.Messaging.Abstractions; +using static MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure.Internal.OpenClientTests; using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; +using AppIntent = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIntent; using AppMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppMetadata; using DisplayMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.DisplayMetadata; using ImplementationMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.ImplementationMetadata; +using IntentMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.IntentMetadata; namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure; @@ -85,10 +91,9 @@ public async Task GetAppMetadata_returns_AppIdentifier() var desktopAgent = new DesktopAgentClient(messagingMock.Object); - var result = await desktopAgent.GetAppMetadata(new AppIdentifier { AppId = "test-appId" }); + var result = await desktopAgent.GetAppMetadata(new AppIdentifier { AppId = "test-appId" }); - result.Should().NotBeNull(); - result!.Should().BeEquivalentTo(new AppMetadata {AppId = "test-appId"}); + result!.Should().BeEquivalentTo(new AppMetadata { AppId = "test-appId" }); } [Fact] @@ -135,7 +140,7 @@ await action.Should().ThrowAsync() public async Task GetInfo_returns_ImplementationMetadata() { var messagingMock = new Mock(); - var implementationMetadata = new ImplementationMetadata {AppMetadata = new AppMetadata {AppId = "test_appId"}}; + var implementationMetadata = new ImplementationMetadata { AppMetadata = new AppMetadata { AppId = "test_appId" } }; messagingMock.Setup( _ => _.InvokeServiceAsync( @@ -148,7 +153,6 @@ public async Task GetInfo_returns_ImplementationMetadata() var result = await desktopAgent.GetInfo(); - result.Should().NotBeNull(); result.Should().BeEquivalentTo(implementationMetadata); } @@ -168,7 +172,6 @@ public async Task JoinUserChannel_joins_to_a_channel() var channel = await desktopAgent.GetCurrentChannel(); - channel.Should().NotBeNull(); channel!.Id.Should().Be("test-channelId"); } @@ -184,7 +187,7 @@ public async Task JoinUserChannel_returns_error() .Returns(new ValueTask(JsonSerializer.Serialize(new JoinUserChannelResponse { Success = false, Error = "test-error" }, _jsonSerializerOptions))); var desktopAgent = new DesktopAgentClient(messagingMock.Object); - var action = async() => await desktopAgent.JoinUserChannel("test-channelId"); + var action = async () => await desktopAgent.JoinUserChannel("test-channelId"); await action.Should().ThrowAsync() .WithMessage("*test-error*"); @@ -234,14 +237,13 @@ public async Task JoinUserChannel_handles_last_context_for_all_the_registered_to .Returns(new ValueTask(JsonSerializer.Serialize(new Context("test-type"), _jsonSerializerOptions))); //This shouldn't be the last context which is received by the second context listener as the last context for the registered context type is already set when invoking the GetCurrentContext for the first context listener var desktopAgent = new DesktopAgentClient(messagingMock.Object); - var listener = await desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContextListenerInvocations++; }); + var listener = await desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContextListenerInvocations++; }); var listener2 = await desktopAgent.AddContextListener("fdc3.instrument", (context, contextMetadata) => { resultContextListenerInvocations++; }); await desktopAgent.JoinUserChannel("test-channelId"); var channel = await desktopAgent.GetCurrentChannel(); - channel.Should().NotBeNull(); channel!.Id.Should().Be("test-channelId"); resultContextListenerInvocations.Should().Be(2); } @@ -256,7 +258,7 @@ public async Task LeaveCurrentChannel_leaves_the_currently_used_channel() It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new ValueTask(JsonSerializer.Serialize(new JoinUserChannelResponse { Success = true, DisplayMetadata = new DisplayMetadata() { Name = "test-channelId" } }, _jsonSerializerOptions))); + .Returns(new ValueTask(JsonSerializer.Serialize(new JoinUserChannelResponse { Success = true, DisplayMetadata = new DisplayMetadata() { Name = "test-channelId" } }, _jsonSerializerOptions))); var desktopAgent = new DesktopAgentClient(messagingMock.Object); @@ -425,16 +427,1226 @@ public async Task Broadcast_sends_message() It.IsAny()), Times.Once); } - public Task InitializeAsync() + [Fact] + public async Task GetUserChannels_returns_channels() { - Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), "test-appId2"); - Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), Guid.NewGuid().ToString()); + var messagingMock = new Mock(); - return Task.CompletedTask; + messagingMock.Setup( + _ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask( + JsonSerializer.Serialize( + new GetUserChannelsResponse + { + Channels = new[] + { + new ChannelItem { Id = "1", DisplayMetadata = new DisplayMetadata { Name = "1" } }, + new ChannelItem { Id = "2", DisplayMetadata = new DisplayMetadata { Name = "2" } } + } + }, _jsonSerializerOptions))); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var result = await desktopAgent.GetUserChannels(); + + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result.Should().BeEquivalentTo( + new[] + { + new Channel("1", ChannelType.User, messagingMock.Object, It.IsAny(), null, new DisplayMetadata { Name = "1" }, It.IsAny()), + new Channel("2", ChannelType.User, messagingMock.Object, It.IsAny(), null, new DisplayMetadata { Name = "2" }, It.IsAny()) + }); } - public Task DisposeAsync() + [Fact] + public async Task GetUserChannels_throws_error_on_no_response() + { + var messagingMock = new Mock(); + + messagingMock.Setup( + _ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask((string?) null)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var action = async () => await desktopAgent.GetUserChannels(); + + await action.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server.*"); + } + + [Fact] + public async Task GetUserChannels_throws_error_on_error_response_received() + { + var messagingMock = new Mock(); + + messagingMock.Setup( + _ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask( + JsonSerializer.Serialize( + new GetUserChannelsResponse + { + Channels = null + }, _jsonSerializerOptions))); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var action = async () => await desktopAgent.GetUserChannels(); + + await action.Should().ThrowAsync() + .WithMessage($"*{Fdc3DesktopAgentErrors.NoUserChannelSetFound}*"); + } + + [Fact] + public async Task GetUserChannels_throws_error_on_null_channel_response_received() + { + var messagingMock = new Mock(); + + messagingMock.Setup( + _ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask( + JsonSerializer.Serialize( + new GetUserChannelsResponse + { + Error = "test" + }, _jsonSerializerOptions))); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var action = async () => await desktopAgent.GetUserChannels(); + + await action.Should().ThrowAsync() + .WithMessage("*test*"); + } + + [Fact] + public async Task GetOrCreateAppChannel_returns_channel() + { + var response = new CreateAppChannelResponse { Success = true }; + var messagingMock = new Mock(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var channel = await desktopAgent.GetOrCreateChannel("testChannel"); + + channel.Id.Should().Be("testChannel"); + } + + [Fact] + public async Task GetOrCreateAppChannel_throws_when_response_is_null() + { + var messagingMock = new Mock(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.GetOrCreateChannel("testChannel"); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server.*"); + } + + [Fact] + public async Task GetOrCreateAppChannel_throws_when_response_has_error() + { + var response = new CreateAppChannelResponse { Success = false, Error = "Some error" }; + var messagingMock = new Mock(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.GetOrCreateChannel("testChannel"); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task GetOrCreateAppChannel_throws_when_response_is_not_successful() + { + var response = new CreateAppChannelResponse { Success = false }; + var messagingMock = new Mock(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.GetOrCreateChannel("testChannel"); + + await act.Should().ThrowAsync() + .WithMessage("*Error was received from the FDC3 backend server: UnspecifiedReason*"); + } + + [Fact] + public async Task FindIntent_throws_when_response_is_null() + { + var messagingMock = new Mock(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.FindIntent("fdc3.instrument"); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server*"); + } + + [Fact] + public async Task FindIntent_throws_when_error_received() + { + var messagingMock = new Mock(); + var response = new FindIntentResponse { Error = "Some error" }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.FindIntent("fdc3.instrument"); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task FindIntent_throws_when_no_error_but_AppIntent_is_null() + { + var messagingMock = new Mock(); + var response = new FindIntentResponse { Error = null, AppIntent = null }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.FindIntent("fdc3.instrument"); + + await act.Should().ThrowAsync() + .WithMessage($"*{Fdc3DesktopAgentErrors.NoAppIntent}*"); + } + + [Fact] + public async Task FindIntent_returns_AppIntent() + { + var messaginMock = new Mock(); + var response = new FindIntentResponse { Error = null, AppIntent = new AppIntent { Intent = new IntentMetadata { Name = "test" }, Apps = new List() { new AppMetadata { AppId = "test-appId1" } } } }; + + messaginMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messaginMock.Object); + var result = await desktopAgent.FindIntent("fdc3.instrument"); + + result.Should().BeEquivalentTo(response.AppIntent); + } + + [Fact] + public async Task FindInstances_returns_instances() + { + var messagingMock = new Mock(); + var response = new FindInstancesResponse + { + Instances = new[] + { + new AppIdentifier + { + InstanceId = Guid.NewGuid().ToString(), + AppId = "test-appId1" + }, + new AppIdentifier + { + InstanceId = Guid.NewGuid().ToString(), + AppId = "test-appId1" + } + } + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var result = await desktopAgent.FindInstances(new AppIdentifier { AppId = "test-appId1" }); + + result.Should().BeEquivalentTo(response.Instances); + } + + [Fact] + public async Task FindInstances_throws_when_null_response() + { + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.FindInstances(new AppIdentifier { AppId = "test-appId1" }); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server*"); + } + + [Fact] + public async Task FindInstances_throws_when_error_response_received() + { + var messagingMock = new Mock(); + var response = new FindInstancesResponse + { + Error = "Some error" + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.FindInstances(new AppIdentifier { AppId = "test-appId1" }); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task FindInstances_throws_when_no_error_and_no_instances_received() + { + var response = new FindInstancesResponse + { + Error = null, + Instances = null + }; + + var messagingMock = new Mock(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.FindInstances(new AppIdentifier { AppId = "test-appId1" }); + + await act.Should().ThrowAsync() + .WithMessage($"*{Fdc3DesktopAgentErrors.NoInstanceFound}*"); + } + + [Fact] + public async Task FindIntentsByContext_returns_AppIntents() + { + var messagingMock = new Mock(); + var response = new FindIntentsByContextResponse + { + AppIntents = new[] + { + new AppIntent + { + Intent = new IntentMetadata { Name = "test-intent1" }, + Apps = new List { new AppMetadata { AppId = "test-appId1" } } + }, + new AppIntent + { + Intent = new IntentMetadata { Name = "test-intent2" }, + Apps = new List { new AppMetadata { AppId = "test-appId2" } } + } + } + }; + + var context = new Instrument(new InstrumentID { Ticker = "test-ticker" }, "test-name"); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var result = await desktopAgent.FindIntentsByContext(context); + + result.Should().BeEquivalentTo(response.AppIntents); + } + + [Fact] + public async Task FindIntentsByContext_throws_when_null_response_received() + { + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.FindIntentsByContext(new Context("test-type")); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server*"); + } + + [Fact] + public async Task FindIntentsByContext_throws_when_error_response_received() { + var messagingMock = new Mock(); + var response = new FindIntentsByContextResponse + { + Error = "Some error" + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.FindIntentsByContext(new Context("test-type")); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task FindIntentsByContext_throws_when_no_error_and_no_AppIntents_received() + { + var messagingMock = new Mock(); + var response = new FindIntentsByContextResponse + { + Error = null, + AppIntents = null + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.FindIntentsByContext(new Context("test-type")); + + await act.Should().ThrowAsync() + .WithMessage("*The AppIntent was not returned by*"); + } + + [Fact] + public async Task RaiseIntentForContext_returns_channel_as_IntentResolution() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + Intent = "test-intent1", + MessageId = Guid.NewGuid().ToString() + }; + + var channelFactoryMock = new Mock(); + channelFactoryMock + .Setup(_ => _.FindChannelAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Channel("test-channel-id", ChannelType.User, messagingMock.Object, It.IsAny(), null, It.IsAny(), It.IsAny())); + + var findChannelResponse = new FindChannelResponse + { + Found = true, + }; + + var intentResolutionResponse = new GetIntentResultResponse + { + ChannelId = "test-channelId", + ChannelType = ChannelType.User + }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(intentResolutionResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(findChannelResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var intentResolution = await desktopAgent.RaiseIntentForContext(new Instrument()); + intentResolution.Should().NotBeNull(); + + var intentResult = await intentResolution.GetResult(); + + intentResult.Should().BeAssignableTo(); + } + + [Fact] + public async Task RaiseIntentForContext_returns_context_as_IntentResolution() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + Intent = "test-intent1", + MessageId = Guid.NewGuid().ToString() + }; + + var intentResolutionResponse = new GetIntentResultResponse + { + Context = JsonSerializer.Serialize(new Instrument(), _jsonSerializerOptions) + }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(intentResolutionResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var intentResolution = await desktopAgent.RaiseIntentForContext(new Instrument()); + intentResolution.Should().NotBeNull(); + + var intentResult = await intentResolution.GetResult(); + + intentResult.Should().BeAssignableTo(); + } + + [Fact] + public async Task RaiseIntentForContext_returns_void_as_IntentResolution() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + Intent = "test-intent1", + MessageId = Guid.NewGuid().ToString() + }; + + var intentResolutionResponse = new GetIntentResultResponse + { + VoidResult = true + }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(intentResolutionResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var intentResolution = await desktopAgent.RaiseIntentForContext(new Instrument()); + intentResolution.Should().NotBeNull(); + + var intentResult = await intentResolution.GetResult(); + intentResult.Should().BeNull(); + } + + [Fact] + public async Task RaiseIntentForContext_returns_channel_if_everything_is_defined_as_IntentResolution() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + Intent = "test-intent1", + MessageId = Guid.NewGuid().ToString() + }; + + var channelFactoryMock = new Mock(); + channelFactoryMock + .Setup(_ => _.FindChannelAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Channel("test-channel-id", ChannelType.App, messagingMock.Object, It.IsAny(), null, It.IsAny(), It.IsAny())); + + var findChannelResponse = new FindChannelResponse + { + Found = true, + }; + + var intentResolutionResponse = new GetIntentResultResponse + { + ChannelId = "test-channel-id", + ChannelType = ChannelType.App, + Context = JsonSerializer.Serialize(new Instrument(), _jsonSerializerOptions) + }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(intentResolutionResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(findChannelResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var intentResolution = await desktopAgent.RaiseIntentForContext(new Instrument()); + intentResolution.Should().NotBeNull(); + + var intentResult = await intentResolution.GetResult(); + + intentResult.Should().BeAssignableTo(); + } + + [Fact] + public async Task RaiseIntentForContext_returns_context_if_voidResult_and_context_are_defined_as_IntentResolution() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + Intent = "test-intent1", + MessageId = Guid.NewGuid().ToString() + }; + + var intentResolutionResponse = new GetIntentResultResponse + { + VoidResult = true, + Context = JsonSerializer.Serialize(new Instrument(), _jsonSerializerOptions) + }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(intentResolutionResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var intentResolution = await desktopAgent.RaiseIntentForContext(new Instrument()); + intentResolution.Should().NotBeNull(); + + var intentResult = await intentResolution.GetResult(); + + intentResult.Should().BeAssignableTo(); + } + + [Fact] + public async Task RaiseIntentForContext_throws_when_no_result_retrieved() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + Intent = "test-intent1", + MessageId = Guid.NewGuid().ToString() + }; + + var intentResolutionResponse = new GetIntentResultResponse + { + VoidResult = false + }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(intentResolutionResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var intentResolution = await desktopAgent.RaiseIntentForContext(new Instrument()); + intentResolution.Should().NotBeNull(); + + var act = async () => await intentResolution.GetResult(); + await act.Should().ThrowAsync() + .WithMessage("*Retrieving the intent resolution failed*"); + } + + [Fact] + public async Task RaiseIntentForContext_throws_when_no_response_retrieved() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + Intent = "test-intent1", + MessageId = Guid.NewGuid().ToString() + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.RaiseIntentForContext(new Instrument()); + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server.*"); + } + + [Fact] + public async Task RaiseIntentForContext_throws_when_error_response_retrieved() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + Error = "Some error" + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.RaiseIntentForContext(new Instrument()); + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task RaiseIntentForContext_throws_when_messageId_is_not_retrieved() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + Intent = "test-intent1", + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.RaiseIntentForContext(new Instrument()); + await act.Should().ThrowAsync() + .WithMessage("*was not retrieved from the backend.*"); + } + + [Fact] + public async Task RaiseIntentForContext_throws_when_intent_is_not_retrieved() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + MessageId = Guid.NewGuid().ToString() + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.RaiseIntentForContext(new Instrument()); + await act.Should().ThrowAsync() + .WithMessage("*was not retrieved from the backend.*"); + } + + [Fact] + public async Task RaiseIntentForContext_throws_when_AppMetadata_is_not_retrieved() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + MessageId = Guid.NewGuid().ToString(), + Intent = "test-intent1" + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.RaiseIntentForContext(new Instrument()); + await act.Should().ThrowAsync() + .WithMessage("*was not retrieved from the backend.*"); + } + + [Fact] + public async Task AddIntentListener_returns_listener() + { + var messagingMock = new Mock(); + var subscriptionMock = new Mock(); + List capturedHandlers = new(); + + messagingMock + .Setup( + _ => _.SubscribeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((string topic, TopicMessageHandler handler, CancellationToken ct) => + { + capturedHandlers.Add(handler); + return new ValueTask(subscriptionMock.Object); + }); + + var subscribeResponse = new IntentListenerResponse + { + Stored = true + }; + + messagingMock + .Setup( + _ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(subscribeResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var result = await desktopAgent.AddIntentListener("test-intent", (context, contextMetadata) => Task.FromResult(new Instrument())); + + result.Should().NotBeNull(); + result.Should().BeAssignableTo(); + } + + [Fact] + public async Task AddIntentListener_throws_when_null_response_received() + { + var messagingMock = new Mock(); + messagingMock + .Setup( + _ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.AddIntentListener("test-intent", (context, contextMetadata) => Task.FromResult(new Instrument())); + + await act.Should().ThrowAsync() + .WithMessage("*No response*"); + } + + [Fact] + public async Task AddIntentListener_throws_when_error_response_received() + { + var messagingMock = new Mock(); + var subscribeResponse = new IntentListenerResponse + { + Error = "Some error" + }; + + messagingMock + .Setup( + _ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(subscribeResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.AddIntentListener("test-intent", (context, contextMetadata) => Task.FromResult(new Instrument())); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task AddIntentListener_throws_when_not_stored_response_received() + { + var messagingMock = new Mock(); + var subscribeResponse = new IntentListenerResponse + { + Stored = false + }; + + messagingMock + .Setup( + _ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(subscribeResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.AddIntentListener("test-intent", (context, contextMetadata) => Task.FromResult(new Instrument())); + + await act.Should().ThrowAsync() + .WithMessage("*Intent listener is not registered for the intent*"); + } + + [Fact] + public async Task RaiseIntent_throws_when_no_response_returned() + { + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + var act = async () => await desktopAgent.RaiseIntent("test-intent", new Instrument()); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server.*"); + } + + [Fact] + public async Task RaiseIntent_throws_when_error_response_returned() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + Error = "Some error" + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.RaiseIntent("test-intent", new Instrument()); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task RaiseIntent_throws_when_required_fields_not_returned() + { + var messagingMock = new Mock(); + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = null, + Intent = null, + MessageId = null + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.RaiseIntent("test-intent", new Instrument()); + + await act.Should().ThrowAsync() + .WithMessage("*not retrieved from the backend.*"); + } + + [Fact] + public async Task RaiseIntent_returns_IntentResolution() + { + var messagingMock = new Mock(); + + var raiseIntentResponse = new RaiseIntentResponse + { + AppMetadata = new AppMetadata { AppId = "test-appId1" }, + Intent = "test-intent1", + MessageId = Guid.NewGuid().ToString() + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(raiseIntentResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var result = await desktopAgent.RaiseIntent("test-intent", new Instrument()); + + result.Should().BeAssignableTo(); + } + + [Fact] + public async Task Open_returns_AppIdentifier() + { + var messagingMock = new Mock(); + + var openResponse = new OpenResponse + { + AppIdentifier = new AppIdentifier + { + AppId = "test-appId1", + InstanceId = Guid.NewGuid().ToString() + } + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(openResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var result = await desktopAgent.Open(new AppIdentifier { AppId = "test-appId1" }, new Instrument()); + + result.Should().BeEquivalentTo(openResponse.AppIdentifier); + } + + [Fact] + public async Task Open_throws_when_context_is_malformed() + { + var messagingMock = new Mock(); + + var openResponse = new OpenResponse + { + AppIdentifier = new AppIdentifier + { + AppId = "test-appId1", + InstanceId = Guid.NewGuid().ToString() + } + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(openResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.Open(new AppIdentifier { AppId = "test-appId1" }, new MyUnvalidContext()); + + await act.Should().ThrowAsync() + .WithMessage("*context is malformed*"); + } + + [Fact] + public async Task Open_throws_when_null_response_is_received() + { + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.Open(new AppIdentifier { AppId = "test-appId1" }, new Instrument()); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server.*"); + } + + [Fact] + public async Task Open_throws_when_error_response_received() + { + var messagingMock = new Mock(); + var openResponse = new OpenResponse + { + Error = "Some error" + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(openResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.Open(new AppIdentifier { AppId = "test-appId1" }, new Instrument()); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task Open_throws_when_AppIdentifier_is_not_returned() + { + var messagingMock = new Mock(); + var openResponse = new OpenResponse(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(openResponse, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.Open(new AppIdentifier { AppId = "test-appId1" }, new Instrument()); + + await act.Should().ThrowAsync() + .WithMessage("*AppIdentifier cannot be returned*"); + } + + [Fact] + public async Task CreatePrivateChannel_creates_private_channel() + { + var messagingMock = new Mock(); + var response = new CreatePrivateChannelResponse + { + ChannelId = "private-channel-id", + Success = true + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var channel = await desktopAgent.CreatePrivateChannel(); + + channel.Should().NotBeNull(); + channel.Id.Should().Be("private-channel-id"); + } + + [Fact] + public async Task CreatePrivateChannel_throws_when_null_response_received() + { + var messagingMock = new Mock(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.CreatePrivateChannel(); + + await act.Should().ThrowAsync() + .WithMessage("*No response*"); + } + + [Fact] + public async Task CreatePrivateChannel_throws_when_error_response_received() + { + var messagingMock = new Mock(); + + var response = new CreatePrivateChannelResponse + { + Success = false, + Error = "Some error" + }; + + messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var desktopAgent = new DesktopAgentClient(messagingMock.Object); + + var act = async () => await desktopAgent.CreatePrivateChannel(); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + public Task InitializeAsync() + { + Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), "test-appId2"); + Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), Guid.NewGuid().ToString()); + + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + Environment.SetEnvironmentVariable(nameof(AppIdentifier.AppId), null); + Environment.SetEnvironmentVariable(nameof(AppIdentifier.InstanceId), null); + return Task.CompletedTask; } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ChannelFactory.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ChannelFactory.Tests.cs index 8db14bcbb..42975bad2 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ChannelFactory.Tests.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ChannelFactory.Tests.cs @@ -30,7 +30,7 @@ public class ChannelFactoryTests private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; [Fact] - public async Task CreateContextListener_returns_listener_from_channel_when_currentChannel_is_provided() + public async Task CreateContextListenerAsync_returns_listener_from_channel_when_currentChannel_is_provided() { var messagingMock = new Mock(); var channelMock = new Mock(); @@ -43,21 +43,20 @@ public async Task CreateContextListener_returns_listener_from_channel_when_curre var factory = new ChannelFactory(messagingMock.Object, "instanceId"); - var result = await factory.CreateContextListener(handler, channelMock.Object, "fdc3.instrument"); + var result = await factory.CreateContextListenerAsync(handler, channelMock.Object, "fdc3.instrument"); result.Should().BeSameAs(expectedListener); } [Fact] - public async Task CreateContextListener_creates_new_listener_when_currentChannel_is_null() + public async Task CreateContextListenerAsync_creates_new_listener_when_currentChannel_is_null() { var messagingMock = new Mock(); var handler = new ContextHandler((ctx, _) => { }); var factory = new ChannelFactory(messagingMock.Object, "instanceId"); - var result = await factory.CreateContextListener(handler, null, "fdc3.instrument"); + var result = await factory.CreateContextListenerAsync(handler, null, "fdc3.instrument"); - result.Should().NotBeNull(); result.Should().BeOfType>(); } @@ -83,7 +82,6 @@ public async Task JoinUserChannelAsync_returns_channel_when_successful() var result = await factory.JoinUserChannelAsync("channelId"); - result.Should().NotBeNull(); result.Id.Should().Be("channelId"); result.Type.Should().Be(ChannelType.User); } @@ -176,4 +174,314 @@ public async Task JoinUserChannelAsync_returns_error_when_messaging_throws() await act.Should().ThrowAsync() .WithMessage("*Messaging error*"); } + + [Fact] + public async Task CreateAppChannelAsync_returns_channel_when_successful() + { + var messagingMock = new Mock(); + var response = new CreateAppChannelResponse + { + Success = true, + Error = null + }; + + messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))); + + var factory = new ChannelFactory(messagingMock.Object, "instanceId"); + + var result = await factory.CreateAppChannelAsync("channelId"); + + result.Id.Should().Be("channelId"); + } + + [Fact] + public async Task CreateAppChannelAsync_returns_error_when_response_is_null() + { + var messagingMock = new Mock(); + messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask((string?) null)); + + var factory = new ChannelFactory(messagingMock.Object, "instanceId"); + + var act = async () => await factory.CreateAppChannelAsync("channelId"); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server.*"); + } + + [Fact] + public async Task CreateAppChannelAsync_returns_error_when_response_has_error() + { + var messagingMock = new Mock(); + var response = new CreateAppChannelResponse + { + Success = false, + Error = "Some error" + }; + + messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))); + + var factory = new ChannelFactory(messagingMock.Object, "instanceId"); + + var act = async () => await factory.CreateAppChannelAsync("channelId"); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task CreateAppChannelAsync_returns_error_when_response_is_unsuccessful() + { + var messagingMock = new Mock(); + var response = new CreateAppChannelResponse + { + Success = false, + Error = null + }; + + messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))); + + var factory = new ChannelFactory(messagingMock.Object, "instanceId"); + + var act = async () => await factory.CreateAppChannelAsync("channelId"); + + await act.Should().ThrowAsync() + .WithMessage("*UnspecifiedReason*"); + } + + [Fact] + public async Task CreateAppChannelAsync_returns_error_when_messaging_throws() + { + var messagingMock = new Mock(); + + messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Messaging error")); + + var factory = new ChannelFactory(messagingMock.Object, "instanceId"); + + var act = async () => await factory.CreateAppChannelAsync("channelId"); + + await act.Should().ThrowAsync() + .WithMessage("*Messaging error*"); + } + + [Fact] + public async Task FindChannelAsync_returns_Channel_when_found() + { + var response = new FindChannelResponse + { + Found = true + }; + + var responseJson = JsonSerializer.Serialize(response, _jsonSerializerOptions); + + var messagingMock = new Mock(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindChannel, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(responseJson); + + var factory = new ChannelFactory(messagingMock.Object, "instanceId"); + var result = await factory.FindChannelAsync("myChannel", ChannelType.User); + + result.Id.Should().Be("myChannel"); + result.Type.Should().Be(ChannelType.User); + } + + [Fact] + public async Task FindChannelAsync_throws_when_response_is_null() + { + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindChannel, + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var factory = new ChannelFactory(messagingMock.Object, "instanceId"); + var act = async () => await factory.FindChannelAsync("myChannel", ChannelType.User); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received*"); + } + + [Fact] + public async Task FindChannelAsync_throws_when_response_has_error() + { + var response = new FindChannelResponse + { + Error = "Some error" + }; + + var responseJson = JsonSerializer.Serialize(response, _jsonSerializerOptions); + var messagingMock = new Mock(); + + messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindChannel, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(responseJson); + + var factory = new ChannelFactory(messagingMock.Object, "instanceId"); + var act = async () => await factory.FindChannelAsync("myChannel", ChannelType.User); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task FindChannelAsync_throws_when_channel_not_found() + { + var response = new FindChannelResponse + { + Found = false + }; + + var responseJson = JsonSerializer.Serialize(response, _jsonSerializerOptions); + + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindChannel, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(responseJson); + + var factory = new ChannelFactory(messagingMock.Object, "instanceId"); + var act = async () => await factory.FindChannelAsync("myChannel", ChannelType.User); + + await act.Should().ThrowAsync() + .WithMessage("*not found*"); + } + + [Fact] + public async Task FindChannelAsync_joins_private_channel() + { + var messagingMock = new Mock(); + var instanceId = "test-instance"; + var channelId = "private-channel"; + + var findChannelResponse = new FindChannelResponse { Found = true }; + var joinPrivateChannelResponse = new JoinPrivateChannelResponse { Success = true }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(findChannelResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(joinPrivateChannelResponse, _jsonSerializerOptions)); + + var factory = new ChannelFactory(messagingMock.Object, instanceId); + + var result = await factory.FindChannelAsync(channelId, ChannelType.Private); + + result.Should().BeAssignableTo(); + result.Id.Should().Be(channelId); + } + + [Fact] + public async Task FindChannelAsync_try_to_join_private_channel_but_throws_when_missing_response() + { + var messagingMock = new Mock(); + var instanceId = "test-instance"; + var channelId = "private-channel"; + + var findChannelResponse = new FindChannelResponse { Found = true }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(findChannelResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize((string?)null)); + + var factory = new ChannelFactory(messagingMock.Object, instanceId); + + var act = async() => await factory.FindChannelAsync(channelId, ChannelType.Private); + + await act.Should().ThrowAsync() + .WithMessage("*No response*"); + } + + [Fact] + public async Task FindChannelAsync_try_to_join_private_channel_but_throws_when_error_response_received() + { + var messagingMock = new Mock(); + var instanceId = "test-instance"; + var channelId = "private-channel"; + + var findChannelResponse = new FindChannelResponse { Found = true }; + var joinPrivateChannelResponse = new JoinPrivateChannelResponse { Success = true, Error = "Some error" }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(findChannelResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(joinPrivateChannelResponse, _jsonSerializerOptions)); + + var factory = new ChannelFactory(messagingMock.Object, instanceId); + + var act = async () => await factory.FindChannelAsync(channelId, ChannelType.Private); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task FindChannelAsync_try_to_join_private_channel_but_throws_when_no_success_response_received() + { + var messagingMock = new Mock(); + var instanceId = "test-instance"; + var channelId = "private-channel"; + + var findChannelResponse = new FindChannelResponse { Found = true }; + var joinPrivateChannelResponse = new JoinPrivateChannelResponse { Success = false }; + + messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(findChannelResponse, _jsonSerializerOptions)) + .ReturnsAsync(JsonSerializer.Serialize(joinPrivateChannelResponse, _jsonSerializerOptions)); + + var factory = new ChannelFactory(messagingMock.Object, instanceId); + + var act = async () => await factory.FindChannelAsync(channelId, ChannelType.Private); + + await act.Should().ThrowAsync() + .WithMessage("*Client was not able to join to private channel*"); + } } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ContextListener.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ContextListener.Tests.cs index c299011fd..35485282f 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ContextListener.Tests.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/ContextListener.Tests.cs @@ -385,6 +385,7 @@ public async Task HandleContextAsync_invokes_handler_when_context_type_matches_a var handlerCalled = false; var handler = new ContextHandler((ctx, _) => handlerCalled = true); var listener = new ContextListener("instanceId", handler, _messagingMock.Object, "fdc3.instrument"); + await listener.SetOpenHandledAsync(true); await listener.SubscribeAsync("channelId", ChannelType.App); @@ -430,6 +431,7 @@ public async Task HandleContextAsync_does_not_invoke_handler_when_context_type_d var handlerCalled = false; var handler = new ContextHandler((ctx, _) => handlerCalled = true); var listener = new ContextListener("instanceId", handler, _messagingMock.Object, "testType"); + await listener.SetOpenHandledAsync(true); await listener.SubscribeAsync("channelId", ChannelType.App); @@ -462,6 +464,7 @@ public async Task HandleContextAsync_does_not_invoke_handler_when_context_is_not var handler = new ContextHandler((ctx, _) => handlerCalled = true); var listener = new ContextListener("instanceId", handler, _messagingMock.Object, "testType"); + await listener.SetOpenHandledAsync(true); await listener.SubscribeAsync("channelId", ChannelType.App); diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/IntentListener.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/IntentListener.Tests.cs new file mode 100644 index 000000000..a7f9a7104 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/IntentListener.Tests.cs @@ -0,0 +1,210 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System.Text.Json; +using Finos.Fdc3; +using Finos.Fdc3.Context; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Messaging.Abstractions; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure.Internal; + +public class IntentListener +{ + private readonly Mock _messagingMock = new(); + private readonly Mock _subscriptionMock = new(); + private readonly Mock>> _loggerMock = new(); + private readonly string _intent = "ViewChart"; + private readonly string _instanceId = "testInstance"; + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + + private IntentListener CreateIntentListener() + { + var intentListener = new IntentListener( + _messagingMock.Object, + _intent, + _instanceId, + (ctx, meta) => Task.FromResult(null!), + _loggerMock.Object); + + return intentListener; + } + + [Fact] + public void Unsubscribe_throws_when_no_error_but_the_listener_is_still_stored() + { + var response = new IntentListenerResponse { Stored = true }; + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + _subscriptionMock + .Setup(s => s.DisposeAsync()) + .Returns(ValueTask.CompletedTask) + .Verifiable(); + + var listener = CreateIntentListener(); + + var act = () => listener.Unsubscribe(); + + act.Should().Throw() + .WithMessage($"*Intent listener is still registered for the intent: {_intent}*"); + } + + [Fact] + public void Unsubscribe_throws_when_no_response_received_from_server() + { + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?)null); + + var listener = CreateIntentListener(); + + var act = () => listener.Unsubscribe(); + + act.Should().Throw() + .WithMessage("*No response*"); + } + + [Fact] + public void Unsubscribe_throws_when_error_response_received_from_the_server() + { + var response = new IntentListenerResponse { Error = "Some error" }; + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var listener = CreateIntentListener(); + + var act = () => listener.Unsubscribe(); + + act.Should().Throw() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task Unsubscribe_disposes_once() + { + var unsubscribeResponse = new IntentListenerResponse { Stored = false }; + + _messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(unsubscribeResponse, _jsonSerializerOptions)); + + _messagingMock + .Setup(_ => _.SubscribeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(_subscriptionMock.Object); + + _subscriptionMock + .Setup(_ => _.DisposeAsync()) + .Returns(ValueTask.CompletedTask); + + var listener = CreateIntentListener(); + + await listener.RegisterIntentHandlerAsync(); + + listener.Unsubscribe(); + + _subscriptionMock.Verify(s => s.DisposeAsync(), Times.Once); + } + + [Fact] + public async Task Unsubscribe_throws_when_not_subscribed() + { + var unsubscribeResponse = new IntentListenerResponse { Stored = false }; + + _messagingMock + .SetupSequence(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(unsubscribeResponse, _jsonSerializerOptions)); + + var listener = CreateIntentListener(); + + await listener.RegisterIntentHandlerAsync(); + + var act = () => listener.Unsubscribe(); + + act.Should().Throw(); + } + + [Fact] + public async Task RegisterIntentHandlerAsync_registers_handler() + { + _messagingMock + .Setup(_ => _.SubscribeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(_subscriptionMock.Object); + + _subscriptionMock + .Setup(_ => _.DisposeAsync()) + .Returns(ValueTask.CompletedTask); + + var listener = CreateIntentListener(); + + var act = async () => await listener.RegisterIntentHandlerAsync(); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task RegisterIntentHandlerAsync_registers_handler_only_once() + { + _messagingMock + .Setup(_ => _.SubscribeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(_subscriptionMock.Object); + + _subscriptionMock + .Setup(_ => _.DisposeAsync()) + .Returns(ValueTask.CompletedTask); + + var listener = CreateIntentListener(); + + await listener.RegisterIntentHandlerAsync(); + await listener.RegisterIntentHandlerAsync(); + + _messagingMock + .Verify(_ => _.SubscribeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once()); + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/IntentsClient.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/IntentsClient.Tests.cs new file mode 100644 index 000000000..703638048 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/IntentsClient.Tests.cs @@ -0,0 +1,526 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System.Text.Json; +using Finos.Fdc3.Context; +using FluentAssertions; +using Moq; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Messaging.Abstractions; +using IntentMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.IntentMetadata; +using AppMetadata = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppMetadata; +using AppIntent = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIntent; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol; +using Finos.Fdc3; +using IntentResolution = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol.IntentResolution; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure.Internal; + +public class IntentsClientTests +{ + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + private readonly Mock _messagingMock = new(); + private readonly Mock _channelFactoryMock = new(); + private readonly IntentsClient _client; + + public IntentsClientTests() + { + _client = new IntentsClient(_messagingMock.Object, _channelFactoryMock.Object, "testInstance"); + } + + [Fact] + public async Task FindIntentAsync_returns_AppIntent_when_response_is_successful() + { + var expectedAppIntent = new AppIntent + { + Intent = new IntentMetadata + { + Name = "IntentName", + DisplayName = "Intent Display Name" + }, + Apps = new[] + { + new AppMetadata + { + AppId = "AppId", + Name = "App Name", + Version = "1.0.0" + } + } + }; + var response = new FindIntentResponse + { + AppIntent = expectedAppIntent + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var result = await _client.FindIntentAsync("IntentName"); + + result.Should().BeEquivalentTo(expectedAppIntent); + } + + [Fact] + public async Task FindIntentAsync_throws_MissingResponse_when_response_is_null() + { + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var act = async () => await _client.FindIntentAsync("IntentName"); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server*"); + } + + [Fact] + public async Task FindIntentAsync_throws_ErrorResponseReceived_when_response_has_error() + { + var response = new FindIntentResponse + { + Error = "Some error" + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var act = async () => await _client.FindIntentAsync("IntentName"); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task FindIntentAsync_throws_AppIntentIsNotDefined_when_AppIntent_is_null() + { + var response = new FindIntentResponse + { + AppIntent = null + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var act = async () => await _client.FindIntentAsync("IntentName"); + + await act.Should().ThrowAsync() + .WithMessage($"*{Fdc3DesktopAgentErrors.NoAppIntent}*"); + } + + [Fact] + public async Task FindIntentsByContextAsync_returns_appIntents_when_successful() + { + var context = new Instrument(new InstrumentID() { BBG = "test" }, $"{Guid.NewGuid().ToString()}"); + + var expectedIntents = new[] + { + new AppIntent + { + Intent = new IntentMetadata + { + Name = "Intent1", + DisplayName = "Intent 1" + }, + Apps = new[] + { + new AppMetadata + { + AppId = "App1", + Name = "App 1", + Version = "1.0.0" + } + } + }, + + new AppIntent + { + Intent = new IntentMetadata + { + Name = "Intent2", + DisplayName = "Intent 2" + }, + Apps = new[] + { + new AppMetadata + { + AppId = "App2", + Name = "App 2", + Version = "1.0.0" + } + } + } + }; + + var response = new FindIntentsByContextResponse + { + AppIntents = expectedIntents + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindIntentsByContext, + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))); + + var result = await _client.FindIntentsByContextAsync(context); + + result.Should().BeEquivalentTo(expectedIntents); + } + + [Fact] + public async Task FindIntentsByContextAsync_throws_when_response_is_null() + { + var context = new Instrument(new InstrumentID() { BBG = "test" }, $"{Guid.NewGuid().ToString()}"); + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindIntentsByContext, + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask((string?) null)); + + var act = async () => await _client.FindIntentsByContextAsync(context); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server*"); + } + + [Fact] + public async Task FindIntentsByContextAsync_throws_when_response_has_error() + { + var context = new Instrument(new InstrumentID() { BBG = "test" }, $"{Guid.NewGuid().ToString()}"); + var response = new FindIntentsByContextResponse + { + Error = "Some error" + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindIntentsByContext, + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))); + + var act = async () => await _client.FindIntentsByContextAsync(context); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task FindIntentsByContextAsync_throws_when_appIntents_is_null() + { + var context = new Instrument(new InstrumentID() { BBG = "test" }, $"{Guid.NewGuid().ToString()}"); + + var response = new FindIntentsByContextResponse + { + AppIntents = null + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindIntentsByContext, + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))); + + var act = async () => await _client.FindIntentsByContextAsync(context); + + await act.Should().ThrowAsync() + .WithMessage("*The AppIntent was not returned*"); + } + + [Fact] + public async Task RaiseIntentForContextAsync_returns_IntentResolution_when_successful() + { + var context = new Instrument(new InstrumentID() { Ticker = "AAPL" }); + var app = new AppIdentifier { AppId = "app", InstanceId = "id" }; + var response = new RaiseIntentResponse + { + MessageId = Guid.NewGuid().ToString(), + Intent = "ViewChart", + AppMetadata = new AppMetadata { AppId = "TestApp" } + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.RaiseIntentForContext, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var result = await _client.RaiseIntentForContextAsync(context, app); + + result.Intent.Should().Be("ViewChart"); + result.Source.AppId.Should().Be("TestApp"); + } + + [Fact] + public async Task RaiseIntentForContextAsync_throws_when_response_is_null() + { + var context = new Instrument(new InstrumentID() { Ticker = "AAPL" }); + var app = new AppIdentifier { AppId = "app", InstanceId = "id" }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.RaiseIntentForContext, + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var act = async () => await _client.RaiseIntentForContextAsync(context, app); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server*"); + } + + [Fact] + public async Task RaiseIntentForContextAsync_throws_when_response_has_error() + { + var context = new Instrument(new InstrumentID() { Ticker = "AAPL" }); + var app = new AppIdentifier { AppId = "app", InstanceId = "id" }; + var response = new RaiseIntentResponse + { + Error = "Some error" + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.RaiseIntentForContext, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var act = async () => await _client.RaiseIntentForContextAsync(context, app); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [InlineData(null, "IntentName", "AppId" )] + [InlineData("", "IntentName", "AppId")] + [InlineData("testId", null, "AppId")] + [InlineData("testId", "", "AppId")] + [InlineData("testId", "IntentName", null)] + [InlineData("testId", "IntentName", "")] + [Theory] + public async Task RaiseIntentForContextAsync_throws_when_response_required_fields_are_missing(string? messageId, string? intent, string? appId) + { + var context = new Instrument(new InstrumentID() { Ticker = "AAPL" }); + var app = new AppIdentifier { AppId = "app", InstanceId = "id" }; + var response = new RaiseIntentResponse + { + MessageId = messageId, + Intent = intent, + AppMetadata = string.IsNullOrEmpty(appId) + ? null + : new AppMetadata { AppId = appId } + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.RaiseIntentForContext, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var act = async () => await _client.RaiseIntentForContextAsync(context, app); + + await act.Should().ThrowAsync() + .WithMessage($"*not retrieved from the backend.*"); + } + + [Fact] + public async Task AddIntentListenerAsync_throws_when_response_is_null() + { + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.AddIntentListener, + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?)null); + + Func act = async () => await _client.AddIntentListenerAsync("ViewChart", (ctx, meta) => Task.FromResult(new Instrument())); + + await act.Should().ThrowAsync() + .WithMessage("*No response*"); + } + + [Fact] + public async Task AddIntentListenerAsync_throws_when_error_response_received_from_the_backend() + { + var response = new IntentListenerResponse + { + Error = "Some error" + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.AddIntentListener, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + Func act = async () => await _client.AddIntentListenerAsync("ViewChart", (ctx, meta) => Task.FromResult(new Instrument())); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task AddIntentListenerAsync_throws_when_listener_not_registered() + { + var response = new IntentListenerResponse + { + Stored = false + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.AddIntentListener, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + Func act = async () => await _client.AddIntentListenerAsync("ViewChart", (ctx, meta) => Task.FromResult(new Instrument())); + + await act.Should().ThrowAsync() + .WithMessage("*not registered*"); + } + + [Fact] + public async Task AddIntentListenerAsync_returns_listener_when_response_is_valid() + { + var response = new IntentListenerResponse + { + Stored = true + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.AddIntentListener, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var result = await _client.AddIntentListenerAsync("ViewChart", (ctx, meta) => Task.FromResult(new Instrument())); + + result.Should().BeAssignableTo(); + } + + [Fact] + public async Task RaiseIntentAsync_throws_when_no_response_returned() + { + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?)null); + + var act = async () => await _client.RaiseIntentAsync("IntentName", new Instrument(), new AppIdentifier { AppId = "app", InstanceId = "id" }); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server*"); + } + + [Fact] + public async Task RaiseIntentAsync_throws_when_error_response_received() + { + var response = new RaiseIntentResponse + { + Error = "Some error" + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var act = async () => await _client.RaiseIntentAsync("IntentName", new Instrument(), new AppIdentifier { AppId = "app", InstanceId = "id" }); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task RaiseIntentAsync_throws_when_response_required_fields_are_missing() + { + var response = new RaiseIntentResponse + { + MessageId = null, + Intent = null, + AppMetadata = null + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var act = async () => await _client.RaiseIntentAsync("IntentName", new Instrument(), new AppIdentifier { AppId = "app", InstanceId = "id" }); + + await act.Should().ThrowAsync() + .WithMessage($"*not retrieved from the backend.*"); + } + + [Fact] + public async Task RaiseIntentAsync_returns_IntentResolution() + { + var response = new RaiseIntentResponse + { + MessageId = Guid.NewGuid().ToString(), + Intent = "ViewChart", + AppMetadata = new AppMetadata { AppId = "TestApp" } + }; + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var result = await _client.RaiseIntentAsync("IntentName", new Instrument(), new AppIdentifier { AppId = "app", InstanceId = "id" }); + + result.Should().BeAssignableTo(); + } +} \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/MetadataClient.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/MetadataClient.Tests.cs index 118038b85..9acb37e0d 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/MetadataClient.Tests.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/MetadataClient.Tests.cs @@ -21,6 +21,7 @@ using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; using MorganStanley.ComposeUI.Messaging.Abstractions; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure.Internal; @@ -191,4 +192,96 @@ public async Task GetInfoAsync_ThrowsErrorResponseReceivedException_WhenResponse await act.Should().ThrowAsync() .WithMessage("*Some error*"); } + + [Fact] + public async Task FindInstancesAsync_returns_instances_when_successful() + { + var appIdentifier = new AppIdentifier { AppId = "app", InstanceId = "id" }; + var expectedInstances = new[] { appIdentifier }; + var response = new FindInstancesResponse + { + Instances = expectedInstances + }; + + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindInstances, + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))); + + var client = new MetadataClient(AppId, InstanceId, messagingMock.Object); + var result = await client.FindInstancesAsync(appIdentifier); + + result.Should().BeEquivalentTo(expectedInstances); + } + + [Fact] + public async Task FindInstancesAsync_throws_when_response_is_null() + { + var appIdentifier = new AppIdentifier { AppId = "app", InstanceId = "id" }; + + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindInstances, + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask((string?) null)); + + var client = new MetadataClient(AppId, InstanceId, messagingMock.Object); + var act = async () => await client.FindInstancesAsync(appIdentifier); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received from the FDC3 backend server*"); + } + + [Fact] + public async Task FindInstancesAsync_throws_when_response_has_error() + { + var appIdentifier = new AppIdentifier { AppId = "app", InstanceId = "id" }; + var response = new FindInstancesResponse + { + Error = "Some error" + }; + + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindInstances, + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))); + + var client = new MetadataClient(AppId, InstanceId, messagingMock.Object); + var act = async () => await client.FindInstancesAsync(appIdentifier); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task FindInstancesAsync_throws_when_instances_is_null() + { + var appIdentifier = new AppIdentifier { AppId = "app", InstanceId = "id" }; + var response = new FindInstancesResponse + { + Instances = null + }; + + var messagingMock = new Mock(); + messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.FindInstances, + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(JsonSerializer.Serialize(response, _jsonSerializerOptions))); + + var client = new MetadataClient(AppId, InstanceId, messagingMock.Object); + var act = async () => await client.FindInstancesAsync(appIdentifier); + + await act.Should().ThrowAsync() + .WithMessage($"*{Fdc3DesktopAgentErrors.NoInstanceFound}*"); + } } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/OpenClient.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/OpenClient.Tests.cs new file mode 100644 index 000000000..79ad3dbf3 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/OpenClient.Tests.cs @@ -0,0 +1,298 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System.Net.Http.Headers; +using System.Text.Json; +using Finos.Fdc3; +using Finos.Fdc3.Context; +using FluentAssertions; +using Moq; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol; +using MorganStanley.ComposeUI.Messaging.Abstractions; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure.Internal; + +public class OpenClientTests +{ + private readonly Mock _messagingMock = new(); + private readonly Mock _channelFactoryMock = new(); + private readonly Mock _listenerMock = new(); + private readonly Mock _desktopAgentMock = new(); + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + + [Fact] + public async Task GetOpenedAppContextAsync_returns_context() + { + var response = new GetOpenedAppContextResponse + { + Context = JsonSerializer.Serialize(new Instrument(), _jsonSerializerOptions) + }; + + _messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var openClient = new OpenClient( + Guid.NewGuid().ToString(), + _messagingMock.Object, + _desktopAgentMock.Object); + + var context = await openClient.GetOpenAppContextAsync(Guid.NewGuid().ToString()); + + context.Type.Should().Be("fdc3.instrument"); + } + + [Fact] + public async Task GetOpenedAppContextAsync_throws_when_null_response_received() + { + _messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?)null); + + var openClient = new OpenClient( + Guid.NewGuid().ToString(), + _messagingMock.Object, + _desktopAgentMock.Object); + + var act = async() => await openClient.GetOpenAppContextAsync(Guid.NewGuid().ToString()); + + await act.Should().ThrowAsync() + .WithMessage("*No response*"); + } + + [Fact] + public async Task GetOpenedAppContextAsync_throws_when_null_context_id_passed() + { + var response = new GetOpenedAppContextResponse + { + Context = JsonSerializer.Serialize(new Instrument(), _jsonSerializerOptions) + }; + + _messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var openClient = new OpenClient( + Guid.NewGuid().ToString(), + _messagingMock.Object, + _desktopAgentMock.Object); + + var act = async () => await openClient.GetOpenAppContextAsync(null!); + + await act.Should().ThrowAsync() + .WithMessage("*The context id was received*"); + } + + [Fact] + public async Task GetOpenedAppContextAsync_throws_when_error_response_received() + { + var response = new GetOpenedAppContextResponse + { + Error = "Some error" + }; + + _messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var openClient = new OpenClient( + Guid.NewGuid().ToString(), + _messagingMock.Object, + _desktopAgentMock.Object); + + var act = async () => await openClient.GetOpenAppContextAsync(Guid.NewGuid().ToString()); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task GetOpenedAppContextAsync_throws_when_null_context_returned() + { + var response = new GetOpenedAppContextResponse + { + Context = null + }; + + _messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var openClient = new OpenClient( + Guid.NewGuid().ToString(), + _messagingMock.Object, + _desktopAgentMock.Object); + + var act = async () => await openClient.GetOpenAppContextAsync(Guid.NewGuid().ToString()); + + await act.Should().ThrowAsync() + .WithMessage("*No context was received*"); + } + + [Fact] + public async Task OpenAsync_returns_AppIdentifier() + { + var response = new OpenResponse + { + AppIdentifier = new AppIdentifier + { + AppId = "appId", + InstanceId = "instanceId" + } + }; + + _messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + _desktopAgentMock.Setup( + _ => _.GetCurrentChannel()) + .ReturnsAsync((IChannel?) null); + + var openClient = new OpenClient( + Guid.NewGuid().ToString(), + _messagingMock.Object, + _desktopAgentMock.Object); + + var result = await openClient.OpenAsync(new AppIdentifier { AppId = "appId" }, new Instrument()); + + result.AppId.Should().Be("appId"); + result.InstanceId.Should().Be("instanceId"); + } + + [Fact] + public async Task OpenAsync_throws_when_malformed_context() + { + var openClient = new OpenClient(Guid.NewGuid().ToString(), _messagingMock.Object, _desktopAgentMock.Object); + + var act = async () => await openClient.OpenAsync(new AppIdentifier { AppId = "appId" }, new MyUnvalidContext()); + + await act.Should().ThrowAsync() + .WithMessage("*context is malformed*"); + } + + [Fact] + public async Task OpenAsync_throws_when_null_response_received() + { + _messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?)null); + + var openClient = new OpenClient( + Guid.NewGuid().ToString(), + _messagingMock.Object, + _desktopAgentMock.Object); + + var act = async () => await openClient.OpenAsync(new AppIdentifier { AppId = "appId" }, new Instrument()); + + await act.Should().ThrowAsync() + .WithMessage("*No response*"); + } + + [Fact] + public async Task OpenAsync_throws_when_error_response_received() + { + var response = new OpenResponse + { + Error = "Some error" + }; + + _messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + _desktopAgentMock.Setup( + _ => _.GetCurrentChannel()) + .ReturnsAsync((IChannel?) null); + + var openClient = new OpenClient( + Guid.NewGuid().ToString(), + _messagingMock.Object, + _desktopAgentMock.Object); + + var act = async () => await openClient.OpenAsync(new AppIdentifier { AppId = "appId" }, new Instrument()); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task OpenAsync_throws_when_AppIdentifier_not_received() + { + var response = new OpenResponse(); + + _messagingMock + .Setup(_ => _.InvokeServiceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + _desktopAgentMock.Setup( + _ => _.GetCurrentChannel()) + .ReturnsAsync((IChannel?) null); + + var openClient = new OpenClient( + Guid.NewGuid().ToString(), + _messagingMock.Object, + _desktopAgentMock.Object); + + var act = async () => await openClient.OpenAsync(new AppIdentifier { AppId = "appId" }, new Instrument()); + + await act.Should().ThrowAsync() + .WithMessage("*AppIdentifier cannot be returned*"); + + } + + internal class MyUnvalidContext : IContext + { + public object? ID => new object(); + + public string? Name => ""; + + public string Type => ""; + + public dynamic? Native { get; set; } + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Channel.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Protocol/Channel.Tests.cs similarity index 94% rename from src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Channel.Tests.cs rename to src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Protocol/Channel.Tests.cs index 369c78af2..3c4fb5240 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Channel.Tests.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Protocol/Channel.Tests.cs @@ -24,7 +24,7 @@ using Finos.Fdc3.Context; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; -namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure.Internal; +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure.Internal.Protocol; public class ChannelTests { @@ -67,7 +67,7 @@ public async Task AddContextListener_handles_last_context_on_the_channel() _ => _.InvokeServiceAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(JsonSerializer.Serialize(new AddContextListenerResponse { Success = true, Id = Guid.NewGuid().ToString() }, SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization)); - var listener = await _channel.AddContextListener("fdc3.instrument", handler); + var listener = await _channel.AddContextListener("fdc3.instrument", handler); listener.Should().NotBeNull(); handlerCalled.Should().BeFalse(); // Handler is not called until a message is received @@ -108,7 +108,7 @@ public async Task GetCurrentContext_returns_null_when_service_returns_null_on_th It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((string)null!); + .ReturnsAsync((string) null!); var result = await _channel.GetCurrentContext("TestType"); @@ -131,9 +131,8 @@ public async Task GetCurrentContext_returns_deserialized_context_and_updates_las var result = await _channel.GetCurrentContext("fdc3.instrument"); - result.Should().NotBeNull(); result.Should().BeOfType(); - ((Instrument)result!).Should().BeEquivalentTo(context); + ((Instrument) result!).Should().BeEquivalentTo(context); } [Fact] @@ -152,15 +151,14 @@ public async Task GetCurrentContext_returns_last_context_when_context_type_is_nu var result = await _channel.GetCurrentContext(null); - result.Should().NotBeNull(); result.Should().BeOfType(); - ((Instrument)result!).Should().BeEquivalentTo(context); + ((Instrument) result!).Should().BeEquivalentTo(context); } [Fact] public async Task GetCurrentContext_logs_and_returns_null_when_context_type_not_found_on_the_channel() { - var context = new Instrument(new InstrumentID() { Ticker = "MS" }, Guid.NewGuid().ToString() ); + var context = new Instrument(new InstrumentID() { Ticker = "MS" }, Guid.NewGuid().ToString()); var contextJson = JsonSerializer.Serialize(context, SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization); _messagingMock diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Protocol/IntentResolution.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Protocol/IntentResolution.Tests.cs new file mode 100644 index 000000000..3e2ff1665 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Protocol/IntentResolution.Tests.cs @@ -0,0 +1,177 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using Finos.Fdc3; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Messaging.Abstractions; +using System.Text.Json; +using IntentResolution = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol.IntentResolution; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; +using Finos.Fdc3.Context; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure.Internal.Protocol; + +public class IntentResolutionTests +{ + private readonly Mock _messagingMock = new(); + private readonly Mock _channelFactoryMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly string _messageId = "msg-1"; + private readonly string _intent = "ViewChart"; + private readonly AppIdentifier _source = new() { AppId = "app", InstanceId = "inst" }; + private readonly JsonSerializerOptions _jsonOptions = new(); + + private IntentResolution CreateIntentResolution() + { + return new IntentResolution( + _messageId, + _messagingMock.Object, + _channelFactoryMock.Object, + _intent, + _source, + _loggerMock.Object); + } + + [Fact] + public async Task GetResult_returns_channel_when_channelId_and_type_are_present() + { + var response = new GetIntentResultResponse + { + ChannelId = "ch1", + ChannelType = ChannelType.User + }; + + var channelMock = new Mock(); + + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.GetIntentResult, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonOptions)); + + _channelFactoryMock + .Setup(f => f.FindChannelAsync("ch1", ChannelType.User)) + .ReturnsAsync(channelMock.Object); + + var intentResolution = CreateIntentResolution(); + var result = await intentResolution.GetResult(); + + result.Should().Be(channelMock.Object); + } + + [Fact] + public async Task GetResult_returns_context_when_context_is_present() + { + var context = new Instrument(new InstrumentID { Ticker = "AAPL" }); + var response = new GetIntentResultResponse + { + Context = JsonSerializer.Serialize(context, _jsonOptions) + }; + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.GetIntentResult, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonOptions)); + + var intentResolution = CreateIntentResolution(); + var result = await intentResolution.GetResult(); + + result.Should().BeOfType(); + ((Instrument) result!).ID!.Ticker.Should().Be("AAPL"); + } + + [Fact] + public async Task GetResult_returns_null_when_voidResult_is_true() + { + var response = new GetIntentResultResponse + { + VoidResult = true + }; + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.GetIntentResult, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonOptions)); + + var intentResolution = CreateIntentResolution(); + var result = await intentResolution.GetResult(); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetResult_throws_when_response_is_null() + { + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.GetIntentResult, + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string?) null); + + var intentResolution = CreateIntentResolution(); + var act = async () => await intentResolution.GetResult(); + + await act.Should().ThrowAsync() + .WithMessage("*No response was received*"); + } + + [Fact] + public async Task GetResult_throws_when_response_has_error() + { + var response = new GetIntentResultResponse + { + Error = "Some error" + }; + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.GetIntentResult, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonOptions)); + + var intentResolution = CreateIntentResolution(); + var act = async () => await intentResolution.GetResult(); + + await act.Should().ThrowAsync() + .WithMessage("*Some error*"); + } + + [Fact] + public async Task GetResult_throws_when_no_valid_result() + { + var response = new GetIntentResultResponse(); + _messagingMock + .Setup(m => m.InvokeServiceAsync( + Fdc3Topic.GetIntentResult, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonOptions)); + + var intentResolution = CreateIntentResolution(); + var act = async () => await intentResolution.GetResult(); + + await act.Should().ThrowAsync() + .WithMessage($"*{_intent}*"); + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Protocol/PrivateChannel.Tests.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Protocol/PrivateChannel.Tests.cs new file mode 100644 index 000000000..fe9ce64b0 --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/Infrastructure/Internal/Protocol/PrivateChannel.Tests.cs @@ -0,0 +1,180 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + + +using System.Text.Json; +using Finos.Fdc3.Context; +using FluentAssertions; +using Moq; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Infrastructure.Internal.Protocol; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol; +using MorganStanley.ComposeUI.Messaging.Abstractions; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.Infrastructure.Internal.Protocol; + +public class PrivateChannelTests +{ + private readonly Mock _messagingMock = new(); + private readonly string _channelId = "test-channel"; + private readonly string _instanceId = "test-instance"; + private readonly DisplayMetadata _displayMetadata = new(); + private readonly PrivateChannel _channel; + private readonly JsonSerializerOptions _jsonSerializerOptions = SerializerOptionsHelper.JsonSerializerOptionsWithContextSerialization; + + public PrivateChannelTests() + { + _messagingMock.Setup(m => m.SubscribeAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(Mock.Of()); + _messagingMock.Setup(m => m.RegisterServiceAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(Mock.Of()); + _messagingMock.Setup(m => m.PublishAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(new ValueTask()); + + _channel = new PrivateChannel( + _channelId, + _messagingMock.Object, + _instanceId, + onDisconnect: () => { }, + isOriginalCreator: true); + } + + [Fact] + public async Task Broadcast_when_disconnected_throws() + { + _channel.Disconnect(); + + await Task.Delay(2000); + + Func act = async () => await _channel.Broadcast(Mock.Of()); + + await act.Should().ThrowAsync() + .WithMessage("*disconnected*"); + } + + [Fact] + public async Task Broadcast_when_connected_calls_publish() + { + var context = new Instrument(); + + await _channel.Broadcast(context); + + _messagingMock.Verify(m => m.PublishAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.AtLeastOnce()); + } + + [Fact] + public async Task AddContextListener_when_disconnected_throws() + { + _channel.Disconnect(); + + Func act = async () => await _channel.AddContextListener(null, (ctx, ctxM) => { }); + + await act.Should().ThrowAsync() + .WithMessage("*disconnected*"); + } + + [Fact] + public async Task AddContextListener_when_connected_returns_listener() + { + var response = new AddContextListenerResponse + { + Success = true, + Id = "test-listener-id" + }; + + _messagingMock.Setup( + _ => _.InvokeServiceAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(JsonSerializer.Serialize(response, _jsonSerializerOptions)); + + var channel = new PrivateChannel(_channelId, _messagingMock.Object, _instanceId, onDisconnect:() => { }, isOriginalCreator: true); + + var listener = await channel.AddContextListener(null, (ctx, ctxM) => { }); + + listener.Should().NotBeNull(); + } + + [Fact] + public void OnAddContextListener_when_connected_returns_listener() + { + var listener = _channel.OnAddContextListener(_ => { }); + + listener.Should().NotBeNull(); + } + + [Fact] + public async Task OnAddContextListener_when_disconnected_throws() + { + _channel.Disconnect(); + + await Task.Delay(2000); + + Action act = () => _channel.OnAddContextListener(_ => { }); + + act.Should().Throw().WithMessage("*disconnected*"); + } + + [Fact] + public void OnDisconnect_when_connected_returns_listener() + { + var listener = _channel.OnDisconnect(() => { }); + + listener.Should().NotBeNull(); + } + + [Fact] + public void OnDisconnect_when_disconnected_throws() + { + _channel.Disconnect(); + + Action act = () => _channel.OnDisconnect(() => { }); + + act.Should().Throw().WithMessage("*disconnected*"); + } + + [Fact] + public void OnUnsubscribe_when_connected_returns_listener() + { + var listener = _channel.OnUnsubscribe(_ => { }); + + listener.Should().NotBeNull(); + } + + [Fact] + public async Task OnUnsubscribe_when_disconnected_throws() + { + _channel.Disconnect(); + + await Task.Delay(2000); + + Action act = () => _channel.OnUnsubscribe(_ => { }); + + act.Should().Throw().WithMessage("*disconnected*"); + } + + [Fact] + public void Disconnect_when_called_multiple_times_does_not_throw() + { + _channel.Disconnect(); + Action act = () => _channel.Disconnect(); + + act.Should().NotThrow(); + } + + [Fact] + public async Task DisposeAsync_when_called_disposes_resources() + { + Func act = async () => await _channel.DisposeAsync(); + + await act.Should().NotThrowAsync(); + } +} diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.csproj b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.csproj index f877e86aa..a2d7b843e 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.csproj +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests.csproj @@ -25,12 +25,6 @@ - - - - - - diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/testAppDirectory.json b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/testAppDirectory.json index 9666be047..7c2e6903b 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/testAppDirectory.json +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Client.Tests/testAppDirectory.json @@ -33,9 +33,6 @@ "intent2": [ "multipleContext" ], - "intentWithNoResult": [ - "fdc3.nothing" - ], "noIntentShouldHandle": [], "noAppShouldReturn": [ "singleContext" @@ -55,6 +52,13 @@ "singleContext" ], "resultType": "resultType1" + }, + "TestInstrument": { + "name": "TestInstrument", + "displayName": "TestInstrument", + "contexts": [ + "fdc3.valuation" + ] } } } @@ -69,7 +73,29 @@ }, "interop": { "intents": { + "raises": { + "TestInstrument": [ + "fdc3.instrument" + ], + "ViewInstrument": [ + "fdc3.valuation" + ] + }, "listensFor": { + "ViewInstrument": { + "name": "ViewInstrument", + "displayName": "ViewInstrument", + "contexts": [ + "fdc3.instrument" + ] + }, + "TestInstrument": { + "name": "TestInstrument", + "displayName": "TestInstrument", + "contexts": [ + "fdc3.valuation" + ] + } } } } diff --git a/src/fdc3/dotnet/DesktopAgent.Client/test/TestHelpers/DesktopAgentClientConsoleApp/Program.cs b/src/fdc3/dotnet/DesktopAgent.Client/test/TestHelpers/DesktopAgentClientConsoleApp/Program.cs index a68329a55..e8641e53f 100644 --- a/src/fdc3/dotnet/DesktopAgent.Client/test/TestHelpers/DesktopAgentClientConsoleApp/Program.cs +++ b/src/fdc3/dotnet/DesktopAgent.Client/test/TestHelpers/DesktopAgentClientConsoleApp/Program.cs @@ -14,36 +14,124 @@ using Finos.Fdc3.Context; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using AppIdentifier = MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Protocol.AppIdentifier; -Console.WriteLine("Hello, World!"); -var serviceCollection = new ServiceCollection() - .AddMessageRouter(m => +internal class Program +{ + private static async Task Main(string[] args) { - m.UseWebSocketFromEnvironment(); - }) - .AddMessageRouterMessagingAdapter() - .AddFdc3DesktopAgentClient() - .AddLogging(l => l.AddFile("./log.txt", LogLevel.Trace)); + Console.WriteLine("Hello, World!"); -var serviceProvider = serviceCollection.BuildServiceProvider(); + var serviceCollection = new ServiceCollection() + .AddMessageRouter(m => + { + m.UseWebSocketFromEnvironment(); + }) + .AddMessageRouterMessagingAdapter() + .AddFdc3DesktopAgentClient() + .AddLogging(l => l.AddFile($"{Directory.GetCurrentDirectory}\\log.txt", LogLevel.Trace)); -var desktopAgentClient = serviceProvider.GetRequiredService(); + var serviceProvider = serviceCollection.BuildServiceProvider(); -if (desktopAgentClient == null) -{ - throw new Exception("Failed to get IDesktopAgent from service provider..."); -} + var desktopAgentClient = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); -await desktopAgentClient.JoinUserChannel("fdc3.channel.1"); + try + { + if (desktopAgentClient == null) + { + throw new Exception("Failed to get IDesktopAgent from service provider..."); + } -var currentChannel = await desktopAgentClient.GetCurrentChannel(); -if (currentChannel == null) -{ - throw new Exception("Failed to join to a channel..."); -} + await desktopAgentClient.JoinUserChannel("fdc3.channel.1"); + + var currentChannel = await desktopAgentClient.GetCurrentChannel(); + + if (currentChannel == null) + { + throw new Exception("Failed to join to a channel..."); + } + + logger.LogInformation("Joined to a channel..."); + + //Testing the broadcast + await desktopAgentClient.Broadcast(new Instrument(new InstrumentID { Ticker = "test-instrument" }, "test-name")); + logger.LogInformation("Broadcasted an instrument to the current channel..."); + + var appChannel = await desktopAgentClient.GetOrCreateChannel("app-channel-1"); + logger.LogInformation("Got or created app channel..."); + + if (appChannel == null) + { + throw new Exception("Failed to get or create app channel..."); + } + + var listener = await appChannel.AddContextListener("fdc3.instrument", (context, metadata) => + { + logger.LogInformation($"Received context in app channel: {context?.Name} - {context?.ID?.Ticker}"); + Console.WriteLine($"Received context in app channel: {context?.Name} - {context?.ID?.Ticker}"); + }); + + await appChannel.Broadcast(new Instrument(new InstrumentID { Ticker = $"test-instrument-2" }, "test-name2")); + logger.LogInformation("Broadcasted an instrument to the app channel..."); + + var intentListener = await desktopAgentClient.AddIntentListener("ViewInstrument", (context, metadata) => + { + logger.LogInformation($"Intent received: {context?.Name} - {context?.ID?.Ticker}"); + Console.WriteLine($"Intent received: {context?.Name} - {context?.ID?.Ticker}"); + return Task.FromResult(currentChannel); + }); + + var instances = await desktopAgentClient.FindInstances(new AppIdentifier { AppId = "appId1" }); + var instance = instances.First(); + + logger.LogDebug($"Initiator identified: {instance.AppId}; {instance.InstanceId}, but retrieved instances were : {instances.Count()}..."); + + try + { + var intentResolution = await desktopAgentClient.RaiseIntentForContext(new Valuation("USD", 400, 1, "02/10/2025", "10/10/2025", "USD", "USD"), instance); + } + catch (Exception exception) + { + logger.LogWarning(exception, $"Exception was thrown in the test app during raising intent for context"); + } + + var privateChannel = await desktopAgentClient.CreatePrivateChannel(); + + var appChannel2 = await desktopAgentClient.GetOrCreateChannel("app-channel-for-private-channel-test"); + privateChannel.OnAddContextListener(async ctx => + { + await appChannel2.Broadcast(new Nothing()); + }); + + var listener3 = privateChannel.OnUnsubscribe((ctx) => + { + logger.LogInformation("Private channel is unsubscribed..."); + Console.WriteLine("Private channel is unsubscribed..."); + }); + + var listener4 = privateChannel.OnAddContextListener((ctx) => + { + logger.LogInformation($"Private channel received context listener addition information: {ctx}"); + Console.WriteLine($"Private channel received context listener addition information context: {ctx}"); + }); + + var intentListener2 = await desktopAgentClient.AddIntentListener("TestInstrument", (context, metadata) => + { + logger.LogInformation($"Contact Intent received: {context?.Name} - {context?.ID}, sending private channel: {privateChannel.Id}..."); + Console.WriteLine($"Contact Intent received: {context?.Name} - {context?.ID}, sending private channel: {privateChannel.Id}..."); + return Task.FromResult(privateChannel); + }); + } + catch (Exception exception) + { + logger.LogError(exception, "Error during DesktopAgentClient test..."); + } -//Testing the broadcast -await desktopAgentClient.Broadcast(new Instrument(new InstrumentID { Ticker = "test-instrument" }, "test-name")); + await Task.Delay(2000); + logger.LogInformation("DesktopAgent is tested..."); -Console.WriteLine("DesktopAgent is tested..."); -Console.ReadLine(); + Console.WriteLine("DesktopAgent is tested..."); + Console.ReadLine(); + } +} \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Contracts/CreatePrivateChannelResponse.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Contracts/CreatePrivateChannelResponse.cs index 58beb5b4a..d3edee655 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Contracts/CreatePrivateChannelResponse.cs +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Contracts/CreatePrivateChannelResponse.cs @@ -16,17 +16,10 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; internal class CreatePrivateChannelResponse { - private CreatePrivateChannelResponse(bool success, string? channelId, string? error) - { - Success = success; - ChannelId = channelId; - Error = error; - } + public bool Success { get; init; } + public string? Error { get; init; } + public string? ChannelId { get; init; } - public bool Success { get; } - public string? Error { get; } - public string? ChannelId { get; } - - public static CreatePrivateChannelResponse Created(string channelId) => new CreatePrivateChannelResponse(true, channelId, null); - public static CreatePrivateChannelResponse Failed(string error) => new CreatePrivateChannelResponse(false, null, error); + public static CreatePrivateChannelResponse Created(string channelId) => new CreatePrivateChannelResponse { Success = true, ChannelId = channelId, Error = null}; + public static CreatePrivateChannelResponse Failed(string error) => new CreatePrivateChannelResponse { Success = false, ChannelId = null, Error = error }; } diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Converters/ContextJsonConverter.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Converters/ContextJsonConverter.cs index a8a462312..aa70793c8 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Converters/ContextJsonConverter.cs +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Converters/ContextJsonConverter.cs @@ -1,4 +1,19 @@ -using System.Text.Json; +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + + +using System.Text.Json; using System.Text.Json.Serialization; namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Converters; diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Converters/ValuationJsonConverter.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Converters/ValuationJsonConverter.cs new file mode 100644 index 000000000..289b0c35e --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Converters/ValuationJsonConverter.cs @@ -0,0 +1,96 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using System.Text.Json; +using System.Text.Json.Serialization; +using Finos.Fdc3.Context; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; + +/// +/// JsonConverter to fix the deserialization of the CURRENCY_ISOCODE +/// +public class ValuationJsonConverter : JsonConverter +{ + public override Valuation? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + string? currencyISOCode = null; + string? expiryTime = null, valuationTime = null, name = null; + float? price = null, value = null; + object? id = null; + + if (root.TryGetProperty("CURRENCY_ISOCODE", out var val) + || root.TryGetProperty("currency_isocode", out val) + || root.TryGetProperty("currencY_ISOCODE", out val)) + { + currencyISOCode = val.GetString(); + } + + if (string.IsNullOrEmpty(currencyISOCode)) + { + throw new JsonException($"{nameof(Valuation)} cannot be desrialized as {nameof(currencyISOCode)} is null."); + } + + if (root.TryGetProperty("price", out var priceValue)) + { + price = priceValue.GetSingle(); + } + + if (root.TryGetProperty("value", out var valueValue)) + { + value = valueValue.GetSingle(); + } + + if (root.TryGetProperty("expiryTime", out var expiryTimeValue)) + { + expiryTime = expiryTimeValue.GetString(); + } + + if (root.TryGetProperty("valuationTime", out var valuationTimeValue)) + { + valuationTime = valuationTimeValue.GetString(); + } + + if (root.TryGetProperty("name", out var nameValue)) + { + name = nameValue.GetString(); + } + + if (root.TryGetProperty("id", out var idValue)) + { + id = idValue.GetString(); + } + + return new Valuation(currencyISOCode!, price, value, expiryTime, valuationTime, id, name); + } + + public override void Write(Utf8JsonWriter writer, Valuation value, JsonSerializerOptions options) + { + // Create a copy of options without this converter + var defaultOptions = new JsonSerializerOptions(options); + + // Remove this converter to avoid recursion + var thisConverter = defaultOptions.Converters.FirstOrDefault(c => c.GetType() == typeof(ValuationJsonConverter)); + + if (thisConverter != null) + { + defaultOptions.Converters.Remove(thisConverter); + } + + JsonSerializer.Serialize(writer, value, defaultOptions); + } +} \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/Fdc3DesktopAgentErrors.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/Fdc3DesktopAgentErrors.cs index b656489ba..86a733935 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/Fdc3DesktopAgentErrors.cs +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/Fdc3DesktopAgentErrors.cs @@ -42,7 +42,7 @@ public static class Fdc3DesktopAgentErrors public const string ResponseHasNoAttribute = nameof(ResponseHasNoAttribute); /// - /// Indicates that no user channel set was configured. + /// Indicates that no user channel (null) set was configured. /// public const string NoUserChannelSetFound = nameof(NoUserChannelSetFound); @@ -70,4 +70,19 @@ public static class Fdc3DesktopAgentErrors /// Indicates that the private channel a client tried to join cannot be found /// public const string PrivateChannelNotFound = nameof(PrivateChannelNotFound); + + /// + /// Indicates that an unspecified error occurred in the desktop agent. + /// + public const string UnspecifiedReason = nameof(UnspecifiedReason); + + /// + /// Indicates that null instance was found for the given app identifier. + /// + public const string NoInstanceFound = nameof(NoInstanceFound); + + /// + /// Indicates that null app intent was found for the given criteria. + /// + public const string NoAppIntent = nameof(NoAppIntent); } \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/ThrowHelper.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/ThrowHelper.cs index dbc38fa63..d952f9908 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/ThrowHelper.cs +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Exceptions/ThrowHelper.cs @@ -13,6 +13,7 @@ */ using Finos.Fdc3; +using Finos.Fdc3.Context; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; @@ -75,4 +76,55 @@ internal static Fdc3DesktopAgentException ContextListenerNotCreated(string id, s internal static Fdc3DesktopAgentException InvalidResponseRecevied(string instanceId, string appId, string methodName) => new($"The method: {methodName} returned a not valid response from the server, app: {appId}, instance: {instanceId}."); + + public static Fdc3DesktopAgentException DesktopAgentBackendDidNotResolveRequest(string requestType, string unvalidPropertyName, string errorReason) => + new($"The DesktopAgent backend did not return valid response for {requestType}; it contained unvalid property: {unvalidPropertyName}. Specified reason: {errorReason}."); + + public static Fdc3DesktopAgentException AppIntentMissingFromResponse(string intent, string? contextType = null, string? resultType = null) => + new($"The {nameof(AppIntent)} was not returned by the FDC3 DesktopAgent backend for intent: {intent}; context: {contextType}; and resultType: {resultType}."); + + public static Fdc3DesktopAgentException ChannelNotFound(string channelId, ChannelType channelType) => + new($"The channel with ID: {channelId} and type: {channelType} is not found."); + + public static Fdc3DesktopAgentException IntentResolutionIsNotDefined(string contextType, string? appId = null, string? instanceId = null, string? intent = null) => + new($"The app with ID: {appId} and instanceId: {instanceId} for context: {contextType} could not return an {nameof(IntentResolution)} as message id, the {nameof(AppMetadata)} or the intent: {intent} was not retrieved from the backend."); + + public static Fdc3DesktopAgentException IntentResolutionFailed(string intent, string messageId, IAppIdentifier appIdentifier) => + new($"Retrieving the intent resolution failed from the backend for intent: {intent}, app: {appIdentifier.AppId}, instanceId: {appIdentifier.InstanceId}, messageId: {messageId}."); + + public static Fdc3DesktopAgentException MissingContext() => + new($"No context or invalid context was received as part of the {nameof(IntentResolution)}."); + + public static Fdc3DesktopAgentException MissingOpenedAppContext() => + new("No context was received from the backend."); + + public static Fdc3DesktopAgentException IntentResultStoreFailed(string intent, string instanceId) => + new($"Instance: {instanceId} was not able to store the {nameof(IIntentResult)} on the backend."); + + public static Fdc3DesktopAgentException ListenerNotRegistered(string intent, string instanceId) => + new($"Intent listener is not registered for the intent: {intent} on the backend for instance: {instanceId}."); + + public static Fdc3DesktopAgentException ListenerNotUnRegistered(string intent, string instanceId) => + new($"Intent listener is still registered for the intent: {intent} on the backend for instance: {instanceId}."); + + internal static Fdc3DesktopAgentException MalformedContext() => + new ($"The context is malformed when the fdc3.open method was called for the native client."); + + internal static Fdc3DesktopAgentException AppIdentifierNotRetrieved() => + new($"The {nameof(AppIdentifier)} cannot be returned when calling the fdc3.open from the native client..."); + + internal static Fdc3DesktopAgentException MissingOpenAppContext() => + new("The context id was received while checking if the app is opened via the fdc3.open call..."); + + internal static Fdc3DesktopAgentException PrivateChannelCreationFailed() => + new($"No response was received from the backend. {ChannelError.CreationFailed}."); + + internal static Fdc3DesktopAgentException PrivateChannelDisconnected(string channelId, string instanceId) => + new($"Private channel is disconnected. ChannelId: {channelId}; instance id: {instanceId}."); + + internal static Fdc3DesktopAgentException PrivatChannelSubscribeFailure(string? contextType, string channelId, string instanceId) => + new($"Private channel was not able to add context listener. ChannelId: {channelId}; instance id: {instanceId}; context type: {contextType}."); + + internal static Fdc3DesktopAgentException PrivateChannelJoiningFailed(string channelId) => + new($"Client was not able to join to private channel. ChannelId: {channelId}."); } \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3StartupParameters.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3StartupParameters.cs similarity index 94% rename from src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3StartupParameters.cs rename to src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3StartupParameters.cs index ba9a3621f..39bba5a6f 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3StartupParameters.cs +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3StartupParameters.cs @@ -12,7 +12,7 @@ * and limitations under the License. */ -namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent; +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; /// /// This class is for setting internally the properties that should be injected to the opened app. diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3Topic.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3Topic.cs index 48d22507d..1ccdbe6d5 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3Topic.cs +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/Fdc3Topic.cs @@ -16,7 +16,10 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; -internal static class Fdc3Topic +/// +/// Provides topics for handling messages received via the messaging abstractions. +/// +public static class Fdc3Topic { internal static string TopicRoot => "ComposeUI/fdc3/v2.0/"; internal static string FindChannel => TopicRoot + "findChannel"; @@ -26,7 +29,6 @@ internal static class Fdc3Topic internal static string GetIntentResult => TopicRoot + "getIntentResult"; internal static string SendIntentResult => TopicRoot + "sendIntentResult"; internal static string AddIntentListener => TopicRoot + "addIntentListener"; - internal static string ResolverUI => TopicRoot + "resolverUI"; internal static string CreatePrivateChannel => TopicRoot + "createPrivateChannel"; internal static string CreateAppChannel => TopicRoot + "createAppChannel"; internal static string GetUserChannels => TopicRoot + "getUserChannels"; @@ -40,7 +42,16 @@ internal static class Fdc3Topic internal static string Open => TopicRoot + "open"; internal static string GetOpenedAppContext => TopicRoot + "getOpenedAppContext"; internal static string RaiseIntentForContext => TopicRoot + "raiseIntentForContext"; - internal static string ResolverUIIntent => TopicRoot + "resolverUIIntent"; + + /// + /// Topic for handling ResolverUI intention (raiseIntent) from the backend based on the container. + /// + public static string ResolverUI => TopicRoot + "resolverUI"; + + /// + /// Topic for handling ResolverUI intention (raiseIntentForContext) from the backend based on the container. + /// + public static string ResolverUIIntent => TopicRoot + "resolverUIIntent"; //IntentListeners will be listening at this endpoint internal static string RaiseIntentResolution(string intent, string instanceId) @@ -83,4 +94,9 @@ internal PrivateChannelTopics(string id) : base(id, ChannelType.Private) } public string Events { get; } + + public string GetContextHandlers(bool isOriginalCreator) + { + return ChannelRoot + (isOriginalCreator ? "creator" : "listener") + "/getContextHandlers"; + } } \ No newline at end of file diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.csproj b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.csproj index 556ff16a1..89c72ea2c 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.csproj +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.csproj @@ -3,8 +3,7 @@ netstandard2.0 - Package for FDC3 Desktop Agent implementations and backend for ComposeUI. - Contains shared models, interfaces, and utilities to facilitate building FDC3-compliant desktop agents that enable application interoperability using FDC3 context, intents, and channels. Provides foundational components for app discovery, context sharing, and intent resolution, with integration for FDC3 App Directory and support for dependency injection. Compatible with .NET Standard 2.0. More Details: https://morganstanley.github.io/ComposeUI/ + Package for FDC3 Desktop Agent implementations and backend for ComposeUI. Contains shared models, interfaces, and utilities to facilitate building FDC3-compliant desktop agents that enable application interoperability using FDC3 context, intents, and channels. Provides foundational components for app discovery, context sharing, and intent resolution, with integration for FDC3 App Directory and support for dependency injection. Compatible with .NET Standard 2.0. More Details: https://morganstanley.github.io/ComposeUI/ diff --git a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/SerializerOptionsHelper.cs b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/SerializerOptionsHelper.cs index c554e2257..652c48f70 100644 --- a/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/SerializerOptionsHelper.cs +++ b/src/fdc3/dotnet/DesktopAgent.Shared/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared/SerializerOptionsHelper.cs @@ -34,6 +34,8 @@ public static class SerializerOptionsHelper #if DEBUG WriteIndented = true, #endif + IncludeFields = true, + PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { @@ -45,6 +47,7 @@ public static class SerializerOptionsHelper new ImageJsonConverter(), new IntentMetadataJsonConverter(), new ImplementationMetadataJsonConverter(), + new ValuationJsonConverter(), new IContextJsonConverter(), new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/DependencyInjection/ServiceCollectionExtensions.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/DependencyInjection/ServiceCollectionExtensions.cs index f1a3cdda7..f33de3603 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/DependencyInjection/ServiceCollectionExtensions.cs @@ -46,7 +46,7 @@ public static IServiceCollection AddFdc3DesktopAgent( builder.ServiceCollection.AddSingleton(); builder.ServiceCollection.AddHostedService(); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgent.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgentService.cs similarity index 96% rename from src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgent.cs rename to src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgentService.cs index 69d81d1fc..d47c0cc69 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgent.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3DesktopAgentService.cs @@ -40,9 +40,9 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent; -internal class Fdc3DesktopAgent : IFdc3DesktopAgentBridge +internal class Fdc3DesktopAgentService : IFdc3DesktopAgentService { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IResolverUICommunicator _resolverUI; private readonly IUserChannelSetReader _userChannelSetReader; private readonly ConcurrentDictionary _userChannels = new(); @@ -64,7 +64,7 @@ internal class Fdc3DesktopAgent : IFdc3DesktopAgentBridge private readonly object _privateChannelsDictionaryLock = new(); private readonly IntentResolver _intentResolver; - public Fdc3DesktopAgent( + public Fdc3DesktopAgentService( IAppDirectory appDirectory, IModuleLoader moduleLoader, IOptions options, @@ -78,7 +78,7 @@ public Fdc3DesktopAgent( _resolverUI = resolverUI; _userChannelSetReader = userChannelSetReader; _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; - _logger = _loggerFactory.CreateLogger() ?? NullLogger.Instance; + _logger = _loggerFactory.CreateLogger() ?? NullLogger.Instance; _intentResolver = new(appDirectory, _runningModules); } @@ -135,6 +135,7 @@ public async ValueTask CreateOrJoinPrivateChannel(Func a _logger.LogError($"Could not create private channel while executing {nameof(CreateOrJoinPrivateChannel)} due to private channel id is null."); return; } + PrivateChannel? privateChannel = null; try { @@ -202,7 +203,8 @@ private void SafeRemoveFromPrivateChannelsDictionary(string instanceId, PrivateC public async ValueTask AddAppChannel(Func addAppChannelFactory, CreateAppChannelRequest request) { - if (!_runningModules.TryGetValue(new Guid(request.InstanceId), out _)) + if (!_runningModules.TryGetValue(new Guid(request.InstanceId), out _) + || string.IsNullOrEmpty(request.ChannelId)) { return CreateAppChannelResponse.Failed(ChannelError.CreationFailed); } @@ -690,6 +692,11 @@ public async ValueTask GetAppMetadata(GetAppMetadataRequ return OpenResponse.Failure(Fdc3DesktopAgentErrors.PayloadNull); } + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug($"Executing {nameof(Open)} request for appId: {request.AppIdentifier?.AppId ?? string.Empty} with instanceId: {request.InstanceId}."); + } + if (!Guid.TryParse(request.InstanceId, out var fdc3InstanceId) || !_runningModules.ContainsKey(fdc3InstanceId)) { @@ -700,7 +707,7 @@ public async ValueTask GetAppMetadata(GetAppMetadataRequ try { - var fdc3App = await _appDirectory.GetApp(request.AppIdentifier.AppId); + var fdc3App = await _appDirectory.GetApp(request.AppIdentifier!.AppId); var appMetadata = fdc3App.ToAppMetadata(); var parameters = new Dictionary(); @@ -800,9 +807,9 @@ public async ValueTask> RaiseIntentForCon _logger.LogWarning("Source app did not register its raiseable intent(s) for context: {ContextType} in the `raises` section of AppDirectory.", contextType); } - var findIntentsByContextResult = await FindIntentsByContext(new FindIntentsByContextRequest() { Context = request.Context, Fdc3InstanceId = request.Fdc3InstanceId }, contextType); + var filteredAppIntents = await GetAppIntentsByRequest(contextType: contextType, targetAppIdentifier: request.TargetAppIdentifier); - if (findIntentsByContextResult.AppIntents == null || !findIntentsByContextResult.AppIntents.Any()) + if (filteredAppIntents.AppIntents == null || !filteredAppIntents.AppIntents.Any()) { return new() { @@ -810,21 +817,11 @@ public async ValueTask> RaiseIntentForCon }; } - List result; - if (request.TargetAppIdentifier != null) - { - result = FilterAppIntentsByAppId(findIntentsByContextResult.AppIntents, request.TargetAppIdentifier).ToList(); - } - else - { - result = findIntentsByContextResult.AppIntents.ToList(); - } - RaiseIntentSpecification raiseIntentSpecification; - if (result.Count > 1) + if (filteredAppIntents.AppIntents.Count > 1) { using var resolverUIIntentCancellationSource = new CancellationTokenSource(TimeSpan.FromMinutes(2)); - var resolverUIIntentResponse = await _resolverUI.SendResolverUIIntentRequest(result.Select(x => x.Intent.Name), resolverUIIntentCancellationSource.Token); + var resolverUIIntentResponse = await _resolverUI.SendResolverUIIntentRequest(filteredAppIntents.AppIntents.Select(x => x.Value.Intent.Name), resolverUIIntentCancellationSource.Token); if (resolverUIIntentResponse == null) { @@ -856,19 +853,28 @@ public async ValueTask> RaiseIntentForCon SourceAppInstanceId = new(request.Fdc3InstanceId) }; } + else if (filteredAppIntents.AppIntents.Count == 0) + { + return new() + { + Response = new() + { + Error = ResolveError.IntentDeliveryFailed + } + }; + } else { raiseIntentSpecification = new() { Context = request.Context, - Intent = result.ElementAt(0).Intent.Name, + Intent = filteredAppIntents.AppIntents.Values.First()!.Intent.Name, RaisedIntentMessageId = request.MessageId, SourceAppInstanceId = new(request.Fdc3InstanceId) }; } - var appIntent = result.FirstOrDefault(x => x.Intent.Name == raiseIntentSpecification.Intent); - if (appIntent == null) + if (!filteredAppIntents.AppIntents.TryGetValue(raiseIntentSpecification.Intent, out var appIntent)) { return new() { @@ -996,6 +1002,7 @@ private static IEnumerable FilterAppIntentsByAppId(IEnumerable validatedAppMetadata = new(); foreach (var app in intent.Apps) { var matches = true; @@ -1003,6 +1010,7 @@ private static IEnumerable FilterAppIntentsByAppId(IEnumerable FilterAppIntentsByAppId(IEnumerable 0) + { + var appIntent = new AppIntent + { + Intent = intent.Intent, + Apps = validatedAppMetadata + }; + + yield return appIntent; } } } @@ -1311,8 +1330,7 @@ private async Task GetAppIntentsByRequest( return result; } - private Dictionary GetAppIntentsFromIntentMetadataCollection( - IEnumerable intentMetadataCollection) + private Dictionary GetAppIntentsFromIntentMetadataCollection(IEnumerable intentMetadataCollection) { Dictionary appIntents = []; diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3ShutdownAction.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3ShutdownAction.cs index 8dde55e51..e848faaf9 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3ShutdownAction.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Fdc3ShutdownAction.cs @@ -36,7 +36,7 @@ public async Task InvokeAsync(ShutdownContext shutDownContext, Func next, { if (shutDownContext.ModuleInstance.Manifest.ModuleType == ModuleType.Web) { - var desktopAgent = _serviceProvider.GetRequiredService(); + var desktopAgent = _serviceProvider.GetRequiredService(); var fdc3InstanceId = shutDownContext.ModuleInstance.GetProperties().FirstOrDefault()?.InstanceId; diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/Fdc3DesktopAgentMessagingService.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/Fdc3DesktopAgentMessagingService.cs index d717a1dd5..a90055d22 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/Fdc3DesktopAgentMessagingService.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/Fdc3DesktopAgentMessagingService.cs @@ -14,7 +14,6 @@ using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; using Finos.Fdc3; using Finos.Fdc3.Context; using Microsoft.Extensions.Hosting; @@ -25,7 +24,6 @@ using MorganStanley.ComposeUI.Fdc3.DesktopAgent.DependencyInjection; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Contracts; -using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Converters; using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared.Exceptions; using MorganStanley.ComposeUI.Messaging.Abstractions; @@ -34,7 +32,7 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Infrastructure.Internal; internal class Fdc3DesktopAgentMessagingService : IHostedService { private readonly IMessaging _messaging; - private readonly IFdc3DesktopAgentBridge _desktopAgent; + private readonly IFdc3DesktopAgentService _desktopAgent; private readonly Fdc3DesktopAgentOptions _options; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; @@ -46,7 +44,7 @@ internal class Fdc3DesktopAgentMessagingService : IHostedService public Fdc3DesktopAgentMessagingService( IMessaging messaging, - IFdc3DesktopAgentBridge desktopAgent, + IFdc3DesktopAgentService desktopAgent, IOptions options, ILoggerFactory? loggerFactory = null) { diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IFdc3DesktopAgentBridge.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IFdc3DesktopAgentService.cs similarity index 99% rename from src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IFdc3DesktopAgentBridge.cs rename to src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IFdc3DesktopAgentService.cs index 94cb71c0f..0587cfe30 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IFdc3DesktopAgentBridge.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IFdc3DesktopAgentService.cs @@ -18,7 +18,7 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Infrastructure.Internal; -internal interface IFdc3DesktopAgentBridge +internal interface IFdc3DesktopAgentService { /// /// Triggers the necessary events like ModuleLoader's Subscribe when Startup. diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IntentResolver.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IntentResolver.cs index cfe1036e3..5ce0a3c62 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IntentResolver.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/IntentResolver.cs @@ -150,10 +150,12 @@ private async Task> MatchSpecificInstance( { throw ThrowHelper.TargetInstanceUnavailable(); } + if (otherFilters) { throw ThrowHelper.NoAppsFound(); } + if (appIdentifier != null) { try diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/RaiseIntentResult.cs b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/RaiseIntentResult.cs index d6f0c3a35..44738c693 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/RaiseIntentResult.cs +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/Infrastructure/Internal/RaiseIntentResult.cs @@ -19,7 +19,7 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Infrastructure.Internal; internal partial class RaiseIntentResult { /// - /// Response for the call. + /// Response for the call. /// public TResponse Response { get; set; } diff --git a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/MorganStanley.ComposeUI.Fdc3.DesktopAgent.csproj b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/MorganStanley.ComposeUI.Fdc3.DesktopAgent.csproj index 62b30cd0d..13a78d8b3 100644 --- a/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/MorganStanley.ComposeUI.Fdc3.DesktopAgent.csproj +++ b/src/fdc3/dotnet/DesktopAgent/src/MorganStanley.ComposeUI.Fdc3.DesktopAgent/MorganStanley.ComposeUI.Fdc3.DesktopAgent.csproj @@ -57,7 +57,7 @@ - + diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentTests.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTests.cs similarity index 99% rename from src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentTests.cs rename to src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTests.cs index 60cdeca7a..2fef2d7af 100644 --- a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentTests.cs +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTests.cs @@ -36,9 +36,9 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests; -public partial class Fdc3DesktopAgentTests : Fdc3DesktopAgentTestsBase +public partial class Fdc3DesktopAgentServiceTests : Fdc3DesktopAgentServiceTestsBase { - public Fdc3DesktopAgentTests() : base(AppDirectoryPath) { } + public Fdc3DesktopAgentServiceTests() : base(AppDirectoryPath) { } [Fact] public async Task AddUserChannel_wont_throw_and_adds_channel() @@ -476,7 +476,7 @@ public async Task GetUserChannels_returns_empty_userChannel_set() UserChannelConfigFile = new Uri("C://hello/world/test.json"), }; - var fdc3 = new Fdc3DesktopAgent( + var fdc3 = new Fdc3DesktopAgentService( AppDirectory, ModuleLoader.Object, options, diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentTestsBase.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTestsBase.cs similarity index 93% rename from src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentTestsBase.cs rename to src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTestsBase.cs index cf89c9a1e..59bdc77d7 100644 --- a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentTestsBase.cs +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Fdc3DesktopAgentServiceTestsBase.cs @@ -24,11 +24,11 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests; -public abstract class Fdc3DesktopAgentTestsBase : IAsyncLifetime +public abstract class Fdc3DesktopAgentServiceTestsBase : IAsyncLifetime { protected IAppDirectory AppDirectory { get; } - internal IFdc3DesktopAgentBridge Fdc3 { get; } + internal IFdc3DesktopAgentService Fdc3 { get; } protected MockModuleLoader ModuleLoader { get; } = new(); protected Mock ResolverUICommunicator { get; } = new(); internal Mock> Logger { get; } = new(); @@ -37,7 +37,7 @@ public abstract class Fdc3DesktopAgentTestsBase : IAsyncLifetime private readonly ConcurrentDictionary _modules = new(); private IDisposable? _disposable; - public Fdc3DesktopAgentTestsBase(string appDirectorySource) + public Fdc3DesktopAgentServiceTestsBase(string appDirectorySource) { AppDirectory = new AppDirectory.AppDirectory( new AppDirectoryOptions @@ -59,7 +59,7 @@ public Fdc3DesktopAgentTestsBase(string appDirectorySource) .Setup(_ => _.CreateLogger(It.IsAny())) .Returns(Logger.Object); - Fdc3 = new Fdc3DesktopAgent( + Fdc3 = new Fdc3DesktopAgentService( AppDirectory, ModuleLoader.Object, options, diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/FindIntentTests.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/FindIntentTests.cs index e3f2c0772..e56da2429 100644 --- a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/FindIntentTests.cs +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/FindIntentTests.cs @@ -20,7 +20,7 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests; -public class FindIntentTests : Fdc3DesktopAgentTestsBase +public class FindIntentTests : Fdc3DesktopAgentServiceTestsBase { public FindIntentTests() : base(AppDirectoryPath) { } diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/FindIntentsByContextTests.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/FindIntentsByContextTests.cs index 80d02c27c..cea7153f2 100644 --- a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/FindIntentsByContextTests.cs +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/FindIntentsByContextTests.cs @@ -20,7 +20,7 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests; -public class FindIntentsByContextTests : Fdc3DesktopAgentTestsBase +public class FindIntentsByContextTests : Fdc3DesktopAgentServiceTestsBase { public FindIntentsByContextTests() : base(AppDirectoryPath) { } diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Infrastructure/Internal/Fdc3DesktopAgentMessagingServiceTests.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Infrastructure/Internal/Fdc3DesktopAgentMessagingServiceTests.cs index 254f0da2a..18a51a9d1 100644 --- a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Infrastructure/Internal/Fdc3DesktopAgentMessagingServiceTests.cs +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Infrastructure/Internal/Fdc3DesktopAgentMessagingServiceTests.cs @@ -68,7 +68,7 @@ public Fdc3DesktopAgentMessagingServiceTests() _fdc3 = new Fdc3DesktopAgentMessagingService( _mockMessaging.Object, - new Fdc3DesktopAgent( + new Fdc3DesktopAgentService( _appDirectory, _mockModuleLoader.Object, options, diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/RaiseIntentForContextTests.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/RaiseIntentForContextTests.cs index d214ca33d..eff4ecc40 100644 --- a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/RaiseIntentForContextTests.cs +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/RaiseIntentForContextTests.cs @@ -22,7 +22,7 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests; -public class RaiseIntentForContextTests : Fdc3DesktopAgentTestsBase +public class RaiseIntentForContextTests : Fdc3DesktopAgentServiceTestsBase { public RaiseIntentForContextTests() : base(AppDirectoryPath) { } diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/RaiseIntentTests.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/RaiseIntentTests.cs index eb17730dc..8cd9417ca 100644 --- a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/RaiseIntentTests.cs +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/RaiseIntentTests.cs @@ -23,7 +23,7 @@ namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests; -public class RaiseIntentTests : Fdc3DesktopAgentTestsBase +public class RaiseIntentTests : Fdc3DesktopAgentServiceTestsBase { public RaiseIntentTests() : base(AppDirectoryPath) { } diff --git a/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Shared.Converters/ValuationJsonConverter.Tests.cs b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Shared.Converters/ValuationJsonConverter.Tests.cs new file mode 100644 index 000000000..a1d6bfb0d --- /dev/null +++ b/src/fdc3/dotnet/DesktopAgent/test/MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests/Shared.Converters/ValuationJsonConverter.Tests.cs @@ -0,0 +1,108 @@ +/* + * Morgan Stanley makes this available to you under the Apache License, + * Version 2.0 (the "License"). You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0. + * + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. Unless required by applicable law or agreed + * to in writing, software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +using Finos.Fdc3.Context; +using MorganStanley.ComposeUI.Fdc3.DesktopAgent.Shared; +using System.Text.Json; + +namespace MorganStanley.ComposeUI.Fdc3.DesktopAgent.Tests.Shared.Converters; + +public class ValuationJsonConverterTests +{ + private readonly JsonSerializerOptions _options; + + public ValuationJsonConverterTests() + { + _options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + _options.Converters.Add(new ValuationJsonConverter()); + } + + [Theory] + [InlineData("CURRENCY_ISOCODE")] + [InlineData("currency_isocode")] + [InlineData("currencY_ISOCODE")] + public void Read_Should_Deserialize_With_Any_CurrencyIsoCode_Casing(string currencyKey) + { + var json = $@"{{ + ""{currencyKey}"": ""USD"", + ""price"": 123.45, + ""value"": 678.90, + ""expiryTime"": ""2024-01-01T00:00:00Z"", + ""valuationTime"": ""2024-01-02T00:00:00Z"", + ""id"": ""abc123"", + ""name"": ""TestValuation"" + }}"; + + var result = JsonSerializer.Deserialize(json, _options); + + result.Should().NotBeNull(); + result!.CURRENCY_ISOCODE.Should().Be("USD"); + result.Price.Should().Be(123.45f); + result.Value.Should().Be(678.90f); + result.ExpiryTime.Should().Be("2024-01-01T00:00:00Z"); + result.ValuationTime.Should().Be("2024-01-02T00:00:00Z"); + result.ID.Should().Be("abc123"); + result.Name.Should().Be("TestValuation"); + } + + [Fact] + public void Read_Should_Throw_If_CurrencyIsoCode_Missing() + { + var json = @"{ + ""price"": 123.45 + }"; + + Action act = () => JsonSerializer.Deserialize(json, _options); + + act.Should().Throw() + .WithMessage("*currencyISOCode*null*"); + } + + [Fact] + public void Read_Should_Handle_Missing_Optional_Properties() + { + var json = @"{ + ""CURRENCY_ISOCODE"": ""EUR"" + }"; + + var result = JsonSerializer.Deserialize(json, _options); + + result.Should().NotBeNull(); + result!.CURRENCY_ISOCODE.Should().Be("EUR"); + result.Price.Should().BeNull(); + result.Value.Should().BeNull(); + result.ExpiryTime.Should().BeNull(); + result.ValuationTime.Should().BeNull(); + result.ID.Should().BeNull(); + result.Name.Should().BeNull(); + } + + [Fact] + public void Write_Should_Serialize_Valuation() + { + var valuation = new Valuation( + "JPY", 1.23f, 4.56f, "2024-06-01T00:00:00Z", "2024-06-02T00:00:00Z", "id-xyz", "ValName" + ); + + var json = JsonSerializer.Serialize(valuation, _options); + + json.Should().Contain("\"currencY_ISOCODE\":\"JPY\"") + .And.Contain("\"price\":1.23") + .And.Contain("\"value\":4.56") + .And.Contain("\"expiryTime\":\"2024-06-01T00:00:00Z\"") + .And.Contain("\"valuationTime\":\"2024-06-02T00:00:00Z\"") + .And.Contain("\"id\":\"id-xyz\"") + .And.Contain("\"name\":\"ValName\""); + } +} diff --git a/src/messaging/dotnet/src/Abstractions/Delegates.cs b/src/messaging/dotnet/src/Abstractions/Delegates.cs index eda17dc39..8c60d45ab 100644 --- a/src/messaging/dotnet/src/Abstractions/Delegates.cs +++ b/src/messaging/dotnet/src/Abstractions/Delegates.cs @@ -17,6 +17,14 @@ namespace MorganStanley.ComposeUI.Messaging.Abstractions; /// public delegate ValueTask TopicMessageHandler(string payload); +/// +/// A generic delegate type to be used with typed topic subscriptions e.g. +/// +/// +/// +/// +public delegate ValueTask TopicMessageHandler(T payload); + /// /// The delegate type that gets called when a service is invoked /// diff --git a/src/messaging/dotnet/src/Abstractions/MessagingServiceJsonExtensions.cs b/src/messaging/dotnet/src/Abstractions/MessagingServiceJsonExtensions.cs index 13dc65b60..ede98fa9a 100644 --- a/src/messaging/dotnet/src/Abstractions/MessagingServiceJsonExtensions.cs +++ b/src/messaging/dotnet/src/Abstractions/MessagingServiceJsonExtensions.cs @@ -99,6 +99,40 @@ public static ValueTask RegisterJsonServiceAsync + /// Subscribes to a topic with . The topic handler will deserialize the incoming JSON string to the specified type. + /// + /// + /// + /// + /// + /// + /// + /// + public static ValueTask SubscribeJsonAsync( + this IMessaging messaging, + string topic, + TopicMessageHandler typedHandler, + JsonSerializerOptions jsonSerializerOptions, + CancellationToken cancellationToken = default) + { + return messaging.SubscribeAsync(topic, CreateJsonTopicMessageHandler(typedHandler, jsonSerializerOptions), cancellationToken); + } + + private static TopicMessageHandler CreateJsonTopicMessageHandler(TopicMessageHandler typedHandler, JsonSerializerOptions jsonSerializerOptions) + { + if (typeof(TRequest) == typeof(string)) + { + throw new MessagingException("NonJsonTopic", "The handler provided accepts a string as input. This extension does not support that use-case. Use SubscribeAsync directly to register such a topic."); + } + + return async (payload) => + { + var request = JsonSerializer.Deserialize(payload, jsonSerializerOptions); + await typedHandler(request); + }; + } + private static ServiceHandler CreateJsonServiceHandler(ServiceHandler realHandler, JsonSerializerOptions jsonSerializerOptions) { if (typeof(TRequest) == typeof(string)) diff --git a/src/messaging/js/composeui-messaging-abstractions/README.md b/src/messaging/js/composeui-messaging-abstractions/README.md index ca48e6642..f6a69af6c 100644 --- a/src/messaging/js/composeui-messaging-abstractions/README.md +++ b/src/messaging/js/composeui-messaging-abstractions/README.md @@ -1,4 +1,4 @@ -@morgan-stanley/composeui-messaging-abstractions +# @morgan-stanley/composeui-messaging-abstractions Messaging helpers that wrap a lower‑level IMessaging abstraction (publish/subscribe plus request/response). Provides simple, typed APIs for sending and receiving structured data as JSON without duplicating serialization logic in callers. Provides JSON focused extension methods available using the `JsonMessaging` class. @@ -12,17 +12,17 @@ Messaging helpers that wrap a lower‑level IMessaging abstraction (publish/subs ## Installation -Install as a workspace dependency (pnpm/yarn/npm): +Install as a workspace dependency ```bash -npm install @your-scope/composeui-messaging-abstractions +npm install @morgan-stanley/composeui-messaging-abstractions ``` ## Usage Implement the `IMessaging` API to declare your own communication. ```typescript -import { IMessaging } from "@morgant-stanley/composeui-messaging-abstractions"; +import { IMessaging } from "@morgan-stanley/composeui-messaging-abstractions"; import { HubConnection } from '@microsoft/signalr'; export class MyMessaging implements IMessaging { @@ -40,10 +40,10 @@ Rollup emits dual builds: package.json exports map require to CJS and import to ESM. Use: ```typescript // CJS -const { JsonMessaging } = require('@your-scope/composeui-messaging-abstractions'); +const { JsonMessaging } = require('@morgan-stanley/composeui-messaging-abstractions'); // ESM / TypeScript -import { JsonMessaging } from '@your-scope/composeui-messaging-abstractions'; +import { JsonMessaging } from '@morgan-stanley/composeui-messaging-abstractions'; ``` ## Dependencies diff --git a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/NativeModuleRunner.cs b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/NativeModuleRunner.cs index c4eaf3f4b..b92389e5c 100644 --- a/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/NativeModuleRunner.cs +++ b/src/module-loader/dotnet/src/MorganStanley.ComposeUI.ModuleLoader/Runners/NativeModuleRunner.cs @@ -56,10 +56,12 @@ public async Task Start(StartupContext startupContext, Func pipeline) foreach (var envVar in startupContext.GetProperties().SelectMany(x => x.Variables)) { // TODO: what to do with duplicate envvars? - if (!mainProcess.StartInfo.EnvironmentVariables.ContainsKey(envVar.Key)) + if (mainProcess.StartInfo.EnvironmentVariables.ContainsKey(envVar.Key)) { - mainProcess.StartInfo.EnvironmentVariables.Add(envVar.Key, envVar.Value); + mainProcess.StartInfo.EnvironmentVariables.Remove(envVar.Key); } + + mainProcess.StartInfo.EnvironmentVariables.Add(envVar.Key, envVar.Value); } var unexpectedStopHandlers = startupContext.GetProperties();