diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5570ab9b3e..eac5f78721 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,6 +51,9 @@ jobs: - name: dotnet build run: npm run build:data-release + - name: run dotnet unit tests + run: dotnet test c-sharp-tests/c-sharp-tests.csproj + - name: check dotnet formatting run: cd c-sharp && dotnet tool restore && dotnet csharpier --check . diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 094f933e6d..bc71922e91 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "dbaeumer.vscode-eslint", "editorconfig.editorconfig", "esbenp.prettier-vscode", + "formulahendry.dotnet-test-explorer", "mrmlnc.vscode-json5", "streetsidesoftware.code-spell-checker" ] diff --git a/.vscode/settings.json b/.vscode/settings.json index f735a1362f..c477505c74 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,6 @@ { + "dotnet-test-explorer.testProjectPath": "c-sharp-tests/c-sharp-tests.csproj", + "editor.defaultFormatter": "esbenp.prettier-vscode", "[csharp]": { "editor.defaultFormatter": "csharpier.csharpier-vscode" diff --git a/c-sharp-tests/.gitignore b/c-sharp-tests/.gitignore new file mode 100644 index 0000000000..8ff25832a2 --- /dev/null +++ b/c-sharp-tests/.gitignore @@ -0,0 +1,398 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +# *.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml \ No newline at end of file diff --git a/c-sharp-tests/JsonUtils/MessageConverterTests.cs b/c-sharp-tests/JsonUtils/MessageConverterTests.cs new file mode 100644 index 0000000000..2d91e52445 --- /dev/null +++ b/c-sharp-tests/JsonUtils/MessageConverterTests.cs @@ -0,0 +1,93 @@ +using Paranext.DataProvider.JsonUtils; +using Paranext.DataProvider.Messages; +using System.Text.Json; + +namespace TestParanextDataProvider.JsonUtils; + +public class MessageConverterTests +{ + [Test] + public void Deserialize_Event_SenderIdIsCorrect() + { + string messageToDecode = """ + {"type":"event","eventType":"does-not-matter","senderId":12345,"event":{"also-does-not-matter":""}} + """; + var msg = DeserializeMessageEvent(messageToDecode); + Assert.That(msg.SenderId, Is.EqualTo(12345)); + } + + [Test] + public void Deserialize_ClientConnect_StronglyTypedContentsAreCorrect() + { + string messageToDecode = """ + {"type":"event","eventType":"network:onDidClientConnect","senderId":0,"event":{"clientId":3,"didReconnect":false}} + """; + var msg = DeserializeMessageEvent(messageToDecode); + Assert.That(msg.EventContents!.ClientId, Is.EqualTo(3)); + Assert.That(msg.EventContents!.DidReconnect, Is.False); + } + + [Test] + public void Deserialize_ClientDisconnect_StronglyTypedContentsAreCorrect() + { + string messageToDecode = """ + {"type":"event","eventType":"network:onDidClientDisconnect","senderId":0,"event":{"clientId":123}} + """; + var msg = DeserializeMessageEvent(messageToDecode); + Assert.That(msg.EventContents!.ClientId, Is.EqualTo(123)); + } + + [Test] + public void Deserialize_ObjectDispose_StronglyTypedContentsAreCorrect() + { + string messageToDecode = """ + {"type":"event","eventType":"object:onDidDisposeNetworkObject","senderId":0,"event":"test-main"} + """; + var msg = DeserializeMessageEvent(messageToDecode); + Assert.That(msg.EventContents!, Is.EqualTo("test-main")); + } + + [Test] + public void Deserialize_UnknownEventType_ProducesMessageEvent() + { + string messageToDecode = """ + {"type":"event","eventType":"no-such-event","senderId":0,"event":"What type am I?"} + """; + var msg = DeserializeMessageEvent(messageToDecode); + Assert.That(msg.Event!.ToString(), Is.EqualTo("What type am I?")); + } + + private static MessageType DeserializeMessageEvent(string messageToDecode) + where MessageType : MessageEvent + { + JsonSerializerOptions so = JsonSerializerOptionsForTesting; + + var msg = JsonSerializer.Deserialize(messageToDecode, so); + Assert.That(msg, Is.Not.Null); + Assert.That(msg.Event, Is.Not.Null); + + string reserializedMessage = JsonSerializer.Serialize(msg, so); + var msg2 = JsonSerializer.Deserialize(reserializedMessage, so); + Assert.That(msg2, Is.Not.Null); + Assert.That(msg2.Event, Is.Not.Null); + + // Make sure that we get the same event contents when doing a round trip through serialization/deserialization + if (msg.GetType() == typeof(MessageEvent)) + // Short cut to check equality of JsonElements + Assert.That(msg.Event!.ToString(), Is.EqualTo(msg2.Event!.ToString())); + else + Assert.That(msg.Event, Is.EqualTo(msg2.Event)); + + return msg; + } + + private static JsonSerializerOptions JsonSerializerOptionsForTesting + { + get + { + JsonSerializerOptions so = SerializationOptions.CreateSerializationOptions(); + so.Converters.Add(new MessageConverter()); + return so; + } + } +} diff --git a/c-sharp-tests/MessageHandlers/MessageHandlerEventTests.cs b/c-sharp-tests/MessageHandlers/MessageHandlerEventTests.cs new file mode 100644 index 0000000000..6dd92d6601 --- /dev/null +++ b/c-sharp-tests/MessageHandlers/MessageHandlerEventTests.cs @@ -0,0 +1,89 @@ +using Paranext.DataProvider.MessageHandlers; +using Paranext.DataProvider.Messages; +using PtxUtils; + +namespace TestParanextDataProvider.MessageHandlers; + +public class MessageHandlerEventTests +{ + [Test] + public void HandleMessage_NoHandlers_NothingHappens() + { + MessageHandlerEvent mhe = new(); + + VerifyResults(mhe.HandleMessage(TestMessage), 0); + } + + [Test] + public void HandleMessage_OneHandler_RegistrationWorks() + { + MessageHandlerEvent mhe = new(); + + mhe.RegisterEventHandler(TestEventType, ProcessEvent1); + VerifyResults(mhe.HandleMessage(TestMessage), 1); + + mhe.RegisterEventHandler(TestEventType, ProcessEvent1); + VerifyResults(mhe.HandleMessage(TestMessage), 1); + + mhe.UnregisterEventHandler(TestEventType, ProcessEvent1); + VerifyResults(mhe.HandleMessage(TestMessage), 0); + } + + [Test] + public void HandleMessage_SeveralHandlers_RegistrationWorks() + { + MessageHandlerEvent mhe = new(); + + mhe.RegisterEventHandler(TestEventType, ProcessEvent1); + mhe.RegisterEventHandler(TestEventType, ProcessEvent2); + VerifyResults(mhe.HandleMessage(TestMessage), 2); + + mhe.UnregisterEventHandler(TestEventType, ProcessEvent2); + VerifyResults(mhe.HandleMessage(TestMessage), 1); + + mhe.RegisterEventHandler(TestEventType, ProcessEvent2); + mhe.RegisterEventHandler(TestEventType, ProcessEvent3); + VerifyResults(mhe.HandleMessage(TestMessage), 3); + + mhe.RegisterEventHandler(TestEventType, ProcessEvent1); + VerifyResults(mhe.HandleMessage(TestMessage), 3); + } + + private static void VerifyResults(IEnumerable messages, int expectedCount) + { + Assert.That(messages.Count(), Is.EqualTo(expectedCount)); + + if (expectedCount == 0) + return; + + List messageContents = new(); + foreach (var msg in messages) + { + messageContents.Add(((MessageEvent)msg).Event); + } + messageContents.Sort(); + for (int i = 0; i < expectedCount; i++) + { + Assert.That(messageContents[i], Is.EqualTo((i + 1).ToString())); + } + } + + private static Enum TestEventType => EventType.ObjectDispose; + + private static MessageEvent TestMessage => new MessageEventObjectDispose("test"); + + private Message? ProcessEvent1(MessageEvent messageEvent) + { + return new MessageEventObjectDispose("1"); + } + + private Message? ProcessEvent2(MessageEvent messageEvent) + { + return new MessageEventObjectDispose("2"); + } + + private Message? ProcessEvent3(MessageEvent messageEvent) + { + return new MessageEventObjectDispose("3"); + } +} diff --git a/c-sharp-tests/Usings.cs b/c-sharp-tests/Usings.cs new file mode 100644 index 0000000000..a2ef4115a6 --- /dev/null +++ b/c-sharp-tests/Usings.cs @@ -0,0 +1,2 @@ +global using NUnit.Framework; + diff --git a/c-sharp-tests/c-sharp-tests.csproj b/c-sharp-tests/c-sharp-tests.csproj new file mode 100644 index 0000000000..6e48aa1d70 --- /dev/null +++ b/c-sharp-tests/c-sharp-tests.csproj @@ -0,0 +1,25 @@ + + + + net7.0 + enable + enable + + false + true + x64 + + + + + + + + + + + + + + + diff --git a/c-sharp/AssemblyInfo.cs b/c-sharp/AssemblyInfo.cs new file mode 100644 index 0000000000..2598f24440 --- /dev/null +++ b/c-sharp/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("c-sharp-tests")] diff --git a/c-sharp/JsonUtils/MessageConverter.cs b/c-sharp/JsonUtils/MessageConverter.cs index 1100b94dec..cf2185fce4 100644 --- a/c-sharp/JsonUtils/MessageConverter.cs +++ b/c-sharp/JsonUtils/MessageConverter.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using Paranext.DataProvider.Messages; @@ -10,17 +11,82 @@ namespace Paranext.DataProvider.JsonUtils; /// internal sealed class MessageConverter : JsonConverter { - private static readonly JsonSerializerOptions recursiveSafeOptions = + private static readonly JsonSerializerOptions s_jsonOptions = SerializationOptions.CreateSerializationOptions(); - private static readonly Dictionary, Type> messageTypeMap = - new() + + // Our type hierarchy for messages follows this pattern: + // Base (abstract) <- Messages <- Base subtype (abstract) <- Message Subtypes + // ^ + // |-- Not all messages have subtypes + // For example: + // Message <- MessageRequest + // Message <- MessageEvent <- MessageEventGeneric <- MessageEventClientConnect + // + // All non-abstract types have Enum values that can be seen in raw message JSON. + // s_messageTypeMap holds a mapping from Enum values to the .NET types. + // It does not contain message subtypes, only individual message types. + // For example, it maps "event" to MessageEvent and nothing to MessageEventClientConnect. + private static readonly Dictionary, Type> s_messageTypeMap = new(); + + // For event messages, all but MessageEventGeneric (since it is abstract) hold an + // Enum value that can be discerned from raw message JSON. + // s_eventTypeMap holds a mapping from Enum values to .NET types. + // It doesn't contain all message types, only event subtypes (subtypes of MessageEventGeneric). + // For example, it maps "network:onDidClientConnect" to "MessageEventClientConnect". + private static readonly Dictionary, Type> s_eventTypeMap = new(); + + static MessageConverter() + { + foreach (var msg in GetObjectsOfClosestSubtypes()) + { + s_messageTypeMap.Add(msg.Type, msg.GetType()); + } + + foreach (var evt in GetObjectsOfClosestSubtypes()) + { + s_eventTypeMap.Add(evt.EventType, evt.GetType()); + } + } + + /// + /// "Closest" means there isn't a subclass in the hierarchy that can be created. For example: + /// C is a subclass of B which is a subclass of A. + /// When calling this for A, if B is abstract, then an object of type C will be returned. + /// When calling this for A, if B is not abstract, then an object of type B will be returned. + /// + private static IEnumerable GetObjectsOfClosestSubtypes() + where BaseType : class + { + var possibilities = Assembly + .GetExecutingAssembly() + .GetTypes() + // Note that "IsSubclassOf" goes arbitrarily deep in the hierarchy + .Where(t => t.IsClass && !t.IsAbstract && t.IsSubclassOf(typeof(BaseType))) + .Select(type => Activator.CreateInstance(type, true) as BaseType) + .Where(obj => obj is not null); + + foreach (var obj in possibilities) { - { MessageType.InitClient, typeof(MessageInitClient) }, - { MessageType.ClientConnect, typeof(MessageClientConnect) }, - { MessageType.Request, typeof(MessageRequest) }, - { MessageType.Response, typeof(MessageResponse) }, - { MessageType.Event, typeof(MessageEvent) }, - }; + var baseType = obj!.GetType().BaseType; + while (baseType != null) + { + if (baseType == typeof(BaseType)) + { + yield return obj; + break; + } + + if (baseType.IsAbstract) + { + baseType = baseType.BaseType; + continue; + } + + // At this point, a closer, creatable (i.e., non-abstract) subclass was found + break; + } + } + } public override bool CanConvert(Type typeToConvert) => typeof(Message).IsAssignableFrom(typeToConvert); @@ -34,14 +100,26 @@ JsonSerializerOptions options if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException("Not at start of JSON object"); - Utf8JsonReader readerClone = reader; // Make copy of reader state (struct copy) - - Enum messageType = ReadType(ref readerClone); - if (!messageTypeMap.TryGetValue(messageType, out Type? messageDataType)) + // Find the right message type to deserialize + Enum messageType = ReadValue(reader, "type"); + if (!s_messageTypeMap.TryGetValue(messageType, out Type? messageDataType)) throw new ArgumentException("Unexpected message type: " + messageType); - return (Message) - JsonSerializer.Deserialize(ref reader, messageDataType!, recursiveSafeOptions)!; + // Provide a more specific type for event messages if we know about it + if (messageDataType == typeof(MessageEvent)) + { + Enum eventType = ReadValue(reader, "eventType"); + if (s_eventTypeMap.TryGetValue(eventType, out Type? eventMessageDataType)) + messageDataType = eventMessageDataType; + } + + // Copy the current state of the reader because we might need it again + Utf8JsonReader readerClone = reader; + + var msg = (Message)JsonSerializer.Deserialize(ref reader, messageDataType!, s_jsonOptions)!; + if (msg is MessageEvent msgEvent) + msgEvent.Event = GetEventData(readerClone, msgEvent.EventContentsType); + return msg; } public override void Write( @@ -50,35 +128,60 @@ public override void Write( JsonSerializerOptions options ) { - JsonSerializer.Serialize(writer, message, message.GetType(), recursiveSafeOptions); + JsonSerializer.Serialize(writer, message, message.GetType(), s_jsonOptions); } /// - /// Reads the type property from the message given the specified reader + /// Reads the property from the message given the specified reader /// - private static Enum ReadType(ref Utf8JsonReader reader) + private static Enum ReadValue(Utf8JsonReader reader, string property) + where T : class, EnumType { do { bool success = reader.Read(); if (!success) - return Enum.Null; + return Enum.Null; if (reader.TokenType != JsonTokenType.PropertyName) continue; string? propertyName = reader.GetString(); - if (propertyName != "type") + if (propertyName != property) continue; success = reader.Read(); if (!success) - return Enum.Null; + return Enum.Null; if (reader.TokenType != JsonTokenType.String) throw new JsonException($"Unexpected token {reader.TokenType} (expected String)"); - return new Enum(reader.GetString()); + return new Enum(reader.GetString()); + } while (true); + } + + /// + /// Deserializes the specific type for the "event" property from the given reader + /// + private static dynamic? GetEventData(Utf8JsonReader reader, Type type) + { + do + { + if (!reader.Read()) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + continue; + + string? propertyName = reader.GetString(); + if (propertyName != "event") + continue; + + reader.Read(); + return JsonSerializer.Deserialize(ref reader, type, s_jsonOptions); } while (true); + + throw new JsonException("Could not find event data within event message"); } } diff --git a/c-sharp/JsonUtils/SerializationOptions.cs b/c-sharp/JsonUtils/SerializationOptions.cs index 5c5ac4b84a..bf5b67533e 100644 --- a/c-sharp/JsonUtils/SerializationOptions.cs +++ b/c-sharp/JsonUtils/SerializationOptions.cs @@ -19,9 +19,10 @@ public static JsonSerializerOptions CreateSerializationOptions() Encoder = JavaScriptEncoder.Create(UnicodeRanges.All), // Don't escape non-ASCII characters PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // Allow properties to be upper-case while JSON contains lower-case WriteIndented = false, // No need to waste bytes with nice formatting - IgnoreReadOnlyProperties = false, // Need message type to be serialized + IgnoreReadOnlyProperties = false, // Need types to be serialized }; + options.Converters.Add(new EnumConverter()); options.Converters.Add(new EnumConverter()); options.Converters.Add(new EnumConverter()); return options; diff --git a/c-sharp/MessageHandlers/IMessageHandler.cs b/c-sharp/MessageHandlers/IMessageHandler.cs index 534f8b867e..6dc32bf955 100644 --- a/c-sharp/MessageHandlers/IMessageHandler.cs +++ b/c-sharp/MessageHandlers/IMessageHandler.cs @@ -8,6 +8,6 @@ internal interface IMessageHandler /// Handle an incoming message, and optionally respond with another message /// /// Incoming message to handle - /// Optional response to the incoming message - public Message? HandleMessage(Message message); + /// Optional responses to the incoming message + public IEnumerable HandleMessage(Message message); } diff --git a/c-sharp/MessageHandlers/MessageHandlerEvent.cs b/c-sharp/MessageHandlers/MessageHandlerEvent.cs index f313def653..a754a409e2 100644 --- a/c-sharp/MessageHandlers/MessageHandlerEvent.cs +++ b/c-sharp/MessageHandlers/MessageHandlerEvent.cs @@ -1,13 +1,55 @@ using Paranext.DataProvider.Messages; +using PtxUtils; namespace Paranext.DataProvider.MessageHandlers; +using PapiEventHandler = Func; + /// /// Handler for "Event" messages /// internal class MessageHandlerEvent : IMessageHandler { - public Message? HandleMessage(Message message) + private readonly Dictionary, PapiEventHandler> _handlers = new(); + private readonly object _handlersLock = new(); + + public void RegisterEventHandler(Enum eventType, PapiEventHandler handler) + { + lock (_handlersLock) + { + if (_handlers.TryGetValue(eventType, out PapiEventHandler? existingDelegate)) + { + // Remove and add the handler to ensure it isn't included twice + PapiEventHandler? newDelegate = existingDelegate - handler; + if (newDelegate != null) + { + newDelegate += handler; + _handlers[eventType] = newDelegate; + } + } + else + { + _handlers[eventType] = handler; + } + } + } + + public void UnregisterEventHandler(Enum eventType, PapiEventHandler handler) + { + lock (_handlersLock) + { + if (_handlers.TryGetValue(eventType, out PapiEventHandler? existingDelegate)) + { + PapiEventHandler? newDelegate = existingDelegate - handler; + if (newDelegate != null) + _handlers[eventType] = newDelegate; + else + _handlers.Remove(eventType, out _); + } + } + } + + public IEnumerable HandleMessage(Message message) { if (message == null) throw new ArgumentNullException(nameof(message)); @@ -17,6 +59,23 @@ internal class MessageHandlerEvent : IMessageHandler Console.WriteLine($"Event received: {message}"); - return null; + MessageEvent evt = (MessageEvent)message; + Delegate[]? handlersToRun = null; + lock (_handlersLock) + { + if (!_handlers.TryGetValue(evt.EventType, out PapiEventHandler? handlersForEventType)) + yield break; + + // The returned array and the functions in it will not change after this call + // even if Register/Unregister is called while we are invoking the handlers + handlersToRun = handlersForEventType.GetInvocationList(); + } + + foreach (var handler in handlersToRun) + { + var msg = handler.DynamicInvoke(evt); + if (msg != null) + yield return (Message)msg; + } } } diff --git a/c-sharp/MessageHandlers/MessageHandlerRequestByRequestType.cs b/c-sharp/MessageHandlers/MessageHandlerRequestByRequestType.cs index 37f7c6a23e..32372d5043 100644 --- a/c-sharp/MessageHandlers/MessageHandlerRequestByRequestType.cs +++ b/c-sharp/MessageHandlers/MessageHandlerRequestByRequestType.cs @@ -21,7 +21,7 @@ public void SetHandlerForRequestType(Enum requestType, RequestHandl _handlersByRequestType[requestType] = handler; } - public Message? HandleMessage(Message message) + public IEnumerable HandleMessage(Message message) { if (message == null) throw new ArgumentNullException(nameof(message)); @@ -33,23 +33,27 @@ public void SetHandlerForRequestType(Enum requestType, RequestHandl if (!_handlersByRequestType.TryGetValue(request.RequestType, out RequestHandler? handler)) { Console.Error.WriteLine($"Unable to process request type: {request.RequestType}"); - return null; + yield break; } var response = handler(request.Contents); if (response.Success) - return new MessageResponse( + { + yield return new MessageResponse( request.RequestType, request.RequestId, request.SenderId, response.Contents ); + } else - return new MessageResponse( + { + yield return new MessageResponse( request.RequestType, request.RequestId, request.SenderId, response.ErrorMessage ); + } } } diff --git a/c-sharp/MessageHandlers/MessageHandlerResponse.cs b/c-sharp/MessageHandlers/MessageHandlerResponse.cs index 24f9ed3b82..85ec08ef33 100644 --- a/c-sharp/MessageHandlers/MessageHandlerResponse.cs +++ b/c-sharp/MessageHandlers/MessageHandlerResponse.cs @@ -1,47 +1,43 @@ using Paranext.DataProvider.Messages; -namespace Paranext.DataProvider.MessageHandlers +namespace Paranext.DataProvider.MessageHandlers; + +/// +/// Handler for "Response" messages +/// +internal class MessageHandlerResponse : IMessageHandler { - /// - /// Handler for "Response" messages - /// - internal class MessageHandlerResponse : IMessageHandler + private readonly MessageRequest _originalRequest; + private readonly Action _messageProcessingCallback; + + public MessageHandlerResponse(MessageRequest originalRequest, Action callback) + { + _originalRequest = originalRequest; + _messageProcessingCallback = callback; + } + + public MessageHandlerResponse(MessageRequest originalRequest) + : this(originalRequest, DoNothing) { } + + private static void DoNothing(bool success, dynamic? message) { } + + public IEnumerable HandleMessage(Message message) { - private readonly MessageRequest _originalRequest; - private readonly Action _messageProcessingCallback; - - public MessageHandlerResponse( - MessageRequest originalRequest, - Action callback - ) - { - _originalRequest = originalRequest; - _messageProcessingCallback = callback; - } - - public MessageHandlerResponse(MessageRequest originalRequest) - : this(originalRequest, DoNothing) { } - - private static void DoNothing(bool success, dynamic? message) { } - - public Message? HandleMessage(Message message) - { - if (message == null) - throw new ArgumentNullException(nameof(message)); - - if (message.Type != MessageType.Response) - throw new ArgumentException("Incorrect message type", nameof(message)); - - var response = (MessageResponse)message; - if (!response.Success) - Console.Error.WriteLine( - "Request failed: \"{0}\" with error message \"{1}\"", - _originalRequest, - response.ErrorMessage ?? "" - ); - - _messageProcessingCallback(response.Success, response.Contents); - return null; - } + if (message == null) + throw new ArgumentNullException(nameof(message)); + + if (message.Type != MessageType.Response) + throw new ArgumentException("Incorrect message type", nameof(message)); + + var response = (MessageResponse)message; + if (!response.Success) + Console.Error.WriteLine( + "Request failed: \"{0}\" with error message \"{1}\"", + _originalRequest, + response.ErrorMessage ?? "" + ); + + _messageProcessingCallback(response.Success, response.Contents); + yield break; } } diff --git a/c-sharp/MessageTransports/PapiClient.cs b/c-sharp/MessageTransports/PapiClient.cs index bd6e898ee2..dc959c59d8 100644 --- a/c-sharp/MessageTransports/PapiClient.cs +++ b/c-sharp/MessageTransports/PapiClient.cs @@ -29,7 +29,7 @@ internal sealed class PapiClient : IDisposable private readonly Thread _messageHandlingThread; private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly ManualResetEventSlim _messageHandlingComplete = new(false); - private int _clientId = NetworkConnectorInfo.CLIENT_ID_UNSET; + private int _clientId = MessageInitClientConnectorInfo.CLIENT_ID_UNSET; private int _nextRequestId = 1; private bool _isDisposed = false; #endregion @@ -210,6 +210,40 @@ public async Task RegisterRequestHandlerAsync( } return registrationSucceeded; } + + /// + /// Configure PapiClient to call whenever an event of type is received. + /// + /// Event type to monitor + /// Function that optionally returns messages to send when an event is received + public void RegisterEventHandler(Enum eventType, Func func) + { + var msgHandler = (MessageHandlerEvent)_messageHandlersByMessageType[MessageType.Event]; + msgHandler.RegisterEventHandler(eventType, func); + Console.WriteLine($"Handler for event type \"{eventType}\" successfully registered"); + } + + /// + /// Configure PapiClient to no longer call whenever an event of type is received. + /// + /// Event type to monitor + /// Same function reference previously passed to RegisterEventHandler + public void UnregisterEventHandler(Enum eventType, Func func) + { + var msgHandler = (MessageHandlerEvent)_messageHandlersByMessageType[MessageType.Event]; + msgHandler.UnregisterEventHandler(eventType, func); + Console.WriteLine($"Handler for event type \"{eventType}\" successfully unregistered"); + } + + /// + /// Send an event message to the server/>. + /// + /// Event message to send + /// + public async Task SendEvent(MessageEvent message) + { + await SendMessageAsync(message, CancellationToken.None); + } #endregion #region Private helper methods @@ -274,75 +308,95 @@ private async Task SendMessageAsync(Message message, CancellationToken cancellat /// private async void HandleMessages() { - do + try { - try + do { - Console.WriteLine("PapiClient waiting for the next incoming message"); - var receiveTask = ReceiveMessageAsync(_cancellationTokenSource.Token); - Message? message = await receiveTask; - if (message is null) - { - Console.Error.WriteLine("Received null message!"); - continue; - } - - if (message is MessageResponse response) + try { - // Remove, don't just get, the response handler since the request is complete - if ( - _messageHandlersForMyRequests.TryRemove( - response.RequestId, - out IMessageHandler? messageHandler - ) - ) + Console.WriteLine("PapiClient waiting for the next incoming message"); + var receiveTask = ReceiveMessageAsync(_cancellationTokenSource.Token); + Message? message = await receiveTask; + if (message is null) { - messageHandler.HandleMessage(message); + Console.Error.WriteLine("Received null message!"); } else { - Console.Error.WriteLine( - $"No handler registered for response from request ID: {response.RequestId}" - ); - continue; + // Handle each message asynchronously so we can keep receiving more messages + _ = Task.Run(() => + { + HandleMessage(message); + }); } } - else + catch (OperationCanceledException) // Thrown by the websocket when cancelling { - if ( - _messageHandlersByMessageType.TryGetValue( - message.Type, - out IMessageHandler? messageHandler - ) - ) - { - Message? messageToSend = messageHandler.HandleMessage(message); - if (messageToSend != null) - { - await SendMessageAsync(messageToSend, _cancellationTokenSource.Token); - } - } - else - { - Console.Error.WriteLine( - $"No handler registered for message type: {message.Type}" - ); - continue; - } + break; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Exception while handling messages: {ex}"); } + } while (!_cancellationTokenSource.IsCancellationRequested && Connected); + + _messageHandlingComplete.Set(); + Console.WriteLine("PapiClient HandleMessages exiting"); + } + // Don't take down the process if Dispose() ran faster than some of the code here + catch (ObjectDisposedException) { } + } + + /// + /// Message handler for any kind of message + /// + private async void HandleMessage(Message message) + { + try + { + if (message is MessageResponse messageResponse) + { + HandleMessageResponse(messageResponse); + return; } - catch (OperationCanceledException) // Thrown by the websocket when cancelling + + if (_messageHandlersByMessageType.TryGetValue(message.Type, out var messageHandler)) { - break; + foreach (var messageToSend in messageHandler.HandleMessage(message)) + { + await SendMessageAsync(messageToSend, _cancellationTokenSource.Token); + } } - catch (Exception ex) + else { - Console.Error.WriteLine($"Exception while handling message: {ex}"); + Console.Error.WriteLine($"No handler registered for message type: {message.Type}"); } - } while (!_cancellationTokenSource.IsCancellationRequested && Connected); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Exception while handling message: {ex}"); + } + } - _messageHandlingComplete.Set(); - Console.WriteLine("PapiClient HandleMessages exiting"); + /// + /// Message handler for a response to a request we previously sent + /// + private async void HandleMessageResponse(MessageResponse response) + { + // Remove, don't just get, the response handler since the request is complete + if (_messageHandlersForMyRequests.TryRemove(response.RequestId, out var messageHandler)) + { + foreach (var messageToSend in messageHandler.HandleMessage(response)) + { + await SendMessageAsync(messageToSend, _cancellationTokenSource.Token); + } + } + else + { + Console.Error.WriteLine( + $"No handler registered for response from request ID: {response.RequestId}" + ); + } } #endregion } diff --git a/c-sharp/Messages/EventType.cs b/c-sharp/Messages/EventType.cs new file mode 100644 index 0000000000..bd7abd89f4 --- /dev/null +++ b/c-sharp/Messages/EventType.cs @@ -0,0 +1,12 @@ +using PtxUtils; + +namespace Paranext.DataProvider.Messages; + +public sealed class EventType : EnumType +{ + public static readonly Enum ClientConnect = new("network:onDidClientConnect"); + public static readonly Enum ClientDisconnect = new("network:onDidClientDisconnect"); + public static readonly Enum ObjectDispose = new("object:onDidDisposeNetworkObject"); + + private EventType() { } // Can't create an instance +} diff --git a/c-sharp/Messages/Message.cs b/c-sharp/Messages/Message.cs index 7e1fa5d46e..69d9c01ffc 100644 --- a/c-sharp/Messages/Message.cs +++ b/c-sharp/Messages/Message.cs @@ -19,8 +19,14 @@ protected Message() public const int UNKNOWN_SENDER_ID = -1; + /// + /// Message type + /// public abstract Enum Type { get; } + /// + /// ID (as assigned by the server) of the original sender of this message + /// public int SenderId { get; set; } public override string ToString() diff --git a/c-sharp/Messages/MessageEvent.cs b/c-sharp/Messages/MessageEvent.cs index b8386f16e4..ec9ff3586b 100644 --- a/c-sharp/Messages/MessageEvent.cs +++ b/c-sharp/Messages/MessageEvent.cs @@ -5,24 +5,34 @@ namespace Paranext.DataProvider.Messages; /// /// Message events to/from the server. /// -public sealed class MessageEvent : Message +public class MessageEvent : Message { /// /// ONLY FOR DESERIALIZATION /// - private MessageEvent() { } - - public MessageEvent(string? eventType) + protected MessageEvent() { - EventType = eventType; + // Default for new events that don't have a custom class + EventContentsType = typeof(System.Text.Json.JsonElement); } public override Enum Type => MessageType.Event; - public string? EventType { get; set; } + public virtual Enum EventType { get; set; } + + /// + /// Weakly typed contents of the event message. See also + /// + public dynamic? Event { get; set; } + + /// + /// The intended type of the data stored in . This is used during deserialization. + /// + [System.Text.Json.Serialization.JsonIgnore] + public Type EventContentsType { get; protected set; } public override string ToString() { - return $"Event: {EventType} from {SenderId}"; + return $"Event: {EventType} from {SenderId} is \"{Event}\""; } } diff --git a/c-sharp/Messages/MessageEventClientConnect.cs b/c-sharp/Messages/MessageEventClientConnect.cs new file mode 100644 index 0000000000..8b148fb55a --- /dev/null +++ b/c-sharp/Messages/MessageEventClientConnect.cs @@ -0,0 +1,25 @@ +namespace Paranext.DataProvider.Messages; + +public sealed class MessageEventClientConnect + : MessageEventGeneric +{ + /// + /// ONLY FOR DESERIALIZATION + /// + private MessageEventClientConnect() + : base(Messages.EventType.ClientConnect) { } + + public MessageEventClientConnect(MessageEventClientConnectContents eventContents) + : base(Messages.EventType.ClientConnect, eventContents) { } +} + +public sealed record MessageEventClientConnectContents +{ + public int ClientId { get; set; } + public bool DidReconnect { get; set; } + + public override string ToString() + { + return $"ClientId = {ClientId}, DidReconnect = {DidReconnect}"; + } +} diff --git a/c-sharp/Messages/MessageEventClientDisconnect.cs b/c-sharp/Messages/MessageEventClientDisconnect.cs new file mode 100644 index 0000000000..fc2759efe7 --- /dev/null +++ b/c-sharp/Messages/MessageEventClientDisconnect.cs @@ -0,0 +1,24 @@ +namespace Paranext.DataProvider.Messages; + +public sealed class MessageEventClientDisconnect + : MessageEventGeneric +{ + /// + /// ONLY FOR DESERIALIZATION + /// + private MessageEventClientDisconnect() + : base(Messages.EventType.ClientDisconnect) { } + + public MessageEventClientDisconnect(MessageEventClientDisconnectContents eventContents) + : base(Messages.EventType.ClientDisconnect, eventContents) { } +} + +public sealed record MessageEventClientDisconnectContents +{ + public int ClientId { get; set; } + + public override string ToString() + { + return $"ClientId = {ClientId}"; + } +} diff --git a/c-sharp/Messages/MessageEventGeneric.cs b/c-sharp/Messages/MessageEventGeneric.cs new file mode 100644 index 0000000000..0d9a3fa6fa --- /dev/null +++ b/c-sharp/Messages/MessageEventGeneric.cs @@ -0,0 +1,37 @@ +using PtxUtils; + +namespace Paranext.DataProvider.Messages; + +/// +/// This is what all event messages (other than "MessageEvent" itself) should use as a base class +/// +public abstract class MessageEventGeneric : MessageEvent +// Unfortunately using the EventType as a generic type is not supported, as in "MessageEventGeneric". +// You can't pass any old object as a type to generics. They must be System.Type values, and PtxUtils.Enum values are not System.Type values. +// Even if PtxUtils.Enum values were actually enums it wouldn't help. https://stackoverflow.com/a/1331811/7303994 +{ + private readonly Enum _eventType; + + protected MessageEventGeneric(Enum eventType) + { + _eventType = eventType; + EventContentsType = typeof(ContentsType); + } + + protected MessageEventGeneric(Enum eventType, ContentsType eventContents) + : this(eventType) + { + Event = eventContents; + } + + public sealed override Enum EventType => _eventType; + + /// + /// Strongly typed contents of the event message. See also + /// + [System.Text.Json.Serialization.JsonIgnore] + public ContentsType? EventContents + { + get { return Event; } + } +} diff --git a/c-sharp/Messages/MessageEventNetworkObject.cs b/c-sharp/Messages/MessageEventNetworkObject.cs new file mode 100644 index 0000000000..531cd7a364 --- /dev/null +++ b/c-sharp/Messages/MessageEventNetworkObject.cs @@ -0,0 +1,13 @@ +namespace Paranext.DataProvider.Messages; + +public sealed class MessageEventObjectDispose : MessageEventGeneric +{ + /// + /// ONLY FOR DESERIALIZATION + /// + private MessageEventObjectDispose() + : base(Messages.EventType.ObjectDispose) { } + + public MessageEventObjectDispose(string eventContents) + : base(Messages.EventType.ObjectDispose, eventContents) { } +} diff --git a/c-sharp/Messages/MessageInitClient.cs b/c-sharp/Messages/MessageInitClient.cs index f9d0d863cf..e9acd2ef4c 100644 --- a/c-sharp/Messages/MessageInitClient.cs +++ b/c-sharp/Messages/MessageInitClient.cs @@ -3,7 +3,7 @@ namespace Paranext.DataProvider.Messages; /// -/// Message sent to the client to give it NetworkConnectorInfo +/// Message sent to the client to give it ConnectorInfo /// public sealed class MessageInitClient : Message { @@ -12,15 +12,35 @@ public sealed class MessageInitClient : Message /// private MessageInitClient() { - ConnectorInfo = new NetworkConnectorInfo(NetworkConnectorInfo.CLIENT_ID_UNSET); + ConnectorInfo = new(MessageInitClientConnectorInfo.CLIENT_ID_UNSET); } - public MessageInitClient(NetworkConnectorInfo connectorInfo) + public MessageInitClient(MessageInitClientConnectorInfo connectorInfo) { ConnectorInfo = connectorInfo; } public override Enum Type => MessageType.InitClient; - public NetworkConnectorInfo ConnectorInfo { get; set; } + public MessageInitClientConnectorInfo ConnectorInfo { get; set; } +} + +public sealed class MessageInitClientConnectorInfo +{ + public const int CLIENT_ID_UNSET = -1; + + /// + /// ONLY FOR DESERIALIZATION + /// + private MessageInitClientConnectorInfo() + { + ClientId = CLIENT_ID_UNSET; + } + + public MessageInitClientConnectorInfo(int clientId) + { + ClientId = clientId; + } + + public int ClientId { get; set; } } diff --git a/c-sharp/Messages/MessageRequest.cs b/c-sharp/Messages/MessageRequest.cs index 51669fadd7..cdd46fca99 100644 --- a/c-sharp/Messages/MessageRequest.cs +++ b/c-sharp/Messages/MessageRequest.cs @@ -5,7 +5,7 @@ namespace Paranext.DataProvider.Messages; /// /// Message requests to/from the server. /// -public sealed partial class MessageRequest : Message +public sealed class MessageRequest : Message { /// /// ONLY FOR DESERIALIZATION diff --git a/c-sharp/Messages/NetworkConnectorInfo.cs b/c-sharp/Messages/NetworkConnectorInfo.cs deleted file mode 100644 index 2ccb40fa87..0000000000 --- a/c-sharp/Messages/NetworkConnectorInfo.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Paranext.DataProvider.Messages; - -public sealed class NetworkConnectorInfo -{ - public const int CLIENT_ID_UNSET = -1; - - /// - /// ONLY FOR DESERIALIZATION - /// - private NetworkConnectorInfo() - { - ClientId = CLIENT_ID_UNSET; - } - - public NetworkConnectorInfo(int clientId) - { - ClientId = clientId; - } - - public int ClientId { get; set; } -} diff --git a/c-sharp/Messages/README.md b/c-sharp/Messages/README.md deleted file mode 100644 index 7bd8fccb42..0000000000 --- a/c-sharp/Messages/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# TAKE NOTE - -Everything in this folder should have a matching representation in the TS code. [Here](/src/shared/data/NetworkConnectorTypes.ts) is a good place to look. -If new message types are added here, make sure they are added to TS (and vice versa). - -In addition to having a message class defined, the following should be done for new message types that are intended to be received by the C# PAPI client: - -1. Be added to [the dictionary here](/c-sharp/JsonUtils/MessageConverter.cs) -2. Have a MessageHandler created for them [here](/c-sharp/MessageHandlers/) -3. Have the MessageHandler added to [the constructor of PapiClient](/c-sharp/MessageTransports/PapiClient.cs) diff --git a/c-sharp/ParanextDataProvider.sln b/c-sharp/ParanextDataProvider.sln index 40b77c8748..fa49fce6a4 100644 --- a/c-sharp/ParanextDataProvider.sln +++ b/c-sharp/ParanextDataProvider.sln @@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.4.33213.308 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ParanextDataProvider", "ParanextDataProvider.csproj", "{EE68E4D7-DBB2-40F7-96B5-CF5C70BAEA40}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ParanextDataProvider", "ParanextDataProvider.csproj", "{EE68E4D7-DBB2-40F7-96B5-CF5C70BAEA40}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "c-sharp-tests", "..\c-sharp-tests\c-sharp-tests.csproj", "{47318C23-C03D-4FE1-9900-9C78CA7E4894}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +17,10 @@ Global {EE68E4D7-DBB2-40F7-96B5-CF5C70BAEA40}.Debug|Any CPU.Build.0 = Debug|Any CPU {EE68E4D7-DBB2-40F7-96B5-CF5C70BAEA40}.Release|Any CPU.ActiveCfg = Release|Any CPU {EE68E4D7-DBB2-40F7-96B5-CF5C70BAEA40}.Release|Any CPU.Build.0 = Release|Any CPU + {47318C23-C03D-4FE1-9900-9C78CA7E4894}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {47318C23-C03D-4FE1-9900-9C78CA7E4894}.Debug|Any CPU.Build.0 = Debug|Any CPU + {47318C23-C03D-4FE1-9900-9C78CA7E4894}.Release|Any CPU.ActiveCfg = Release|Any CPU + {47318C23-C03D-4FE1-9900-9C78CA7E4894}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/c-sharp/README.md b/c-sharp/README.md new file mode 100644 index 0000000000..0c4eb9437e --- /dev/null +++ b/c-sharp/README.md @@ -0,0 +1,19 @@ +# Helpful Tips + +## Messages + +To add a new message type to C#: + +1. Create a new Enum\ value for it [here](/c-sharp/Messages/MessageType.cs). +2. Create a new class for it that derives from [Message](/c-sharp/Messages/Message.cs). +3. Add it to the TS code if not already there. [Here](/src/shared/data/NetworkConnectorTypes.ts) is a good place to look. +4. If C# needs to receive this type of message, create a [MessageHandler](/c-sharp/MessageHandlers/IMessageHandler.cs). Add that new MessageHandler to [the constructor of PapiClient](/c-sharp/MessageTransports/PapiClient.cs) + +## Events + +To add a new event type to C#: + +1. Create a new Enum\ value for it [here](/c-sharp/Messages/EventType.cs). +2. Create a new class for it that derives from [MessageEventGeneric](/c-sharp/Messages/MessageEventGeneric.cs). +3. Add it to the TS code if not already there. +4. If C# needs to handle this type of event, add code to call [RegisterEventHandler in PapiClient](/c-sharp/MessageTransports/PapiClient.cs). diff --git a/cspell.json b/cspell.json index 0cf7782c06..eab326917e 100644 --- a/cspell.json +++ b/cspell.json @@ -30,6 +30,7 @@ "Papis", "paranext", "proxied", + "reserialized", "Steenwyk", "Unregistering", "unregisters",