diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d1c262a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,114 @@ +# EditorConfig to support per-solution formatting. +# Use the EditorConfig VS add-in to make this work. +# http://editorconfig.org/ +root = true + +[*] +indent_style = space +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = false + +[*.cs] +indent_size = 4 +# +# Language Conventions +# +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2017#this-and-me +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_property = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_event = false +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2017#language-keywords +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2017#normalize-modifiers +dotnet_style_require_accessibility_modifiers = always +dotnet_style_readonly_field = true +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2017#parentheses-preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2017#expression-level-preferences +dotnet_style_object_initializer = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = true +dotnet_style_prefer_compound_assignment = true +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2017#null-checking-preferences +dotnet_style_coalesce_expression = true +dotnet_style_null_propagation = true +# +# Formatting Conventions +# +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-formatting-conventions?view=vs-2017#organize-using-directives +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-formatting-conventions?view=vs-2017#indentation-options +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents_when_block = true +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-formatting-conventions?view=vs-2017#spacing-options +csharp_space_after_cast = true +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_after_comma = true +csharp_space_before_comma = false +csharp_space_after_dot = false +csharp_space_before_dot = false +csharp_space_after_semicolon_in_for_statement = true +csharp_space_before_semicolon_in_for_statement = false +csharp_space_around_declaration_statements = false +csharp_space_before_open_square_brackets = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_square_brackets = false +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-formatting-conventions?view=vs-2017#wrap-options +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = true + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +[*.{xml,config,*proj,nuspec,props,resx,targets,yml,tasks}] +indent_size = 2 + +[*.json] +indent_size = 2 + +[*.{ps1,psm1}] +indent_size = 4 + +[*.sh] +indent_size = 4 +end_of_line = lf \ No newline at end of file diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml new file mode 100644 index 0000000..11c0604 --- /dev/null +++ b/.github/workflows/ci-pipeline.yml @@ -0,0 +1,25 @@ +name: CI Pipeline + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 3.1.401 + - name: Install dependencies + run: dotnet restore + - name: Build + run: dotnet build --configuration Release --no-restore + - name: Test + run: dotnet test --no-restore --verbosity normal \ No newline at end of file diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml new file mode 100644 index 0000000..b4312ab --- /dev/null +++ b/.github/workflows/release-builds.yml @@ -0,0 +1,26 @@ +name: Release Builds + +on: + create: + tags: + - '*' + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 3.1.401 + - name: Install dependencies + run: dotnet restore + - name: Build + run: dotnet build --configuration Release --no-restore + - name: Test + run: dotnet test --no-restore --verbosity normal + - name: Publish Package + run: dotnet nuget push "artifacts/nupkgs/*.nupkg" -s ${{ secrets.NUGET_URL }} -k ${{ secrets.NUGET_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3a2238d..d4412b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ ## 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/master/VisualStudio.gitignore # User-specific files +*.rsuser *.suo *.user *.userosscache @@ -10,48 +13,68 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs +# Mono auto generated files +mono_crash.* + # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ -[Xx]64/ -[Xx]86/ -[Bb]uild/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ +[Ll]og/ -# Visual Studio 2015 cache/options directory +# Visual Studio 2015/2017 cache/options directory & Rider .vs/ +.idea/ # 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 +# NUnit *.VisualState.xml TestResult.xml +nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c -# DNX +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core project.lock.json +project.fragment.lock.json artifacts/ +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio *_i.c *_p.c -*_i.h +*_h.h *.ilk *.meta *.obj +*.iobj *.pch *.pdb +*.ipdb *.pgc *.pgd *.rsp @@ -61,6 +84,7 @@ artifacts/ *.tlh *.tmp *.tmp_proj +*_wpftmp.csproj *.log *.vspscc *.vssscc @@ -81,6 +105,7 @@ ipch/ *.sdf *.cachefile *.VC.db +*.VC.VC.opendb # Visual Studio profiler *.psess @@ -88,6 +113,9 @@ ipch/ *.vspx *.sap +# Visual Studio Trace Files +*.e2e + # TFS 2012 Local Workspace $tf/ @@ -108,6 +136,14 @@ _TeamCity* # DotCover is a Code Coverage Tool *.dotCover +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + # NCrunch _NCrunch_* .*crunch*.local.xml @@ -139,22 +175,27 @@ publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml - -# TODO: Un-comment the next line if you do not want to checkin -# your web deploy settings because they may include unencrypted -# passwords -#*.pubxml +# 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 -**/packages/* +**/[Pp]ackages/* # except build/, which is used as an MSBuild target. -!**/packages/build/ +!**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed -#!**/packages/repositories.config -# NuGet v3's project.json files produces more ignoreable files +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets @@ -166,31 +207,40 @@ csx/ ecf/ rcf/ -# Microsoft Azure ApplicationInsights config file -ApplicationInsights.config - -# Windows Store app package directory +# 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/ +!?*.[Cc]ache/ # Others ClientBin/ -[Ss]tyle[Cc]op.* ~$* *~ *.dbmdl *.dbproj.schemaview +*.jfm *.pfx *.publishsettings -node_modules/ 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/ @@ -201,15 +251,22 @@ _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/ @@ -219,6 +276,7 @@ FakesAssemblies/ # Node.js Tools for Visual Studio .ntvs_analysis.dat +node_modules/ # Visual Studio 6 build log *.plg @@ -226,6 +284,9 @@ FakesAssemblies/ # Visual Studio 6 workspace options file *.opt +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts @@ -234,12 +295,59 @@ FakesAssemblies/ **/*.Server/ModelManifest.xml _Pvt_Extensions -# LightSwitch generated files -GeneratedArtifacts/ -ModelManifest.xml - # Paket dependency manager .paket/paket.exe +paket-files/ # FAKE - F# Make -.fake/ \ No newline at end of file +.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/ + +# 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/ \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..3d2f34a --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,21 @@ + + + Im5tu;Stuart Blackler + OpenMessage;Messaging;ServiceBus;AspNetCore;ESB + https://github.com/Im5tu/OpenMessage + git + https://github.com/Im5tu/OpenMessage.git + + Latest + + enable + + true + + $(WarningsNotAsErrors);CS1591 + + $(WarningsNotAsErrors);xUnit1004 + + $(MSBuildThisFileDirectory) + + \ No newline at end of file diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..5960090 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/OpenMessage.sln b/OpenMessage.sln index a1ae23c..31957ca 100644 --- a/OpenMessage.sln +++ b/OpenMessage.sln @@ -1,30 +1,101 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25420.1 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.28721.148 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E8751802-CCE4-4C40-9241-E5E01DBEF627}" + ProjectSection(SolutionItems) = preProject + src\Directory.Build.props = src\Directory.Build.props + src\Directory.Build.targets = src\Directory.Build.targets + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F8E4FD3C-F93D-40B6-9330-9415FB35B3E2}" ProjectSection(SolutionItems) = preProject - global.json = global.json + .editorconfig = .editorconfig + .gitattributes = .gitattributes + .gitignore = .gitignore + build.ps1 = build.ps1 + Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets + readme.md = readme.md EndProjectSection EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "OpenMessage", "src\OpenMessage\OpenMessage.xproj", "{7D68A283-6D90-4A50-B015-D8C2BB5C7184}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{B4E58D4F-40CD-45CA-AC6F-1DF4D9CB9A49}" + ProjectSection(SolutionItems) = preProject + tests\Directory.Build.props = tests\Directory.Build.props + tests\Directory.Build.targets = tests\Directory.Build.targets + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage", "src\OpenMessage\OpenMessage.csproj", "{7D68A283-6D90-4A50-B015-D8C2BB5C7184}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{B4CC2690-ADC2-4B78-A961-06C0D6CD948F}" + ProjectSection(SolutionItems) = preProject + docker-compose.yml = docker-compose.yml + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Azure.ServiceBus", "src\OpenMessage.Azure.ServiceBus\OpenMessage.Azure.ServiceBus.csproj", "{46AD3311-B953-49F4-8B7B-9E9AE5E368F7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Azure.EventHubs", "src\OpenMessage.Azure.EventHubs\OpenMessage.Azure.EventHubs.csproj", "{D2EBDC48-95C2-4EBD-9F07-5E3379ECDCF9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.NATS", "src\OpenMessage.NATS\OpenMessage.NATS.csproj", "{D2AF2B8F-947B-447E-BC30-B13ECD28A40E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.RabbitMq", "src\OpenMessage.RabbitMq\OpenMessage.RabbitMq.csproj", "{46A8FD07-E04E-48F0-8D38-5B61320A3CB8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.AWS.SQS", "src\OpenMessage.AWS.SQS\OpenMessage.AWS.SQS.csproj", "{ECF700A0-BC08-4796-8424-6EEB31C649A9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Apache.Kafka", "src\OpenMessage.Apache.Kafka\OpenMessage.Apache.Kafka.csproj", "{92AD2CB7-A31F-41CD-9B0E-B7677EDACF2F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Samples.Core", "samples\OpenMessage.Samples.Core\OpenMessage.Samples.Core.csproj", "{4C2BF517-296A-4559-B092-C5F639E66DD7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Samples.Memory", "samples\OpenMessage.Samples.Memory\OpenMessage.Samples.Memory.csproj", "{75E4E6D9-D4EF-4C53-BE74-9CDA176ECDFC}" EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "OpenMessage.Tests", "tests\OpenMessage.Tests\OpenMessage.Tests.xproj", "{D5C758B5-055C-41F0-A339-C8E4141076EB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Serializer.Hyperion", "src\OpenMessage.Serializer.Hyperion\OpenMessage.Serializer.Hyperion.csproj", "{8C796D60-D79F-46D5-8CE9-8372CCF5F317}" EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "OpenMessage.Serializer.JsonNet", "src\OpenMessage.Serializer.JsonNet\OpenMessage.Serializer.JsonNet.xproj", "{3AB720E9-27B4-4A28-9C1F-B89EA1A4257F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Serializer.Jil", "src\OpenMessage.Serializer.Jil\OpenMessage.Serializer.Jil.csproj", "{5F633833-217B-429D-86F1-8A2327751144}" EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "OpenMessage.Serializer.Jil", "src\OpenMessage.Serializer.Jil\OpenMessage.Serializer.Jil.xproj", "{E70C427C-7285-48AC-A13B-D18519463786}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Serializer.JsonDotNet", "src\OpenMessage.Serializer.JsonDotNet\OpenMessage.Serializer.JsonDotNet.csproj", "{5448B5C6-8C3A-4B71-8789-4AA8880066C4}" EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "OpenMessage.Serializer.ProtobufNet", "src\OpenMessage.Serializer.ProtobufNet\OpenMessage.Serializer.ProtobufNet.xproj", "{987E4E03-FF13-496A-9B72-BC2ADF3A35D2}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Serializer.MessagePack", "src\OpenMessage.Serializer.MessagePack\OpenMessage.Serializer.MessagePack.csproj", "{ED4255C2-9EAA-4EE4-AA71-EA7C219FC09E}" EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "OpenMessage.Providers.Azure", "src\OpenMessage.Providers.Azure\OpenMessage.Providers.Azure.xproj", "{66F710F3-460A-450B-8B07-9CD1476E5CF3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Serializer.MsgPackCli", "src\OpenMessage.Serializer.MsgPackCli\OpenMessage.Serializer.MsgPackCli.csproj", "{00411544-72BC-4569-9EBD-491362002F96}" EndProject -Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "OpenMessage.Providers.Azure.Tests", "tests\OpenMessage.Providers.Azure.Tests\OpenMessage.Providers.Azure.Tests.xproj", "{48B14E17-6FE3-4B0D-BACC-966D4273C07B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Serializer.Protobuf", "src\OpenMessage.Serializer.Protobuf\OpenMessage.Serializer.Protobuf.csproj", "{A4A83F41-2CA1-4D90-B1F1-9EBEBC90B00D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Serializer.ServiceStackJson", "src\OpenMessage.Serializer.ServiceStackJson\OpenMessage.Serializer.ServiceStackJson.csproj", "{62E00C13-154C-4ACC-962B-A0DCD06522E0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Serializer.Utf8Json", "src\OpenMessage.Serializer.Utf8Json\OpenMessage.Serializer.Utf8Json.csproj", "{F36E3B9C-E14D-4F54-B7B7-FC9D54320A71}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Serializer.Wire", "src\OpenMessage.Serializer.Wire\OpenMessage.Serializer.Wire.csproj", "{69CDC76A-75D0-4B77-80D4-AD09D19AD2F1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Samples.Kafka", "samples\OpenMessage.Samples.Kafka\OpenMessage.Samples.Kafka.csproj", "{058D8B78-1999-427E-A12E-D09785EDA977}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.EventStore", "src\OpenMessage.EventStore\OpenMessage.EventStore.csproj", "{6D2199AB-4445-413A-8FE9-0E1898BA39F0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.MediatR", "src\OpenMessage.MediatR\OpenMessage.MediatR.csproj", "{21B732EF-AE9A-4156-B7C7-D5AF87753430}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.AWS.SNS", "src\OpenMessage.AWS.SNS\OpenMessage.AWS.SNS.csproj", "{73607B4A-82A7-4E12-8488-C7E1A1B58EC4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Redis", "src\OpenMessage.Redis\OpenMessage.Redis.csproj", "{0D0AD4AE-EA78-4342-8E78-E572EBC2DDC4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Samples.AWS", "samples\OpenMessage.Samples.AWS\OpenMessage.Samples.AWS.csproj", "{8F9B755C-8AD8-4B90-A2D9-8F870309CA6B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Samples.Setup", "samples\OpenMessage.Samples.Setup\OpenMessage.Samples.Setup.csproj", "{BF9B5246-3F07-4583-8793-7732F13CF5ED}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.Tests", "tests\OpenMessage.Tests\OpenMessage.Tests.csproj", "{EB0F85A1-258F-45B2-8C76-BD786F717250}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenMessage.MediatR.Tests", "tests\OpenMessage.MediatR.Tests\OpenMessage.MediatR.Tests.csproj", "{FD4CBC75-B898-46B9-B58D-A30228273F5D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenMessage.Testing", "src\OpenMessage.Testing\OpenMessage.Testing.csproj", "{DA130D21-63D3-4457-98CA-CA99A351EFA2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenMessage.Testing.Tests", "tests\OpenMessage.Testing.Tests\OpenMessage.Testing.Tests.csproj", "{86D9756B-65B2-46CA-9626-F439B387C7CB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenMessage.Polly", "src\OpenMessage.Polly\OpenMessage.Polly.csproj", "{3942DBA5-5FCC-4337-8FC7-C026DA054611}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{9BA7DF3A-585E-429B-9129-0188FD4F7DB7}" +ProjectSection(SolutionItems) = preProject + .github\workflows\ci-pipeline.yml = .github\workflows\ci-pipeline.yml + .github\workflows\release-builds.yml = .github\workflows\release-builds.yml +EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -36,41 +107,159 @@ Global {7D68A283-6D90-4A50-B015-D8C2BB5C7184}.Debug|Any CPU.Build.0 = Debug|Any CPU {7D68A283-6D90-4A50-B015-D8C2BB5C7184}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D68A283-6D90-4A50-B015-D8C2BB5C7184}.Release|Any CPU.Build.0 = Release|Any CPU - {D5C758B5-055C-41F0-A339-C8E4141076EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D5C758B5-055C-41F0-A339-C8E4141076EB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D5C758B5-055C-41F0-A339-C8E4141076EB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D5C758B5-055C-41F0-A339-C8E4141076EB}.Release|Any CPU.Build.0 = Release|Any CPU - {3AB720E9-27B4-4A28-9C1F-B89EA1A4257F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3AB720E9-27B4-4A28-9C1F-B89EA1A4257F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3AB720E9-27B4-4A28-9C1F-B89EA1A4257F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3AB720E9-27B4-4A28-9C1F-B89EA1A4257F}.Release|Any CPU.Build.0 = Release|Any CPU - {E70C427C-7285-48AC-A13B-D18519463786}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E70C427C-7285-48AC-A13B-D18519463786}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E70C427C-7285-48AC-A13B-D18519463786}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E70C427C-7285-48AC-A13B-D18519463786}.Release|Any CPU.Build.0 = Release|Any CPU - {987E4E03-FF13-496A-9B72-BC2ADF3A35D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {987E4E03-FF13-496A-9B72-BC2ADF3A35D2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {987E4E03-FF13-496A-9B72-BC2ADF3A35D2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {987E4E03-FF13-496A-9B72-BC2ADF3A35D2}.Release|Any CPU.Build.0 = Release|Any CPU - {66F710F3-460A-450B-8B07-9CD1476E5CF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {66F710F3-460A-450B-8B07-9CD1476E5CF3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {66F710F3-460A-450B-8B07-9CD1476E5CF3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {66F710F3-460A-450B-8B07-9CD1476E5CF3}.Release|Any CPU.Build.0 = Release|Any CPU - {48B14E17-6FE3-4B0D-BACC-966D4273C07B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {48B14E17-6FE3-4B0D-BACC-966D4273C07B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {48B14E17-6FE3-4B0D-BACC-966D4273C07B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {48B14E17-6FE3-4B0D-BACC-966D4273C07B}.Release|Any CPU.Build.0 = Release|Any CPU + {46AD3311-B953-49F4-8B7B-9E9AE5E368F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46AD3311-B953-49F4-8B7B-9E9AE5E368F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46AD3311-B953-49F4-8B7B-9E9AE5E368F7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46AD3311-B953-49F4-8B7B-9E9AE5E368F7}.Release|Any CPU.Build.0 = Release|Any CPU + {D2EBDC48-95C2-4EBD-9F07-5E3379ECDCF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2EBDC48-95C2-4EBD-9F07-5E3379ECDCF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2EBDC48-95C2-4EBD-9F07-5E3379ECDCF9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2EBDC48-95C2-4EBD-9F07-5E3379ECDCF9}.Release|Any CPU.Build.0 = Release|Any CPU + {D2AF2B8F-947B-447E-BC30-B13ECD28A40E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2AF2B8F-947B-447E-BC30-B13ECD28A40E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2AF2B8F-947B-447E-BC30-B13ECD28A40E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2AF2B8F-947B-447E-BC30-B13ECD28A40E}.Release|Any CPU.Build.0 = Release|Any CPU + {46A8FD07-E04E-48F0-8D38-5B61320A3CB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46A8FD07-E04E-48F0-8D38-5B61320A3CB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46A8FD07-E04E-48F0-8D38-5B61320A3CB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46A8FD07-E04E-48F0-8D38-5B61320A3CB8}.Release|Any CPU.Build.0 = Release|Any CPU + {ECF700A0-BC08-4796-8424-6EEB31C649A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECF700A0-BC08-4796-8424-6EEB31C649A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECF700A0-BC08-4796-8424-6EEB31C649A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECF700A0-BC08-4796-8424-6EEB31C649A9}.Release|Any CPU.Build.0 = Release|Any CPU + {92AD2CB7-A31F-41CD-9B0E-B7677EDACF2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92AD2CB7-A31F-41CD-9B0E-B7677EDACF2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92AD2CB7-A31F-41CD-9B0E-B7677EDACF2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92AD2CB7-A31F-41CD-9B0E-B7677EDACF2F}.Release|Any CPU.Build.0 = Release|Any CPU + {4C2BF517-296A-4559-B092-C5F639E66DD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C2BF517-296A-4559-B092-C5F639E66DD7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C2BF517-296A-4559-B092-C5F639E66DD7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C2BF517-296A-4559-B092-C5F639E66DD7}.Release|Any CPU.Build.0 = Release|Any CPU + {75E4E6D9-D4EF-4C53-BE74-9CDA176ECDFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75E4E6D9-D4EF-4C53-BE74-9CDA176ECDFC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75E4E6D9-D4EF-4C53-BE74-9CDA176ECDFC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75E4E6D9-D4EF-4C53-BE74-9CDA176ECDFC}.Release|Any CPU.Build.0 = Release|Any CPU + {8C796D60-D79F-46D5-8CE9-8372CCF5F317}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C796D60-D79F-46D5-8CE9-8372CCF5F317}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C796D60-D79F-46D5-8CE9-8372CCF5F317}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C796D60-D79F-46D5-8CE9-8372CCF5F317}.Release|Any CPU.Build.0 = Release|Any CPU + {5F633833-217B-429D-86F1-8A2327751144}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F633833-217B-429D-86F1-8A2327751144}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F633833-217B-429D-86F1-8A2327751144}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F633833-217B-429D-86F1-8A2327751144}.Release|Any CPU.Build.0 = Release|Any CPU + {5448B5C6-8C3A-4B71-8789-4AA8880066C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5448B5C6-8C3A-4B71-8789-4AA8880066C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5448B5C6-8C3A-4B71-8789-4AA8880066C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5448B5C6-8C3A-4B71-8789-4AA8880066C4}.Release|Any CPU.Build.0 = Release|Any CPU + {ED4255C2-9EAA-4EE4-AA71-EA7C219FC09E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED4255C2-9EAA-4EE4-AA71-EA7C219FC09E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED4255C2-9EAA-4EE4-AA71-EA7C219FC09E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED4255C2-9EAA-4EE4-AA71-EA7C219FC09E}.Release|Any CPU.Build.0 = Release|Any CPU + {00411544-72BC-4569-9EBD-491362002F96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00411544-72BC-4569-9EBD-491362002F96}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00411544-72BC-4569-9EBD-491362002F96}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00411544-72BC-4569-9EBD-491362002F96}.Release|Any CPU.Build.0 = Release|Any CPU + {A4A83F41-2CA1-4D90-B1F1-9EBEBC90B00D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4A83F41-2CA1-4D90-B1F1-9EBEBC90B00D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4A83F41-2CA1-4D90-B1F1-9EBEBC90B00D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4A83F41-2CA1-4D90-B1F1-9EBEBC90B00D}.Release|Any CPU.Build.0 = Release|Any CPU + {62E00C13-154C-4ACC-962B-A0DCD06522E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62E00C13-154C-4ACC-962B-A0DCD06522E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62E00C13-154C-4ACC-962B-A0DCD06522E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62E00C13-154C-4ACC-962B-A0DCD06522E0}.Release|Any CPU.Build.0 = Release|Any CPU + {F36E3B9C-E14D-4F54-B7B7-FC9D54320A71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F36E3B9C-E14D-4F54-B7B7-FC9D54320A71}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F36E3B9C-E14D-4F54-B7B7-FC9D54320A71}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F36E3B9C-E14D-4F54-B7B7-FC9D54320A71}.Release|Any CPU.Build.0 = Release|Any CPU + {69CDC76A-75D0-4B77-80D4-AD09D19AD2F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {69CDC76A-75D0-4B77-80D4-AD09D19AD2F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {69CDC76A-75D0-4B77-80D4-AD09D19AD2F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {69CDC76A-75D0-4B77-80D4-AD09D19AD2F1}.Release|Any CPU.Build.0 = Release|Any CPU + {058D8B78-1999-427E-A12E-D09785EDA977}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {058D8B78-1999-427E-A12E-D09785EDA977}.Debug|Any CPU.Build.0 = Debug|Any CPU + {058D8B78-1999-427E-A12E-D09785EDA977}.Release|Any CPU.ActiveCfg = Release|Any CPU + {058D8B78-1999-427E-A12E-D09785EDA977}.Release|Any CPU.Build.0 = Release|Any CPU + {6D2199AB-4445-413A-8FE9-0E1898BA39F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D2199AB-4445-413A-8FE9-0E1898BA39F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D2199AB-4445-413A-8FE9-0E1898BA39F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D2199AB-4445-413A-8FE9-0E1898BA39F0}.Release|Any CPU.Build.0 = Release|Any CPU + {21B732EF-AE9A-4156-B7C7-D5AF87753430}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21B732EF-AE9A-4156-B7C7-D5AF87753430}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21B732EF-AE9A-4156-B7C7-D5AF87753430}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21B732EF-AE9A-4156-B7C7-D5AF87753430}.Release|Any CPU.Build.0 = Release|Any CPU + {73607B4A-82A7-4E12-8488-C7E1A1B58EC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73607B4A-82A7-4E12-8488-C7E1A1B58EC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73607B4A-82A7-4E12-8488-C7E1A1B58EC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73607B4A-82A7-4E12-8488-C7E1A1B58EC4}.Release|Any CPU.Build.0 = Release|Any CPU + {0D0AD4AE-EA78-4342-8E78-E572EBC2DDC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D0AD4AE-EA78-4342-8E78-E572EBC2DDC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D0AD4AE-EA78-4342-8E78-E572EBC2DDC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D0AD4AE-EA78-4342-8E78-E572EBC2DDC4}.Release|Any CPU.Build.0 = Release|Any CPU + {8F9B755C-8AD8-4B90-A2D9-8F870309CA6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F9B755C-8AD8-4B90-A2D9-8F870309CA6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F9B755C-8AD8-4B90-A2D9-8F870309CA6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F9B755C-8AD8-4B90-A2D9-8F870309CA6B}.Release|Any CPU.Build.0 = Release|Any CPU + {BF9B5246-3F07-4583-8793-7732F13CF5ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF9B5246-3F07-4583-8793-7732F13CF5ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF9B5246-3F07-4583-8793-7732F13CF5ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF9B5246-3F07-4583-8793-7732F13CF5ED}.Release|Any CPU.Build.0 = Release|Any CPU + {EB0F85A1-258F-45B2-8C76-BD786F717250}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB0F85A1-258F-45B2-8C76-BD786F717250}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB0F85A1-258F-45B2-8C76-BD786F717250}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB0F85A1-258F-45B2-8C76-BD786F717250}.Release|Any CPU.Build.0 = Release|Any CPU + {FD4CBC75-B898-46B9-B58D-A30228273F5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD4CBC75-B898-46B9-B58D-A30228273F5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD4CBC75-B898-46B9-B58D-A30228273F5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD4CBC75-B898-46B9-B58D-A30228273F5D}.Release|Any CPU.Build.0 = Release|Any CPU + {DA130D21-63D3-4457-98CA-CA99A351EFA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA130D21-63D3-4457-98CA-CA99A351EFA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA130D21-63D3-4457-98CA-CA99A351EFA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA130D21-63D3-4457-98CA-CA99A351EFA2}.Release|Any CPU.Build.0 = Release|Any CPU + {86D9756B-65B2-46CA-9626-F439B387C7CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86D9756B-65B2-46CA-9626-F439B387C7CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86D9756B-65B2-46CA-9626-F439B387C7CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86D9756B-65B2-46CA-9626-F439B387C7CB}.Release|Any CPU.Build.0 = Release|Any CPU + {3942DBA5-5FCC-4337-8FC7-C026DA054611}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3942DBA5-5FCC-4337-8FC7-C026DA054611}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3942DBA5-5FCC-4337-8FC7-C026DA054611}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3942DBA5-5FCC-4337-8FC7-C026DA054611}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {7D68A283-6D90-4A50-B015-D8C2BB5C7184} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} - {D5C758B5-055C-41F0-A339-C8E4141076EB} = {B4E58D4F-40CD-45CA-AC6F-1DF4D9CB9A49} - {3AB720E9-27B4-4A28-9C1F-B89EA1A4257F} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} - {E70C427C-7285-48AC-A13B-D18519463786} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} - {987E4E03-FF13-496A-9B72-BC2ADF3A35D2} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} - {66F710F3-460A-450B-8B07-9CD1476E5CF3} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} - {48B14E17-6FE3-4B0D-BACC-966D4273C07B} = {B4E58D4F-40CD-45CA-AC6F-1DF4D9CB9A49} + {46AD3311-B953-49F4-8B7B-9E9AE5E368F7} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {D2EBDC48-95C2-4EBD-9F07-5E3379ECDCF9} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {D2AF2B8F-947B-447E-BC30-B13ECD28A40E} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {46A8FD07-E04E-48F0-8D38-5B61320A3CB8} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {ECF700A0-BC08-4796-8424-6EEB31C649A9} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {92AD2CB7-A31F-41CD-9B0E-B7677EDACF2F} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {4C2BF517-296A-4559-B092-C5F639E66DD7} = {B4CC2690-ADC2-4B78-A961-06C0D6CD948F} + {75E4E6D9-D4EF-4C53-BE74-9CDA176ECDFC} = {B4CC2690-ADC2-4B78-A961-06C0D6CD948F} + {8C796D60-D79F-46D5-8CE9-8372CCF5F317} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {5F633833-217B-429D-86F1-8A2327751144} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {5448B5C6-8C3A-4B71-8789-4AA8880066C4} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {ED4255C2-9EAA-4EE4-AA71-EA7C219FC09E} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {00411544-72BC-4569-9EBD-491362002F96} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {A4A83F41-2CA1-4D90-B1F1-9EBEBC90B00D} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {62E00C13-154C-4ACC-962B-A0DCD06522E0} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {F36E3B9C-E14D-4F54-B7B7-FC9D54320A71} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {69CDC76A-75D0-4B77-80D4-AD09D19AD2F1} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {058D8B78-1999-427E-A12E-D09785EDA977} = {B4CC2690-ADC2-4B78-A961-06C0D6CD948F} + {6D2199AB-4445-413A-8FE9-0E1898BA39F0} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {21B732EF-AE9A-4156-B7C7-D5AF87753430} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {73607B4A-82A7-4E12-8488-C7E1A1B58EC4} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {0D0AD4AE-EA78-4342-8E78-E572EBC2DDC4} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {8F9B755C-8AD8-4B90-A2D9-8F870309CA6B} = {B4CC2690-ADC2-4B78-A961-06C0D6CD948F} + {BF9B5246-3F07-4583-8793-7732F13CF5ED} = {B4CC2690-ADC2-4B78-A961-06C0D6CD948F} + {EB0F85A1-258F-45B2-8C76-BD786F717250} = {B4E58D4F-40CD-45CA-AC6F-1DF4D9CB9A49} + {FD4CBC75-B898-46B9-B58D-A30228273F5D} = {B4E58D4F-40CD-45CA-AC6F-1DF4D9CB9A49} + {DA130D21-63D3-4457-98CA-CA99A351EFA2} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + {86D9756B-65B2-46CA-9626-F439B387C7CB} = {B4E58D4F-40CD-45CA-AC6F-1DF4D9CB9A49} + {3942DBA5-5FCC-4337-8FC7-C026DA054611} = {E8751802-CCE4-4C40-9241-E5E01DBEF627} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C6B88150-594B-4719-8B95-AF622870727B} EndGlobalSection EndGlobal diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ca083ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,102 @@ +version: '3.5' + +services: + eventstore: + image: eventstore/eventstore:latest + hostname: eventstore + container_name: eventstore + restart: always + networks: [ "OpenMessage" ] + ports: + - 1113:1113 + - 2112:2112 + - 2113:2113 + environment: + EVENTSTORE_CLUSTER_DNS: eventstore + EVENTSTORE_CLUSTER_SIZE: 1 + EVENTSTORE_CLUSTER_GOSSIP_PORT: 2112 + + zookeeper: + image: confluentinc/cp-zookeeper:latest + hostname: zookeeper + container_name: zookeeper + restart: always + networks: [ "OpenMessage" ] + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + + kafka: + image: confluentinc/cp-enterprise-kafka:latest + hostname: kafka + container_name: kafka + restart: always + networks: [ "OpenMessage" ] + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_DELETE_TOPIC_ENABLE: "true" + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + + rabbit: + image: rabbitmq:3-management-alpine + hostname: rabbitmq + container_name: rabbitmq + restart: always + networks: [ "OpenMessage" ] + ports: + - 4369:4369 + - 5671:5671 + - 5672:5672 + - 15672:15672 + - 25672:25672 + + redis: + image: redis:alpine + hostname: redis + container_name: redis + restart: always + networks: [ "OpenMessage" ] + ports: + - 6379:6379 + + redisUI: + image: rediscommander/redis-commander + hostname: redisui + container_name: redisui + restart: always + networks: [ "OpenMessage" ] + depends_on: + - redis + environment: + - REDIS_HOSTS=local:redis:6379 + ports: + - 8081:8081 + + localstack: + image: localstack/localstack + hostname: localstack + container_name: localstack + restart: always + networks: [ "OpenMessage" ] + environment: + SERVICES: "sns,sqs" + AWS_DEFAULT_REGION: eu-west-2 + PORT_WEB_UI: 8082 + AWS_ACCESS_KEY_ID: XXX + AWS_SECRET_ACCESS_KEY: XXX + DEBUG: 1 + ports: + - 4575:4575 + - 4576:4576 + - 8082:8082 + +networks: + OpenMessage: + driver: bridge \ No newline at end of file diff --git a/global.json b/global.json deleted file mode 100644 index 37db865..0000000 --- a/global.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "projects": [ "src", "tests" ], - "sdk": { - "version": "1.0.0-preview2-003121" - } -} diff --git a/readme.md b/readme.md index b455b2b..dc3692c 100644 --- a/readme.md +++ b/readme.md @@ -1,86 +1,97 @@ -#OpenMessage - -Master Branch: ![Build Status](https://im5tu.visualstudio.com/_apis/public/build/definitions/e4fcda74-9f33-4672-b774-b4419099857c/2/badge) - -Dev Branch: ![Build Status](https://im5tu.visualstudio.com/_apis/public/build/definitions/e4fcda74-9f33-4672-b774-b4419099857c/5/badge) +# OpenMessage OpenMessage aims to simplify the service bus paradigm by using pre-existing patterns to build an extensible architecture. -##Getting Started +Designed for the generic hosting model that .Net Core 3 supports, the library aims to be able to cater for a wide range of scenarios, including receiving the same type from multiple sources - aiding a whole host of scenarios. -The library is based around the `Microsoft.Extensions.*` packages and relies upon the abstractions for depenency injection and logging allowing you the freedom to pick the implementations that best suit your scenario. +The core library `OpenMessage` ships with an InMemory provider and a JSON serializer from the AspNetCore team (`System.Text.Json`). -Assuming you want to connect to Azure Service Bus, here is how you configure OpenMessage: +## Getting Started -1 - Add the provider: +The library is based around the `Microsoft.Extensions.*` packages and relies upon the abstractions for dependency injection and logging allowing you the freedom to pick the implementations that best suit your scenario. - PM> Install-Package OpenMessage.Providers.Azure +_Note: The rest of this guide requires you to be using version 3 of `Microsoft.Extensions.*`._ -2 - Add the serializer (or write your own): +1 - Install the `OpenMessage` package: - PM> Install-Package OpenMessage.Serializer.JsonNet - -3 - Add the services to your service collection: +> PM> Install-Package OpenMessage - using OpenMessage; - using OpenMessage.Providers.Azure.Configuration; - using OpenMessage.Serializer.JsonNet; - - ... - - IServiceCollection AddServices(IServiceCollection services) - { - return services - .AddOpenMessage() - .AddJsonNetSerializer() - .Configure(config => { - config.ConnectionString = "YOUR CONNECTION STRING HERE"); - }); - } - -###Sending Messages +_You may also any of the providers listed below for this sample as the Memory provider ships out of the box._ -4 - Add either a Queue/Topic dispatcher to the service collection: +2 - Configure your host: - IServiceCollection AddQueueBindings(IServiceCollection services) + internal class Program { - return services.AddQueueDispatcher(); + private static async Task Main() + { + await Host.CreateDefaultBuilder() + .ConfigureServices(services => services.AddOptions().AddLogging()) + // Configure OpenMessage + .ConfigureMessaging(host => + { + // Adds a memory based consumer and dispatcher + host.ConfigureMemory(); + + // Adds a handler that writes the entire message in json format to the console + host.ConfigureHandler(msg => Console.WriteLine($"Hello {msg.Value.Name}")); + }) + .Build() + .RunAsync(); + } } -5 - Inject an `IDispatcher` into your class of choice: +### Sending Messages - internal class CommandGenerator - { - private readonly IDispatcher _dispatcher; - - public CommandGenerator(IDispatcher dispatcher) - { - _dispatcher = dispatcher; - } - } +To send messages, inject `IDispatcher` and call `DispatchAsync` and the library will route your message to the configured dispatcher for that type. -###Receiving Messages +### Receiving Messages -4 - Add either a Queue/Subscription observable to the service collection: +When a message is received, it flows as follows: - IServiceCollection AddQueueBindings(IServiceCollection services) - { - return services.AddQueueObservable(); - } +> Message Pump > Channel > Consumer Pump > Pipeline > Handler + +This library takes care of everything except the handlers. You have a few choices for implementing a handler, all registered via `.ConfigureHandler`: -5 - When done, resolve an `IEnumerable` from the service collection to begin receiving messages. +1. Use a simple `Action>` +2. Use a simple `Func, Task>` +3. Inherit from `Handler` +4. Implement `IHandler` -##Serializers +By default, after your handler has been run, and assuming the underlying provider supports it, the message is automatically acknowledged. This can be configured by calling `ConfigurePipelineOptions` as well as options for the consumer pump and handler timeout. + +## Serializers You can add more than one serializer to OpenMessage. In this scenario, all registered serializers are checked to see whether they can deserialize the message. When serializing the last registered serializer is used, service collection provider depending. -- [x] [Json.Net](http://www.nuget.org/packages/OpenMessage.Serializer.JsonNet/) -- [x] [Jil](http://www.nuget.org/packages/OpenMessage.Serializer.Jil/) -- [x] [Protobuf](http://www.nuget.org/packages/OpenMessage.Serializer.ProtobufNet/) +Here is a list of the available serializers: + +- [x] Hyperion +- [x] Jil +- [x] JsonDotNet +- [x] MessagePack +- [x] MsgPack +- [x] Protobuf +- [x] ServiceStackJson +- [x] Utf8Json +- [x] Wire + +## Providers + +With OpenMessage you can easily receive from multiple sources in a centralised pipeline whilst providing as much of the underlying providers flexibility as possible. -##Providers +Here is a list of the available providers: -- [x] [Azure Service Bus](http://www.nuget.org/packages/OpenMessage.Providers.Azure/) +- [x] Apache Kafka +- [x] AWS SQS +- [x] AWS SNS +- [ ] AWS Kinesis +- [ ] AWS EventBridge - [ ] Azure Event Hubs -- [ ] In Memory -- [ ] Rabbit MQ \ No newline at end of file +- [ ] Azure Service Bus +- [ ] Eventstore +- [x] InMemory +- [x] MediatR +- [ ] NATS +- [ ] RabbitMq + +_Note: Any unchecked providers are currently a work in progress_. diff --git a/samples/OpenMessage.Samples.AWS/OpenMessage.Samples.AWS.csproj b/samples/OpenMessage.Samples.AWS/OpenMessage.Samples.AWS.csproj new file mode 100644 index 0000000..4bb3179 --- /dev/null +++ b/samples/OpenMessage.Samples.AWS/OpenMessage.Samples.AWS.csproj @@ -0,0 +1,14 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + + diff --git a/samples/OpenMessage.Samples.AWS/Program.cs b/samples/OpenMessage.Samples.AWS/Program.cs new file mode 100644 index 0000000..4ceed8f --- /dev/null +++ b/samples/OpenMessage.Samples.AWS/Program.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenMessage.AWS.SQS; +using OpenMessage.Samples.Core.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Samples.AWS +{ + internal class Program + { + private static int _counter; + + private static async Task Main(string[] args) + { + Environment.SetEnvironmentVariable("AWS_ACCESS_KEY_ID", "XXX", EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("AWS_SECRET_ACCESS_KEY", "XXX", EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("AWS_SESSION_TOKEN", "XXX", EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("AWS_DEFAULT_REGION", "eu-west-2", EnvironmentVariableTarget.Process); + + var verbose = false; + + await Host.CreateDefaultBuilder() + .ConfigureServices(services => services.AddOptions() + .AddLogging() + .AddSampleCore() + .AddMassProducerService() // Adds a producer that calls configured dispatcher + ) + .ConfigureMessaging(host => + { + // Adds a handler that writes to console every 100 messages + host.ConfigureHandler(msg => + { + var counter = Interlocked.Increment(ref _counter); + + if (verbose) + { + var properties = msg is ISupportProperties sp + ? sp.Properties + : Enumerable.Empty>(); + + Console.WriteLine($"Received: #{counter} Received: {DateTime.UtcNow} Properties: {string.Join(",", properties)}"); + } + else if(counter % 100 == 1) + { + Console.WriteLine($"Received: #{counter}"); + } + }); + + // Allow us to write to SNS + // host.ConfigureSnsDispatcher() + // .FromConfiguration(config => + // { + // config.TopicArn = "arn:aws:sns:eu-west-2:000000000000:openmessage_samples_core_models_simplemodel"; + // config.ServiceURL = "http://localhost:4575"; + // }) + // .Build(); + + // For testing the dispatchers + host.ConfigureSqsDispatcher() + .FromConfiguration(config => + { + config.QueueUrl = "http://localhost:4576/000000000000/openmessage_samples_core_models_simplemodel.queue"; + config.ServiceURL = "http://localhost:4576"; + }) + .WithBatchedDispatcher(true) + .Build(); + + // Consume from the same topic as we are writing to + host.ConfigureSqsConsumer() + .FromConfiguration(config => + { + config.QueueUrl = "http://localhost:4576/000000000000/openmessage_samples_core_models_simplemodel.queue"; + config.ServiceURL = "http://localhost:4576"; + }) + .Build(); + }) + .Build() + .RunAsync(); + } + } +} \ No newline at end of file diff --git a/samples/OpenMessage.Samples.Core/Extensions.cs b/samples/OpenMessage.Samples.Core/Extensions.cs new file mode 100644 index 0000000..6571570 --- /dev/null +++ b/samples/OpenMessage.Samples.Core/Extensions.cs @@ -0,0 +1,18 @@ +using OpenMessage.Samples.Core.Services; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class Extensions + { + public static IServiceCollection AddSampleCore(this IServiceCollection services) + => services.AddHostedService(); + + public static IServiceCollection AddMassProducerService(this IServiceCollection services) + where T : class, new() + => services.AddHostedService>(); + + public static IServiceCollection AddProducerService(this IServiceCollection services) + where T : class, new() + => services.AddHostedService>(); + } +} \ No newline at end of file diff --git a/samples/OpenMessage.Samples.Core/Models/CoreModel.cs b/samples/OpenMessage.Samples.Core/Models/CoreModel.cs new file mode 100644 index 0000000..29434f6 --- /dev/null +++ b/samples/OpenMessage.Samples.Core/Models/CoreModel.cs @@ -0,0 +1,9 @@ +using System; + +namespace OpenMessage.Samples.Core.Models +{ + public abstract class CoreModel + { + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/samples/OpenMessage.Samples.Core/Models/SimpleModel.cs b/samples/OpenMessage.Samples.Core/Models/SimpleModel.cs new file mode 100644 index 0000000..2870f94 --- /dev/null +++ b/samples/OpenMessage.Samples.Core/Models/SimpleModel.cs @@ -0,0 +1,16 @@ +using System; + +namespace OpenMessage.Samples.Core.Models +{ + public class SimpleModel : CoreModel + { + public string Property1 { get; set; } = Guid.NewGuid() + .ToString("n"); + + public string Property2 { get; set; } = Guid.NewGuid() + .ToString("n"); + + public string Property3 { get; set; } = Guid.NewGuid() + .ToString("n"); + } +} \ No newline at end of file diff --git a/samples/OpenMessage.Samples.Core/OpenMessage.Samples.Core.csproj b/samples/OpenMessage.Samples.Core/OpenMessage.Samples.Core.csproj new file mode 100644 index 0000000..a57c244 --- /dev/null +++ b/samples/OpenMessage.Samples.Core/OpenMessage.Samples.Core.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp3.1 + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/OpenMessage.Samples.Core/Services/DiagnosticService.cs b/samples/OpenMessage.Samples.Core/Services/DiagnosticService.cs new file mode 100644 index 0000000..e4c640e --- /dev/null +++ b/samples/OpenMessage.Samples.Core/Services/DiagnosticService.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +namespace OpenMessage.Samples.Core.Services +{ + internal sealed class DiagnosticService : EventListener, IHostedService + { + private static readonly string EventCounterType = "CounterType"; + private static readonly string EventCountEventName = "EventCounters"; + private static readonly string EventName = "Name"; + private static readonly string IncrementName = "Increment"; + private static readonly string MeanType = "Mean"; + private static readonly string SumType = "Sum"; + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + if (eventData.EventName == EventCountEventName + && eventData.Payload?.Count > 0 + && eventData.Payload[0] is IDictionary data + && data.TryGetValue(EventCounterType, out var counterType) + && data.TryGetValue(EventName, out var name)) + { + if (name is null || counterType is null) + return; + + var metricName = name.ToString(); + var metricType = counterType.ToString(); + + if (SumType.Equals(metricType) && data.TryGetValue(IncrementName, out var increment)) + { + Debug.WriteLine("{0}: {1}", metricName, increment); + } + else if (MeanType.Equals(metricType) && data.TryGetValue(MeanType, out var mean)) + { + Debug.WriteLine("{0}: {1}", metricName, mean); + } + } + } + + public Task StartAsync(CancellationToken cancellationToken) + { + EnableEvents(OpenMessageEventSource.Instance, EventLevel.LogAlways, EventKeywords.All, new Dictionary {{"EventCounterIntervalSec", "1"}}); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/samples/OpenMessage.Samples.Core/Services/MassProducerService.cs b/samples/OpenMessage.Samples.Core/Services/MassProducerService.cs new file mode 100644 index 0000000..98332bc --- /dev/null +++ b/samples/OpenMessage.Samples.Core/Services/MassProducerService.cs @@ -0,0 +1,54 @@ +using AutoFixture; +using Microsoft.Extensions.Hosting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Samples.Core.Services +{ + internal sealed class MassProducerService : BackgroundService + where T : new() + { + private readonly IDispatcher _dispatcher; + private readonly Fixture _fixture = new Fixture(); + private const int DispatchBatchSize = 100; + + public MassProducerService(IDispatcher dispatcher) => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Without this line we can encounter a blocking issue such as: https://github.com/dotnet/extensions/issues/2816 + await Task.Yield(); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.WhenAll(Enumerable.Range(1, DispatchBatchSize) + .Select(async x => + { + try + { + await _dispatcher.DispatchAsync(new ExtendedMessage(_fixture.Create()) + { + //SendDelay = TimeSpan.FromSeconds(15), + Properties = new List> + { + new KeyValuePair("Dispatched", DateTime.UtcNow.ToString()) + } + }, stoppingToken); + } + catch (Exception e) + { + if (stoppingToken.IsCancellationRequested) + return; + + Console.WriteLine("MassProducer: " + e.Message); + } + })); + + Console.WriteLine($"Dispatched: {DispatchBatchSize}"); + } + } + } +} \ No newline at end of file diff --git a/samples/OpenMessage.Samples.Core/Services/ProducerService.cs b/samples/OpenMessage.Samples.Core/Services/ProducerService.cs new file mode 100644 index 0000000..c5d7e73 --- /dev/null +++ b/samples/OpenMessage.Samples.Core/Services/ProducerService.cs @@ -0,0 +1,37 @@ +using AutoFixture; +using Microsoft.Extensions.Hosting; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Samples.Core.Services +{ + internal sealed class ProducerService : BackgroundService + where T : new() + { + private readonly IDispatcher _dispatcher; + private readonly Fixture _fixture = new Fixture(); + + public ProducerService(IDispatcher dispatcher) => _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Without this line we can encounter a blocking issue such as: https://github.com/dotnet/extensions/issues/2816 + await Task.Yield(); + + while (!stoppingToken.IsCancellationRequested) + try + { + await _dispatcher.DispatchAsync(_fixture.Create(), stoppingToken); + } + catch (Exception e) + { + Console.WriteLine("Producer: " + e.Message); + } + finally + { + await Task.Delay(1000); + } + } + } +} \ No newline at end of file diff --git a/samples/OpenMessage.Samples.Kafka/OpenMessage.Samples.Kafka.csproj b/samples/OpenMessage.Samples.Kafka/OpenMessage.Samples.Kafka.csproj new file mode 100644 index 0000000..84ab4eb --- /dev/null +++ b/samples/OpenMessage.Samples.Kafka/OpenMessage.Samples.Kafka.csproj @@ -0,0 +1,14 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + + diff --git a/samples/OpenMessage.Samples.Kafka/Program.cs b/samples/OpenMessage.Samples.Kafka/Program.cs new file mode 100644 index 0000000..4a2849f --- /dev/null +++ b/samples/OpenMessage.Samples.Kafka/Program.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenMessage.Samples.Core.Models; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Samples.Kafka +{ + internal class Program + { + private static int _counter; + + private static async Task Main() + { + await Host.CreateDefaultBuilder() + .ConfigureServices(services => services.AddOptions() + .AddLogging() + .AddSampleCore() + .AddMassProducerService() // Adds a producer that calls configured dispatcher + ) + .ConfigureMessaging(host => + { + // Adds a handler that writes to console every 1000 messages + host.ConfigureHandler(msg => + { + var counter = Interlocked.Increment(ref _counter); + + if (counter % 1000 == 0) + Console.WriteLine($"Counter: {counter}"); + }); + + // Allow us to write to kafka + host.ConfigureKafkaDispatcher(options => { }); + + // Consume from the same topic as we are writing to + host.ConfigureKafkaConsumer() + .FromTopic("OpenMessage.Samples.Core.Models.SimpleModel".ToLowerInvariant()) + .Build(); + }) + .Build() + .RunAsync(); + } + } +} \ No newline at end of file diff --git a/samples/OpenMessage.Samples.Memory/OpenMessage.Samples.Memory.csproj b/samples/OpenMessage.Samples.Memory/OpenMessage.Samples.Memory.csproj new file mode 100644 index 0000000..f5325b1 --- /dev/null +++ b/samples/OpenMessage.Samples.Memory/OpenMessage.Samples.Memory.csproj @@ -0,0 +1,13 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + diff --git a/samples/OpenMessage.Samples.Memory/Program.cs b/samples/OpenMessage.Samples.Memory/Program.cs new file mode 100644 index 0000000..2182d35 --- /dev/null +++ b/samples/OpenMessage.Samples.Memory/Program.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenMessage.Samples.Core.Models; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Samples.Memory +{ + internal class Program + { + private static int _counter; + + private static async Task Main() + { + await Host.CreateDefaultBuilder() + .ConfigureServices(services => services.AddOptions() + .AddLogging() + .AddMassProducerService() // Adds a producer that calls configured dispatcher + ) + .ConfigureMessaging(host => + { + // Adds a memory based consumer and dispatcher + host.ConfigureMemory() + .Build(); + + // Adds a handler that writes to console every 1000 messages + host.ConfigureHandler(msg => + { + var counter = Interlocked.Increment(ref _counter); + + if (counter % 1000 == 0) + Console.WriteLine($"Counter: {counter}"); + }); + }) + .Build() + .RunAsync(); + } + } +} \ No newline at end of file diff --git a/samples/OpenMessage.Samples.Setup/OpenMessage.Samples.Setup.csproj b/samples/OpenMessage.Samples.Setup/OpenMessage.Samples.Setup.csproj new file mode 100644 index 0000000..13a19b6 --- /dev/null +++ b/samples/OpenMessage.Samples.Setup/OpenMessage.Samples.Setup.csproj @@ -0,0 +1,20 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + + + + + + + + diff --git a/samples/OpenMessage.Samples.Setup/Program.cs b/samples/OpenMessage.Samples.Setup/Program.cs new file mode 100644 index 0000000..d67a90d --- /dev/null +++ b/samples/OpenMessage.Samples.Setup/Program.cs @@ -0,0 +1,121 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Amazon.SQS; +using Amazon.SQS.Model; +using Confluent.Kafka; +using Confluent.Kafka.Admin; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace OpenMessage.Samples.Setup +{ + internal class Program + { + private static readonly string topic = "OpenMessage.Samples.Core.Models.SimpleModel".ToLowerInvariant(); + + private static async Task Main(string[] args) + { + await Task.WhenAll(SetupKafka(), SetupAws()); + } + + private static async Task SetupAws() + { + try + { + Environment.SetEnvironmentVariable("AWS_ACCESS_KEY_ID", "XXX", EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("AWS_SECRET_ACCESS_KEY", "XXX", EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("AWS_SESSION_TOKEN", "XXX", EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("AWS_DEFAULT_REGION", "us-east-1", EnvironmentVariableTarget.Process); + + var snsClient = new AmazonSimpleNotificationServiceClient(new AmazonSimpleNotificationServiceConfig + { + ServiceURL = "http://localhost:4575" + }); + + var sqsClient = new AmazonSQSClient(new AmazonSQSConfig + { + ServiceURL = "http://localhost:4576" + }); + + var topicName = topic.Replace(".", "_"); + + var topicRequest = new CreateTopicRequest(topicName); + var topicResponse = await snsClient.CreateTopicAsync(topicRequest); + + var queueRequest = new CreateQueueRequest($"{topicName}.queue"); + var queueResponse = await sqsClient.CreateQueueAsync(queueRequest); + + var subscribeRequest = new SubscribeRequest + { + Endpoint = queueResponse.QueueUrl, + TopicArn = topicResponse.TopicArn, + Protocol = "sqs", + ReturnSubscriptionArn = true, + Attributes = new Dictionary + { + ["RawMessageDelivery"] = "true" + } + }; + var subscribeResponse = await snsClient.SubscribeAsync(subscribeRequest); + + (await snsClient.ListTopicsAsync()).Topics.ForEach(x => Console.WriteLine($"[AWS] Topic: {x.TopicArn}")); + (await sqsClient.ListQueuesAsync(new ListQueuesRequest())).QueueUrls.ForEach(x => Console.WriteLine($"[AWS] Queue: {x}")); + (await snsClient.ListSubscriptionsAsync(new ListSubscriptionsRequest())).Subscriptions.ForEach(x => Console.WriteLine($"[AWS] Subscription: {x.TopicArn} -> {x.Endpoint}")); + } + catch (Exception e) + { + Console.WriteLine($"[AWS] {e.Message}"); + } + } + + private static async Task SetupKafka() + { + try + { + var client = new AdminClientBuilder(new Dictionary + { + ["bootstrap.servers"] = "localhost:9092", + ["topic.metadata.refresh.interval.ms"] = "500" + }).SetLogHandler((client, message) => Console.WriteLine($"[Kafka] [{message.Level}] {message.Message}")) + .Build(); + + var topics = client.GetMetadata(TimeSpan.FromMinutes(1)) + .Topics; + + if (topics.Any(x => x.Topic.StartsWith("OpenMessage", StringComparison.OrdinalIgnoreCase))) + { + await client.DeleteTopicsAsync(topics.Where(x => x.Topic.StartsWith("OpenMessage", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Topic)); + + await Task.Delay(1000); + } + + await client.CreateTopicsAsync(new[] + { + new TopicSpecification + { + Name = topic, + NumPartitions = 5, + ReplicationFactor = 1, + Configs = new Dictionary + { + ["retention.ms"] = TimeSpan.FromMinutes(15) + .Milliseconds.ToString() + } + } + }); + await Task.Delay(1000); + + topics = client.GetMetadata(TimeSpan.FromMinutes(1)) + .Topics; + topics.ForEach(x => Console.WriteLine($"[Kafka] Topic: {x.Topic}(Partitions: {x.Partitions.Count})")); + } + catch (Exception e) + { + Console.WriteLine($"[Kafka] {e.Message}"); + } + } + } +} \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..d437ad4 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,41 @@ + + + + + netstandard2.1;netcoreapp3.1 + icon.png + + preview + + + + True + + + + + + true + true + $(RepositoryRoot)/artifacts + Preview release for use with .Net Core 3 + $(MSBuildProjectName) + MIT + + + $(NoWarn);NU5048;NU5105 + + true + + true + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + true + false + false + true + true + true + + \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 0000000..0a14c6a --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,22 @@ + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + \ No newline at end of file diff --git a/src/OpenMessage.AWS.SNS/Configuration/ISnsDispatcherBuilder.cs b/src/OpenMessage.AWS.SNS/Configuration/ISnsDispatcherBuilder.cs new file mode 100644 index 0000000..536b4c9 --- /dev/null +++ b/src/OpenMessage.AWS.SNS/Configuration/ISnsDispatcherBuilder.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Hosting; +using OpenMessage.Builders; +using System; + +namespace OpenMessage.AWS.SNS.Configuration +{ + /// + /// SNS Dispatcher Builder + /// + public interface ISnsDispatcherBuilder : IBuilder + { + /// + /// Configure the dispatcher with the specified options + /// + /// The configuration action + /// The SQS dispatcher builder + ISnsDispatcherBuilder FromConfiguration(Action> configuration); + + /// + /// Configure the dispatcher with the specified options + /// + /// The configuration action + /// The SQS dispatcher builder + ISnsDispatcherBuilder FromConfiguration(Action> configuration); + + /// + /// Configure the dispatcher with the specified options + /// + /// The configuration section to use + /// The SNS dispatcher builder + ISnsDispatcherBuilder FromConfiguration(string configurationSection); + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SNS/Configuration/SNSOptions.cs b/src/OpenMessage.AWS.SNS/Configuration/SNSOptions.cs new file mode 100644 index 0000000..4f3c058 --- /dev/null +++ b/src/OpenMessage.AWS.SNS/Configuration/SNSOptions.cs @@ -0,0 +1,31 @@ +using Amazon.SimpleNotificationService; +using System; + +namespace OpenMessage.AWS.SNS.Configuration +{ + /// + /// Configuration options for dispatchers + /// + public class SNSOptions + { + /// + /// Allow the configuration of the raw AWS SNS Dispatcher Config during initialization of the dispatcher. + /// + public Action? AwsDispatcherConfiguration { get; set; } + + /// + /// The region endpoint to use + /// + public string? RegionEndpoint { get; set; } + + /// + /// The url to use for authentication + /// + public string? ServiceURL { get; set; } + + /// + /// The topic ARN to send to + /// + public string? TopicArn { get; set; } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SNS/Configuration/SnsDispatcherBuilder.cs b/src/OpenMessage.AWS.SNS/Configuration/SnsDispatcherBuilder.cs new file mode 100644 index 0000000..02f6c84 --- /dev/null +++ b/src/OpenMessage.AWS.SNS/Configuration/SnsDispatcherBuilder.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenMessage.Builders; +using System; + +namespace OpenMessage.AWS.SNS.Configuration +{ + internal sealed class SnsDispatcherBuilder : Builder, ISnsDispatcherBuilder + { + private Action>? _configuration; + + public SnsDispatcherBuilder(IMessagingBuilder hostBuilder) + : base(hostBuilder) { } + + public override void Build() + { + if (_configuration is {}) + ConfigureOptions(_configuration, true); + HostBuilder.Services.AddSingleton, SnsDispatcher>(); + } + + public ISnsDispatcherBuilder FromConfiguration(Action> configuration) + { + return FromConfiguration((context, options) => configuration(options)); + } + + public ISnsDispatcherBuilder FromConfiguration(Action> configuration) + { + _configuration = configuration; + + return this; + } + + public ISnsDispatcherBuilder FromConfiguration(string configurationSection) + { + _configuration = (context, options) => context.Configuration.Bind(configurationSection, options); + + return this; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SNS/OpenMessage.AWS.SNS.csproj b/src/OpenMessage.AWS.SNS/OpenMessage.AWS.SNS.csproj new file mode 100644 index 0000000..04691ac --- /dev/null +++ b/src/OpenMessage.AWS.SNS/OpenMessage.AWS.SNS.csproj @@ -0,0 +1,13 @@ + + + + $(ProjectTargetFrameworks) + SNS dispatcher implementation for OpenMessage + + + + + + + + diff --git a/src/OpenMessage.AWS.SNS/SnsDispatcher.cs b/src/OpenMessage.AWS.SNS/SnsDispatcher.cs new file mode 100644 index 0000000..ab56d1c --- /dev/null +++ b/src/OpenMessage.AWS.SNS/SnsDispatcher.cs @@ -0,0 +1,165 @@ +using Amazon; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Microsoft.Extensions.Options; +using OpenMessage.AWS.SNS.Configuration; +using OpenMessage.Serialization; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenMessage.AWS.SNS +{ + internal sealed class SnsDispatcher : DispatcherBase + { + private static readonly string AttributeType = "String"; + private readonly AmazonSimpleNotificationServiceClient _client; + private readonly MessageAttributeValue _contentType; + private readonly ISerializer _serializer; + private readonly string _topicArn; + private readonly MessageAttributeValue _valueTypeName; + + public SnsDispatcher(IOptions> options, ISerializer serializer, ILogger> logger) + : base(logger) + { + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + var config = options?.Value ?? throw new ArgumentNullException(nameof(options)); + + var snsConfig = new AmazonSimpleNotificationServiceConfig + { + ServiceURL = config.ServiceURL + }; + + if (!string.IsNullOrEmpty(config.RegionEndpoint)) + snsConfig.RegionEndpoint = RegionEndpoint.GetBySystemName(config.RegionEndpoint); + + config.AwsDispatcherConfiguration?.Invoke(snsConfig); + _client = new AmazonSimpleNotificationServiceClient(snsConfig); + + _contentType = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = _serializer.ContentType + }; + + _valueTypeName = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = typeof(T).AssemblyQualifiedName + }; + _topicArn = config.TopicArn ?? throw new Exception("No topic arn set for type: " + (TypeCache.FriendlyName ?? string.Empty)); + } + + public override async Task DispatchAsync(Message message, CancellationToken cancellationToken) + { + LogDispatch(message); + + if (message.Value is null) + Throw.Exception("Message value cannot be null"); + + var msg = _serializer.AsString(message.Value); + if (string.IsNullOrWhiteSpace(msg)) + Throw.Exception("Message could not be serialized"); + + var request = new PublishRequest + { + MessageAttributes = GetMessageProperties(message), + Message = msg, + TopicArn = _topicArn + }; + +#if NETCOREAPP3_1 + var stopwatch = OpenMessageEventSource.Instance.ProcessMessageDispatchStart(); +#endif + + try + { + var response = await _client.PublishAsync(request, cancellationToken); + + if (response.HttpStatusCode != HttpStatusCode.OK) + ThrowExceptionFromHttpResponse(response.HttpStatusCode); + } + catch (AmazonSimpleNotificationServiceException e) when (e.ErrorCode == "NotFound") + { + ThrowExceptionFromHttpResponse(e.StatusCode, e); + } + finally + { +#if NETCOREAPP3_1 + if (stopwatch.HasValue) + OpenMessageEventSource.Instance.ProcessMessageDispatchStop(stopwatch.Value); +#endif + } + } + + private Dictionary GetMessageProperties(Message message) + { + var result = new Dictionary + { + [KnownProperties.ContentType] = _contentType, + [KnownProperties.ValueTypeName] = _valueTypeName + }; + + if (Activity.Current is {}) + result[KnownProperties.ActivityId] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = Activity.Current.Id + }; + + switch (message) + { + case ISupportProperties p: + { + foreach (var prop in p.Properties) + result[prop.Key] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = prop.Value + }; + + break; + } + case ISupportProperties p2: + { + foreach (var prop in p2.Properties) + result[prop.Key] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = Encoding.UTF8.GetString(prop.Value) + }; + + break; + } + case ISupportProperties p3: + { + foreach (var prop in p3.Properties) + result[Encoding.UTF8.GetString(prop.Key)] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = Encoding.UTF8.GetString(prop.Value) + }; + + break; + } + } + + return result; + } + + private void ThrowExceptionFromHttpResponse(HttpStatusCode statusCode, Exception? innerException = null) + { + var msg = $"Failed to send the message to SNS. Type: '{TypeCache.FriendlyName}' Topic ARN: '{_topicArn ?? ""}' Status Code: '{statusCode}'."; + + if (innerException is null) + throw new Exception(msg); + + throw new Exception(msg, innerException); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SNS/SnsServiceExtensions.cs b/src/OpenMessage.AWS.SNS/SnsServiceExtensions.cs new file mode 100644 index 0000000..0bf36ee --- /dev/null +++ b/src/OpenMessage.AWS.SNS/SnsServiceExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenMessage.AWS.SNS.Configuration; + +namespace OpenMessage.AWS.SNS +{ + /// + /// SNS Extensions + /// + public static class SnsServiceExtensions + { + /// + /// Returns an SNS dispatcher builder + /// + /// The host the dispatcher belongs to + /// The type of message to dispatch + /// An SNS dispatcher builder + public static ISnsDispatcherBuilder ConfigureSnsDispatcher(this IMessagingBuilder messagingBuilder) => new SnsDispatcherBuilder(messagingBuilder); + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/Configuration/ISqsConsumerBuilder.cs b/src/OpenMessage.AWS.SQS/Configuration/ISqsConsumerBuilder.cs new file mode 100644 index 0000000..da8de1c --- /dev/null +++ b/src/OpenMessage.AWS.SQS/Configuration/ISqsConsumerBuilder.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Hosting; +using OpenMessage.Builders; +using System; + +namespace OpenMessage.AWS.SQS.Configuration +{ + /// + /// The builder for an SQS consumer + /// + /// The type to be consumed + public interface ISqsConsumerBuilder : IBuilder + { + /// + /// Configure the consumer with the specified options + /// + /// The configuration action + /// The SQS consumer builder + ISqsConsumerBuilder FromConfiguration(Action configuration); + + /// + /// Configure the consumer with the specified options + /// + /// The configuration action + /// The SQS consumer builder + ISqsConsumerBuilder FromConfiguration(Action configuration); + + /// + /// Configure the dispatcher with the specified options + /// + /// The configuration section to use + /// The SQS dispatcher builder + ISqsConsumerBuilder FromConfiguration(string configurationSection); + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/Configuration/ISqsDispatcherBuilder.cs b/src/OpenMessage.AWS.SQS/Configuration/ISqsDispatcherBuilder.cs new file mode 100644 index 0000000..f71104c --- /dev/null +++ b/src/OpenMessage.AWS.SQS/Configuration/ISqsDispatcherBuilder.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Hosting; +using OpenMessage.Builders; +using System; + +namespace OpenMessage.AWS.SQS.Configuration +{ + /// + /// The builder for an SQS consumer + /// + /// The type to be dispatched + public interface ISqsDispatcherBuilder : IBuilder + { + /// + /// Configure the dispatcher with the specified options + /// + /// The configuration action + /// The SQS dispatcher builder + ISqsDispatcherBuilder FromConfiguration(Action> configuration); + + /// + /// Configure the dispatcher with the specified options + /// + /// The configuration action + /// The SQS dispatcher builder + ISqsDispatcherBuilder FromConfiguration(Action> configuration); + + /// + /// Configure the dispatcher with the specified options + /// + /// The configuration section to use + /// The SQS dispatcher builder + ISqsDispatcherBuilder FromConfiguration(string configurationSection); + + /// + /// Enables the batched dispatcher mechanism + /// + /// Whether or not to enable the batched dispatcher + /// The SQS dispatcher builder + ISqsDispatcherBuilder WithBatchedDispatcher(bool enabled = true); + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/Configuration/SQSConsumerOptions.cs b/src/OpenMessage.AWS.SQS/Configuration/SQSConsumerOptions.cs new file mode 100644 index 0000000..eab969b --- /dev/null +++ b/src/OpenMessage.AWS.SQS/Configuration/SQSConsumerOptions.cs @@ -0,0 +1,67 @@ +using Amazon.SQS; +using System; +using System.Collections.Generic; + +namespace OpenMessage.AWS.SQS.Configuration +{ + /// + /// Options for an SQS consumer + /// + public class SQSConsumerOptions + { + /// + /// Allow the configuration of the raw AWS SQS Client Config during initialization of the consumer. + /// + public Action? AwsConsumerConfiguration { get; set; } + + /// + /// The maximum number of messages to consume + /// + public int MaxNumberOfMessages { get; set; } = 10; + + /// + /// The url of the queue to consume from + /// + public string? QueueUrl { get; set; } + + /// + /// The region endpoint to use + /// + public string? RegionEndpoint { get; set; } + + /// + /// The service url to use + /// + public string? ServiceURL { get; set; } + + /// + /// How long to leave the message on the queue before it becomes consumable again + /// + public int? VisibilityTimeout { get; set; } + + /// + /// The waiting period before return the messages, in seconds + /// + public int WaitTimeSeconds { get; set; } + + /// + /// The minimum number of consumers to manage + /// + public byte MinimumConsumerCount { get; set; } = 1; + + /// + /// The maximum number of consumers to manage + /// + public byte MaximumConsumerCount { get; set; } = 10; + + /// + /// The SQS specific messages attributes to retrieve, eg: ApproximateFirstReceiveTimestamp, ApproximateReceiveCount, AWSTraceHeader, SenderId, SentTimestamp, MessageDeduplicationId, MessageGroupId, SequenceNumber + /// + public List SQSMessageAttributes { get; set; } = new List(0); + + /// + /// The custom message properties, eg: ContentType, to load from the message + /// + public List CustomMessageAttributes { get; set; } = new List { "All" }; + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/Configuration/SQSDispatcherOptions.cs b/src/OpenMessage.AWS.SQS/Configuration/SQSDispatcherOptions.cs new file mode 100644 index 0000000..90ebe08 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/Configuration/SQSDispatcherOptions.cs @@ -0,0 +1,31 @@ +using Amazon.SQS; +using System; + +namespace OpenMessage.AWS.SQS.Configuration +{ + /// + /// Configuration options for dispatchers + /// + public class SQSDispatcherOptions + { + /// + /// Allow the configuration of the raw AWS SQS Dispatcher Config during initialization of the dispatcher. + /// + public Action? AwsDispatcherConfiguration { get; set; } + + /// + /// The queue url to dispatch to + /// + public string? QueueUrl { get; set; } + + /// + /// The region endpoint to use + /// + public string? RegionEndpoint { get; set; } + + /// + /// The url to use for authentication + /// + public string? ServiceURL { get; set; } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/Configuration/SqsConsumerBuilder.cs b/src/OpenMessage.AWS.SQS/Configuration/SqsConsumerBuilder.cs new file mode 100644 index 0000000..cb9fd1d --- /dev/null +++ b/src/OpenMessage.AWS.SQS/Configuration/SqsConsumerBuilder.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using OpenMessage.Builders; +using System; + +namespace OpenMessage.AWS.SQS.Configuration +{ + internal sealed class SqsConsumerBuilder : Builder, ISqsConsumerBuilder + { + private Action? _configuration; + + public SqsConsumerBuilder(IMessagingBuilder hostBuilder) + : base(hostBuilder) { } + + public override void Build() + { + HostBuilder.Services.TryAddConsumerService(); + HostBuilder.TryConfigureDefaultPipeline(); + + if (_configuration is {}) + ConfigureOptions(_configuration); + + HostBuilder.Services.TryAddTransient, SqsConsumer>(); + HostBuilder.Services.TryAddTransient, QueueMonitor>(); + HostBuilder.Services.AddConsumerService>(ConsumerId); + } + + public ISqsConsumerBuilder FromConfiguration(Action configuration) + { + return FromConfiguration((context, options) => configuration(options)); + } + + public ISqsConsumerBuilder FromConfiguration(Action configuration) + { + _configuration = configuration; + + return this; + } + + public ISqsConsumerBuilder FromConfiguration(string configurationSection) + { + _configuration = (context, options) => context.Configuration.Bind(configurationSection, options); + + return this; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/Configuration/SqsDispatcherBuilder.cs b/src/OpenMessage.AWS.SQS/Configuration/SqsDispatcherBuilder.cs new file mode 100644 index 0000000..72a2b00 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/Configuration/SqsDispatcherBuilder.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenMessage.Builders; +using System; +using System.Threading.Channels; + +namespace OpenMessage.AWS.SQS.Configuration +{ + internal sealed class SqsDispatcherBuilder : Builder, ISqsDispatcherBuilder + { + private Action>? _configuration; + private bool _batchedDispatcher = true; + + public SqsDispatcherBuilder(IMessagingBuilder hostBuilder) + : base(hostBuilder) { } + + public override void Build() + { + if (_configuration is {}) + ConfigureOptions(_configuration, true); + + if (_batchedDispatcher) + { + HostBuilder.Services.AddHostedService(); + HostBuilder.Services.TryAddChannel(sp => Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false })); + HostBuilder.Services.AddSingleton, SqsBatchedDispatcher>(); + } + else + { + HostBuilder.Services.AddSingleton, SqsDispatcher>(); + } + } + + public ISqsDispatcherBuilder FromConfiguration(Action> configuration) + { + return FromConfiguration((context, options) => configuration(options)); + } + + public ISqsDispatcherBuilder FromConfiguration(Action> configuration) + { + _configuration = configuration; + + return this; + } + + public ISqsDispatcherBuilder FromConfiguration(string configurationSection) + { + _configuration = (context, options) => context.Configuration.Bind(configurationSection, options); + + return this; + } + + public ISqsDispatcherBuilder WithBatchedDispatcher(bool enabled = true) + { + _batchedDispatcher = enabled; + return this; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/IQueueMonitor.cs b/src/OpenMessage.AWS.SQS/IQueueMonitor.cs new file mode 100644 index 0000000..0ba3787 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/IQueueMonitor.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.AWS.SQS +{ + internal interface IQueueMonitor + { + Task GetQueueCountAsync(string consumerId, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/ISqsConsumer.cs b/src/OpenMessage.AWS.SQS/ISqsConsumer.cs new file mode 100644 index 0000000..1b90c79 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/ISqsConsumer.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.AWS.SQS +{ + internal interface ISqsConsumer + { + Task>> ConsumeAsync(CancellationToken cancellationToken); + void Initialize(string consumerId, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/OpenMessage.AWS.SQS.csproj b/src/OpenMessage.AWS.SQS/OpenMessage.AWS.SQS.csproj new file mode 100644 index 0000000..74456b3 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/OpenMessage.AWS.SQS.csproj @@ -0,0 +1,13 @@ + + + + $(ProjectTargetFrameworks) + SQS consumer and dispatcher implementation for OpenMessage + + + + + + + + diff --git a/src/OpenMessage.AWS.SQS/QueueMonitor.cs b/src/OpenMessage.AWS.SQS/QueueMonitor.cs new file mode 100644 index 0000000..1d47dbe --- /dev/null +++ b/src/OpenMessage.AWS.SQS/QueueMonitor.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon; +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Options; +using OpenMessage.AWS.SQS.Configuration; + +namespace OpenMessage.AWS.SQS +{ + internal sealed class QueueMonitor : IQueueMonitor + { + private readonly IOptionsMonitor _sqsOptions; + private readonly List QueueAttributes = new List + { + "ApproximateNumberOfMessages", + "ApproximateNumberOfMessagesDelayed", + "ApproximateNumberOfMessagesNotVisible" + }; + private readonly ConcurrentDictionary _clients = new ConcurrentDictionary(); + + public QueueMonitor(IOptionsMonitor sqsOptions) + { + _sqsOptions = sqsOptions ?? throw new ArgumentNullException(nameof(sqsOptions)); + } + + public async Task GetQueueCountAsync(string consumerId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(consumerId)) + throw new ArgumentNullException(nameof(consumerId)); + + var options = _sqsOptions.Get(consumerId); + var client = _clients.GetOrAdd(consumerId, id => + { + var config = new AmazonSQSConfig + { + ServiceURL = options.ServiceURL + }; + + if (!string.IsNullOrEmpty(options.RegionEndpoint)) + config.RegionEndpoint = RegionEndpoint.GetBySystemName(options.RegionEndpoint); + + options.AwsConsumerConfiguration?.Invoke(config); + return new AmazonSQSClient(config); + }); + + var attributes = await client.GetQueueAttributesAsync(new GetQueueAttributesRequest + { + QueueUrl = options.QueueUrl, + AttributeNames = QueueAttributes + }, cancellationToken); + + return attributes.ApproximateNumberOfMessages; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/SendSqsMessageCommand.cs b/src/OpenMessage.AWS.SQS/SendSqsMessageCommand.cs new file mode 100644 index 0000000..5aae698 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/SendSqsMessageCommand.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.SQS.Model; + +namespace OpenMessage.AWS.SQS +{ + internal class SendSqsMessageCommand + { + private string? _lookupKey; +#if NETCOREAPP3_1 + private OpenMessageEventSource.ValueStopwatch? _stopwatch; +#endif + private TaskCompletionSource _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + internal string? QueueUrl { get; set; } + internal SendMessageBatchRequestEntry? Message { get; set; } + internal string? ServiceUrl { get; set; } + internal string? RegionEndpoint { get; set; } + internal string LookupKey => _lookupKey ??= $"{QueueUrl ?? string.Empty}|{ServiceUrl ?? string.Empty}|{RegionEndpoint ?? string.Empty}"; + + public SendSqsMessageCommand() + { +#if NETCOREAPP3_1 + _stopwatch = OpenMessageEventSource.Instance.ProcessMessageDispatchStart(); +#endif + } + + internal void Complete() + { + _taskCompletionSource.TrySetResult(true); + CompleteCore(); + } + + internal void Cancel(CancellationToken ct) + { + _taskCompletionSource.TrySetCanceled(ct); + CompleteCore(); + } + + internal void Exception(Exception ex) + { + _taskCompletionSource.TrySetException(ex); + CompleteCore(); + } + + internal Task WaitForCompletion() => _taskCompletionSource.Task; + + private void CompleteCore() + { +#if NETCOREAPP3_1 + if (_stopwatch.HasValue) + OpenMessageEventSource.Instance.ProcessMessageDispatchStop(_stopwatch.Value); +#endif + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/SqsBatchedDispatcher.cs b/src/OpenMessage.AWS.SQS/SqsBatchedDispatcher.cs new file mode 100644 index 0000000..3364a99 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/SqsBatchedDispatcher.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Amazon.SQS.Model; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenMessage.AWS.SQS.Configuration; +using OpenMessage.Serialization; + +namespace OpenMessage.AWS.SQS +{ + internal sealed class SqsBatchedDispatcher : DispatcherBase + { + //15min = 900sec is the maximum delay supported by sqs + private const int MaximumSqsDelaySeconds = 900; + private static readonly string AttributeType = "String"; + private readonly MessageAttributeValue _contentType; + private readonly IOptionsMonitor> _options; + private readonly ISerializer _serializer; + private readonly ChannelWriter _messageWriter; + + public SqsBatchedDispatcher(IOptionsMonitor> options, ISerializer serializer, ILogger> logger, ChannelWriter messageWriter) + : base(logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _messageWriter = messageWriter ?? throw new ArgumentNullException(nameof(messageWriter)); + + _contentType = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = _serializer.ContentType + }; + } + + public override async Task DispatchAsync(Message message, CancellationToken cancellationToken) + { + if (message.Value is null) + Throw.Exception("Message value cannot be null"); + + var json = _serializer.AsString(message.Value); + if (string.IsNullOrWhiteSpace(json)) + Throw.Exception("Message could not be serialized"); + + var options = _options.CurrentValue; + if (options is null) + Throw.Exception("Options cannot be null"); + + LogDispatch(message); + + var request = new SendMessageBatchRequestEntry + { + Id = Guid.NewGuid().ToString("N"), + MessageAttributes = GetMessageProperties(message), + DelaySeconds = DelaySeconds(message), + MessageBody = json + }; + + var msg = new SendSqsMessageCommand + { + Message = request, + QueueUrl = options.QueueUrl, + ServiceUrl = options.ServiceURL, + RegionEndpoint = options.RegionEndpoint + }; + + await _messageWriter.WriteAsync(msg, cancellationToken); + + var taskCancellation = cancellationToken.Register(() => msg.Cancel(cancellationToken)); + try + { + await msg.WaitForCompletion(); + } + finally + { + taskCancellation.Dispose(); + } + } + + private static int DelaySeconds(Message message) + { + if (message is ISupportSendDelay delay && delay.SendDelay > TimeSpan.Zero) + { + return Math.Min(MaximumSqsDelaySeconds, (int) delay.SendDelay.TotalSeconds); + } + + return 0; + } + + private Dictionary GetMessageProperties(Message message) + { + var result = new Dictionary + { + [KnownProperties.ContentType] = _contentType + }; + + if (!(message.Value is null)) + result[KnownProperties.ValueTypeName] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = message.Value.GetType().AssemblyQualifiedName + }; + + if (Activity.Current is {}) + result[KnownProperties.ActivityId] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = Activity.Current.Id + }; + + switch (message) + { + case ISupportProperties p: + { + foreach (var prop in p.Properties) + result[prop.Key] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = prop.Value + }; + + break; + } + case ISupportProperties p2: + { + foreach (var prop in p2.Properties) + result[prop.Key] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = Encoding.UTF8.GetString(prop.Value) + }; + + break; + } + case ISupportProperties p3: + { + foreach (var prop in p3.Properties) + result[Encoding.UTF8.GetString(prop.Key)] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = Encoding.UTF8.GetString(prop.Value) + }; + + break; + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/SqsConsumer.cs b/src/OpenMessage.AWS.SQS/SqsConsumer.cs new file mode 100644 index 0000000..4185045 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/SqsConsumer.cs @@ -0,0 +1,123 @@ +using Amazon; +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Options; +using OpenMessage.AWS.SQS.Configuration; +using OpenMessage.Serialization; +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenMessage.AWS.SQS +{ + internal sealed class SqsConsumer : ISqsConsumer + { + private static readonly string MisconfiguredConsumerMessage = "Consumer has not been initialized. Please call Initialize with the configured consumer id."; + private readonly IDeserializationProvider _deserializationProvider; + private readonly ILogger> _logger; + private readonly List> _emptyList = new List>(0); + + private readonly IOptionsMonitor _options; + private Func, Task>? _acknowledgementAction; + private IAmazonSQS? _client; + private SQSConsumerOptions? _currentConsumerOptions; + + public SqsConsumer(IOptionsMonitor options, IDeserializationProvider deserializationProvider, ILogger> logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _deserializationProvider = deserializationProvider ?? throw new ArgumentNullException(nameof(deserializationProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task>> ConsumeAsync(CancellationToken cancellationToken) + { + if (_currentConsumerOptions is null || _client is null) + Throw.Exception(MisconfiguredConsumerMessage); + + var request = new ReceiveMessageRequest + { + QueueUrl = _currentConsumerOptions.QueueUrl, + MaxNumberOfMessages = _currentConsumerOptions.MaxNumberOfMessages, + WaitTimeSeconds = _currentConsumerOptions.WaitTimeSeconds, + AttributeNames = _currentConsumerOptions.SQSMessageAttributes, + MessageAttributeNames = _currentConsumerOptions.CustomMessageAttributes + }; + + if (_currentConsumerOptions.VisibilityTimeout.HasValue) + request.VisibilityTimeout = _currentConsumerOptions.VisibilityTimeout.Value; + + var response = await _client.ReceiveMessageAsync(request, cancellationToken); + + if (response is null || response.HttpStatusCode != HttpStatusCode.OK || response.Messages is null || response.Messages.Count == 0) + return _emptyList; + + var result = new List>(response.Messages.Count); + + foreach (var message in response.Messages) + { + var properties = new Dictionary(message.Attributes.Count + message.MessageAttributes.Count, StringComparer.Ordinal); + + foreach (var attribute in message.Attributes) + properties[attribute.Key] = attribute.Value; + + foreach (var msgAttribute in message.MessageAttributes) + properties[msgAttribute.Key] = msgAttribute.Value.StringValue; + + var contentType = ContentTypes.Json; + + if (message.MessageAttributes.TryGetValue(KnownProperties.ContentType, out var cta)) + contentType = cta.StringValue; + + var messageType = default(string); + if (message.MessageAttributes.TryGetValue(KnownProperties.ValueTypeName, out var vtn)) + messageType = vtn.StringValue; + + if (_acknowledgementAction is null) + Throw.Exception("Acknowledgement action cannot be null for SQS message"); + + result.Add(new SqsMessage(_acknowledgementAction) + { + Id = message.MessageId, + Properties = properties, + ReceiptHandle = message.ReceiptHandle, + QueueUrl = _currentConsumerOptions.QueueUrl, + Value = _deserializationProvider.From(message.Body, contentType, messageType ?? string.Empty) + }); + } + + return result; + } + + public void Initialize(string consumerId, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + try + { + _currentConsumerOptions = _options.Get(consumerId); + + var config = new AmazonSQSConfig + { + ServiceURL = _currentConsumerOptions.ServiceURL + }; + + if (!string.IsNullOrEmpty(_currentConsumerOptions.RegionEndpoint)) + config.RegionEndpoint = RegionEndpoint.GetBySystemName(_currentConsumerOptions.RegionEndpoint); + + _currentConsumerOptions.AwsConsumerConfiguration?.Invoke(config); + _client = new AmazonSQSClient(config); + _acknowledgementAction = msg => _client?.DeleteMessageAsync(msg.QueueUrl, msg.ReceiptHandle, default) ?? Task.CompletedTask; + + return; + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + if (!cancellationToken.IsCancellationRequested) + Thread.Sleep(TimeSpan.FromSeconds(2)); + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/SqsDispatcher.cs b/src/OpenMessage.AWS.SQS/SqsDispatcher.cs new file mode 100644 index 0000000..0af9f97 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/SqsDispatcher.cs @@ -0,0 +1,168 @@ +using Amazon; +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Options; +using OpenMessage.AWS.SQS.Configuration; +using OpenMessage.Serialization; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenMessage.AWS.SQS +{ + internal sealed class SqsDispatcher : DispatcherBase + { + //15min = 900sec is the maximum delay supported by sqs + private const int MaximumSqsDelaySeconds = 900; + private static readonly string AttributeType = "String"; + private readonly AmazonSQSClient _client; + private readonly MessageAttributeValue _contentType; + private readonly string _queueUrl; + private readonly ISerializer _serializer; + + public SqsDispatcher(IOptions> options, ISerializer serializer, ILogger> logger) + : base(logger) + { + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + var config = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _queueUrl = config.QueueUrl ?? throw new Exception("No queue url set for type: " + (TypeCache.FriendlyName ?? string.Empty)); + + var sqsConfig = new AmazonSQSConfig + { + ServiceURL = config.ServiceURL + }; + + if (!string.IsNullOrEmpty(config.RegionEndpoint)) + sqsConfig.RegionEndpoint = RegionEndpoint.GetBySystemName(config.RegionEndpoint); + + config.AwsDispatcherConfiguration?.Invoke(sqsConfig); + + _client = new AmazonSQSClient(sqsConfig); + + _contentType = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = _serializer.ContentType + }; + } + + public override async Task DispatchAsync(Message message, CancellationToken cancellationToken) + { + LogDispatch(message); + + if (message.Value is null) + Throw.Exception("Message value cannot be null"); + + var msg = _serializer.AsString(message.Value); + if (string.IsNullOrWhiteSpace(msg)) + Throw.Exception("Message could not be serialized"); + + var request = new SendMessageRequest + { + MessageAttributes = GetMessageProperties(message), + DelaySeconds = DelaySeconds(message), + MessageBody = msg, + QueueUrl = _queueUrl + }; + +#if NETCOREAPP3_1 + var stopwatch = OpenMessageEventSource.Instance.ProcessMessageDispatchStart(); +#endif + + try + { + var response = await _client.SendMessageAsync(request, cancellationToken); + if (response.HttpStatusCode != HttpStatusCode.OK) + ThrowExceptionFromHttpResponse(response); + } + finally + { +#if NETCOREAPP3_1 + if (stopwatch.HasValue) + OpenMessageEventSource.Instance.ProcessMessageDispatchStop(stopwatch.Value); +#endif + } + } + + private static int DelaySeconds(Message message) + { + if (message is ISupportSendDelay delay && delay.SendDelay > TimeSpan.Zero) + { + return Math.Min(MaximumSqsDelaySeconds, (int) delay.SendDelay.TotalSeconds); + } + + return 0; + } + + private Dictionary GetMessageProperties(Message message) + { + var result = new Dictionary + { + [KnownProperties.ContentType] = _contentType + }; + + if (!(message.Value is null)) + result[KnownProperties.ValueTypeName] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = message.Value.GetType().AssemblyQualifiedName + }; + + if (Activity.Current is {}) + result[KnownProperties.ActivityId] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = Activity.Current.Id + }; + + switch (message) + { + case ISupportProperties p: + { + foreach (var prop in p.Properties) + result[prop.Key] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = prop.Value + }; + + break; + } + case ISupportProperties p2: + { + foreach (var prop in p2.Properties) + result[prop.Key] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = Encoding.UTF8.GetString(prop.Value) + }; + + break; + } + case ISupportProperties p3: + { + foreach (var prop in p3.Properties) + result[Encoding.UTF8.GetString(prop.Key)] = new MessageAttributeValue + { + DataType = AttributeType, + StringValue = Encoding.UTF8.GetString(prop.Value) + }; + + break; + } + } + + return result; + } + + private void ThrowExceptionFromHttpResponse(SendMessageResponse response) + { + throw new Exception($"Failed to send the message to SQS. Type: '{TypeCache.FriendlyName}' Queue Url: '{_queueUrl ?? ""}' Status Code: '{response.HttpStatusCode}'."); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/SqsDispatcherService.cs b/src/OpenMessage.AWS.SQS/SqsDispatcherService.cs new file mode 100644 index 0000000..2466875 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/SqsDispatcherService.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Amazon; +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace OpenMessage.AWS.SQS +{ + internal sealed class SqsDispatcherService : BackgroundService + { + private readonly ChannelReader _messageReader; + private readonly ILogger _logger; + private readonly Dictionary _clients = new Dictionary(StringComparer.Ordinal); + + private readonly Dictionary> _channels = new Dictionary>(StringComparer.Ordinal); + private readonly Dictionary _channelReaderTasks = new Dictionary(); + + public SqsDispatcherService(ChannelReader messageReader, ILogger logger) + { + _messageReader = messageReader; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + // Without this line we can encounter a blocking issue such as: https://github.com/dotnet/extensions/issues/2816 + await Task.Yield(); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + if (_messageReader.TryRead(out var msg)) + { + if (msg.QueueUrl is null) + { + msg.Exception(new Exception("Cannot process message without a destination queue url")); + continue; + } + + if (msg.Message is null) + { + msg.Exception(new Exception("Cannot process message without a message to send")); + continue; + } + + if (!_channels.TryGetValue(msg.LookupKey, out var channel)) + { + _channels[msg.LookupKey] = channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true + }); + _channelReaderTasks[msg.LookupKey] = Task.Run(async () => + { + var messagesToSend = new List(10); + while (!cancellationToken.IsCancellationRequested) + { + try + { + if (messagesToSend is null) + continue; + + var readMessage = channel.Reader.TryRead(out var msg); + if (readMessage) + messagesToSend.Add(msg); + + if (messagesToSend.Count == 10 || messagesToSend.Count > 0 && !readMessage) + { + var messages = Interlocked.Exchange(ref messagesToSend, new List(10)); + if (messages is {}) + _ = ProcessMessages(messages); + } + else if (!cancellationToken.IsCancellationRequested && !readMessage) + await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (messagesToSend is {}) + foreach (var msg in messagesToSend) + msg.Cancel(cancellationToken); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + if (messagesToSend is {}) + foreach (var msg in messagesToSend) + msg.Exception(ex); + } + } + }); + } + + await channel.Writer.WriteAsync(msg, cancellationToken).ConfigureAwait(false); + } + else + await _messageReader.WaitToReadAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } + } + } + + private async Task ProcessMessages(List messages) + { + if (messages.Count == 0) + return; + + var firstMessage = messages[0]; + + try + { + var entries = new List(messages.Count); + foreach(var msg in messages) + if (msg.Message is {}) + entries.Add(msg.Message); + + var request = new SendMessageBatchRequest(firstMessage.QueueUrl, entries); + if (!_clients.TryGetValue(firstMessage.LookupKey, out var client)) + { + var config = new AmazonSQSConfig + { + ServiceURL = firstMessage.ServiceUrl + }; + + if (firstMessage.RegionEndpoint != null) + config.RegionEndpoint = RegionEndpoint.GetBySystemName(firstMessage.RegionEndpoint); + + _clients[firstMessage.LookupKey] = client = new AmazonSQSClient(config); + } + + var response = await client.SendMessageBatchAsync(request); + + // TODO :: we should be able to complete certain messages here + if (response.Failed.Count > 0) + Throw.Exception("One or more messages failed to send"); + + foreach (var msg in messages) + msg.Complete(); + } + catch (Exception e) + { + foreach (var msg in messages) + msg.Exception(e); + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/SqsMessage.cs b/src/OpenMessage.AWS.SQS/SqsMessage.cs new file mode 100644 index 0000000..51e6c91 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/SqsMessage.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; + +namespace OpenMessage.AWS.SQS +{ + internal sealed class SqsMessage : Message, ISupportAcknowledgement, ISupportIdentification, ISupportProperties + { + private readonly Func, Task> _acknowledgementAction; +#if NETCOREAPP3_1 + private OpenMessageEventSource.ValueStopwatch? _stopwatch; +#endif + + public AcknowledgementState AcknowledgementState { get; private set; } + [MaybeNull, AllowNull] public string Id { get; internal set; } = default; + public IEnumerable> Properties { get; internal set; } = Enumerable.Empty>(); + internal string? ReceiptHandle { get; set; } + internal string? QueueUrl { get; set; } + + public SqsMessage(Func, Task> acknowledgementAction) + { + _acknowledgementAction = acknowledgementAction; +#if NETCOREAPP3_1 + _stopwatch = OpenMessageEventSource.Instance.ProcessMessageStart(); +#endif + } + + public async Task AcknowledgeAsync(bool positivelyAcknowledge = true, Exception? exception = null) + { + try + { + if (!positivelyAcknowledge) + { + AcknowledgementState = AcknowledgementState.NegativelyAcknowledged; + return; + } + + await _acknowledgementAction(this); + AcknowledgementState = AcknowledgementState.Acknowledged; + } + finally + { +#if NETCOREAPP3_1 + if (_stopwatch.HasValue) + { + OpenMessageEventSource.Instance.ProcessMessageStop(_stopwatch.Value); + _stopwatch = null; + } +#endif + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/SqsMessagePump.cs b/src/OpenMessage.AWS.SQS/SqsMessagePump.cs new file mode 100644 index 0000000..c94a8f9 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/SqsMessagePump.cs @@ -0,0 +1,177 @@ +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Logging; +using OpenMessage.Pipelines.Pumps; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenMessage.AWS.SQS.Configuration; + +namespace OpenMessage.AWS.SQS +{ + internal sealed class SqsMessagePump : MessagePump + { + private readonly string _consumerId; + private readonly IQueueMonitor _queueMonitor; + private readonly IOptionsMonitor _sqsOptions; + private readonly IServiceProvider _services; + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private Task? _consumerCheckTask; + private List<(Task tsk, CancellationTokenSource cts)> _consumers = new List<(Task tsk, CancellationTokenSource cts)>(); + + public SqsMessagePump(ChannelWriter> channelWriter, + ILogger> logger, + IQueueMonitor queueMonitor, + IServiceScopeFactory serviceScopeFactory, + IOptionsMonitor sqsOptions, + string consumerId) + : base(channelWriter, logger) + { + _queueMonitor = queueMonitor ?? throw new ArgumentNullException(nameof(queueMonitor)); + _sqsOptions = sqsOptions ?? throw new ArgumentNullException(nameof(sqsOptions)); + if (serviceScopeFactory == null) + throw new ArgumentNullException(nameof(serviceScopeFactory)); + _services = serviceScopeFactory.CreateScope().ServiceProvider; + _consumerId = consumerId ?? throw new ArgumentNullException(nameof(consumerId)); + } + + public override Task StartAsync(CancellationToken cancellationToken) + { + _consumerCheckTask = Task.Run(async () => + { + await Task.Delay(100); + var token = _cancellationTokenSource.Token; + while (!token.IsCancellationRequested) + { + try + { + // This is hacky POC + var count = await _queueMonitor.GetQueueCountAsync(_consumerId, token); + + lock (_consumers) + { + const int targetCountPerConsumer = 50; + var options = _sqsOptions.Get(_consumerId); + if (_consumers.Count == 0) + { + // This is the startup essentially + var newConsumerCount = Math.Min(count == 0 ? options.MinimumConsumerCount : Math.Max(count / targetCountPerConsumer, options.MinimumConsumerCount), options.MaximumConsumerCount); + for (var i = 0; i < newConsumerCount; i++) + { + InitialiseConsumer(count, cancellationToken); + } + } + else if (count >= 0) + { + var maxCapacity = _consumers.Count * targetCountPerConsumer; + if (count > (maxCapacity + targetCountPerConsumer * 3) && _consumers.Count < options.MaximumConsumerCount) + { + InitialiseConsumer(count, cancellationToken); + } + else if (count < (maxCapacity / 2) && _consumers.Count - 1 >= options.MinimumConsumerCount) + { + RemoveConsumer(); + } + } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + Logger.LogError(ex, $"Error occurred while running '{TypeCache.FriendlyName}' {nameof(SqsMessagePump)}. {ex.Message}"); + } + finally + { + if (!cancellationToken.IsCancellationRequested) + await Task.Delay(5000, cancellationToken); + } + } + }); + + return base.StartAsync(cancellationToken); + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + _cancellationTokenSource.Cancel(); + return base.StopAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + await Task.Yield(); + } + + protected override Task ConsumeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private async Task HandleMissingQueueAsync(TException exception, CancellationToken cancellationToken) + where TException : Exception + { + Logger.LogError(exception, $"Queue for type '{TypeCache.FriendlyName}' does not exist. Retrying in 15 seconds."); + await Task.Delay(TimeSpan.FromSeconds(15), cancellationToken); + } + + private void InitialiseConsumer(int queueLength, CancellationToken cancellationToken) + { + lock (_consumers) + { + var consumer = _services.GetRequiredService>(); + consumer.Initialize(_consumerId, cancellationToken); + var cts = new CancellationTokenSource(); + var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + var consumerTask = RunConsumerTask(consumer, ct.Token); + _consumers.Add((consumerTask, cts)); + Logger.LogInformation("Initialized new '{0}' consumer. Current consumer count: {1}. Queue Length: {2}", TypeCache.FriendlyName, _consumers.Count, queueLength); + } + } + + private void RemoveConsumer() + { + lock (_consumers) + { + if (_consumers.Count == 0) + return; + + var index = _consumers.Count - 1; + var tskGroup = _consumers[index]; + _consumers.RemoveAt(index); + tskGroup.cts.Cancel(false); + Logger.LogInformation("Removed '{0}' consumer. Current consumer count: {1}", TypeCache.FriendlyName, _consumers.Count); + } + } + + private async Task RunConsumerTask(ISqsConsumer consumer, CancellationToken cancellationToken) + { + var writer = ChannelWriter; + + while (!cancellationToken.IsCancellationRequested) + { + try + { + var messages = await consumer.ConsumeAsync(cancellationToken); + foreach (var message in messages) + if (!writer.TryWrite(message)) + await writer.WriteAsync(message, cancellationToken); + } + catch (QueueDoesNotExistException queueException) + { + await HandleMissingQueueAsync(queueException, cancellationToken); + } + catch (AmazonSQSException sqsException) when (sqsException.ErrorCode == "AWS.SimpleQueueService.NonExistentQueue") + { + await HandleMissingQueueAsync(sqsException, cancellationToken); + } + catch (OperationCanceledException) { } + catch (Exception e) + { + Logger.LogError(e, e.Message); + throw; + } + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.AWS.SQS/SqsServiceExtensions.cs b/src/OpenMessage.AWS.SQS/SqsServiceExtensions.cs new file mode 100644 index 0000000..6742154 --- /dev/null +++ b/src/OpenMessage.AWS.SQS/SqsServiceExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenMessage.AWS.SQS.Configuration; + +namespace OpenMessage.AWS.SQS +{ + /// + /// SQS Extensions + /// + public static class SqsServiceExtensions + { + /// + /// Returns an SQS consumer builder + /// + /// The host the consumer belongs to + /// The type of message to consume + /// An SQS consumer builder + public static ISqsConsumerBuilder ConfigureSqsConsumer(this IMessagingBuilder messagingBuilder) => new SqsConsumerBuilder(messagingBuilder); + + /// + /// Returns an SQS dispatcher builder + /// + /// The host the dispatcher belongs to + /// The type of message to dispatch + /// An SQS dispatcher builder + public static ISqsDispatcherBuilder ConfigureSqsDispatcher(this IMessagingBuilder messagingBuilder) => new SqsDispatcherBuilder(messagingBuilder); + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/Configuration/IKafkaConsumerBuilder.cs b/src/OpenMessage.Apache.Kafka/Configuration/IKafkaConsumerBuilder.cs new file mode 100644 index 0000000..2784d41 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/Configuration/IKafkaConsumerBuilder.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Hosting; +using OpenMessage.Builders; +using System; + +namespace OpenMessage.Apache.Kafka.Configuration +{ + /// + /// + public interface IKafkaConsumerBuilder : IBuilder + { + /// + /// Configures the consumer with the specified options + /// + /// The configuration to use + /// The modified consumer builder + IKafkaConsumerBuilder FromConfiguration(Action configuration); + + /// + /// Configures the consumer with the specified options + /// + /// The configuration to use + /// The modified consumer builder + IKafkaConsumerBuilder FromConfiguration(Action configuration); + + /// + /// Configures the consumer to consume from the specified topic + /// + /// The name of the topic to consume from + /// The modified consumer builder + IKafkaConsumerBuilder FromTopic(string topicName); + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/Configuration/KafkaConsumerBuilder.cs b/src/OpenMessage.Apache.Kafka/Configuration/KafkaConsumerBuilder.cs new file mode 100644 index 0000000..d8e4c65 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/Configuration/KafkaConsumerBuilder.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using OpenMessage.Apache.Kafka.HostedServices; +using OpenMessage.Builders; +using System; + +namespace OpenMessage.Apache.Kafka.Configuration +{ + internal sealed class KafkaConsumerBuilder : Builder, IKafkaConsumerBuilder + { + private Action? _options; + + private string? _topicName = TypeCache.FriendlyName?.ToLowerInvariant() + .Replace("<", "_") + .Replace(">", "_"); + + public KafkaConsumerBuilder(IMessagingBuilder hostBuilder) + : base(hostBuilder) { } + + public override void Build() + { + var appName = HostBuilder.Context.HostingEnvironment.ApplicationName; + HostBuilder.Services.AddTransient, KafkaConsumer>(); + HostBuilder.Services.AddConsumerService>(ConsumerId); + + HostBuilder.Services.TryAddConsumerService() + .TryAddSingleton, KafkaOptionsPostConfigurationProvider>(); + HostBuilder.TryConfigureDefaultPipeline(); + + ConfigureOptions((cntx, o) => + { + o.TopicName = _topicName; + + _options?.Invoke(cntx, o); + }); + + HostBuilder.Services.PostConfigure(ConsumerId, options => + { + if (!options.KafkaConfiguration.ContainsKey("group.id")) + options.KafkaConfiguration["group.id"] = appName; + }); + } + + public IKafkaConsumerBuilder FromConfiguration(Action configuration) + { + return FromConfiguration((context, options) => configuration(options)); + } + + public IKafkaConsumerBuilder FromConfiguration(Action configuration) + { + _options = configuration; + + return this; + } + + public IKafkaConsumerBuilder FromTopic(string topicName) + { + if (!string.IsNullOrWhiteSpace(topicName)) + _topicName = topicName; + + return this; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptions.cs b/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptions.cs new file mode 100644 index 0000000..d4ccaf2 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptions.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace OpenMessage.Apache.Kafka.Configuration +{ + /// + /// The basic options for a Kafka consumer/dispatcher + /// + public class KafkaOptions + { + /// + /// The Kafka specific configuration to use + /// + public IDictionary KafkaConfiguration { get; set; } = new Dictionary(); + + /// + /// The name of the topic to consume from/dispatch to + /// + public string? TopicName { get; set; } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptionsPostConfigurationProvider.cs b/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptionsPostConfigurationProvider.cs new file mode 100644 index 0000000..662c21c --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptionsPostConfigurationProvider.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using System.Collections.Generic; + +namespace OpenMessage.Apache.Kafka.Configuration +{ + internal class KafkaOptionsPostConfigurationProvider : IPostConfigureOptions + { + private static readonly IReadOnlyDictionary Defaults = new Dictionary + { + {"auto.commit.interval.ms", "1000"}, + {"auto.offset.reset", "earliest"}, + {"bootstrap.servers", "localhost:9092"}, + {"compression.codec", "LZ4"}, + {"enable.auto.commit", "true"}, + {"queue.buffering.max.ms", "5"} + }; + + public void PostConfigure(string name, KafkaOptions options) + { + // Apply the defaults where there are none + foreach (var setting in Defaults) + if (!options.KafkaConfiguration.ContainsKey(setting.Key)) + options.KafkaConfiguration[setting.Key] = setting.Value; + + if (options.KafkaConfiguration.TryGetValue("enable.auto.commit", out var str) && bool.TryParse(str, out var autoCommitEnabled) && autoCommitEnabled) + // Disables automatically storing of the offset of last message provided to application + options.KafkaConfiguration["enable.auto.offset.store"] = "false"; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptionsPostConfigurationProvider{T}.cs b/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptionsPostConfigurationProvider{T}.cs new file mode 100644 index 0000000..12297b4 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptionsPostConfigurationProvider{T}.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Options; + +namespace OpenMessage.Apache.Kafka.Configuration +{ + internal sealed class KafkaOptionsPostConfigurationProvider : KafkaOptionsPostConfigurationProvider, IPostConfigureOptions> + { + public void PostConfigure(string name, KafkaOptions options) + { + base.PostConfigure(name, options); + + if (string.IsNullOrWhiteSpace(options.TopicName)) + options.TopicName = TypeCache.FriendlyName?.ToLowerInvariant() + .Replace("<", "_") + .Replace(">", "_"); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptions{T}.cs b/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptions{T}.cs new file mode 100644 index 0000000..de394c2 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/Configuration/KafkaOptions{T}.cs @@ -0,0 +1,7 @@ +namespace OpenMessage.Apache.Kafka.Configuration +{ + /// + /// The basic options for a Kafka dispatcher + /// + public class KafkaOptions : KafkaOptions { } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/IKafkaConsumer.cs b/src/OpenMessage.Apache.Kafka/IKafkaConsumer.cs new file mode 100644 index 0000000..3d9ff0a --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/IKafkaConsumer.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Apache.Kafka +{ + internal interface IKafkaConsumer + { + Task?> ConsumeAsync(CancellationToken cancellationToken); + void Start(string consumerId); + + void Stop(); + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/KafkaClient.cs b/src/OpenMessage.Apache.Kafka/KafkaClient.cs new file mode 100644 index 0000000..9bfddc7 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/KafkaClient.cs @@ -0,0 +1,67 @@ +using Confluent.Kafka; +using Microsoft.Extensions.Logging; +using System; + +namespace OpenMessage.Apache.Kafka +{ + internal abstract class KafkaClient + { + protected ILogger Logger { get; } + + protected KafkaClient(ILogger logger) => Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + protected virtual void Kafka_OnError(IClient client, Error error) + { + if (error is null) + return; + + OnLog(error.IsFatal ? SyslogLevel.Alert : SyslogLevel.Error, $"{error.Code} - {error.Reason} (Local: {error.IsLocalError} IsBrokerError: {error.IsBrokerError})"); + } + + protected virtual void Kafka_OnLog(object sender, LogMessage message) + { + if (message is null) + return; + + OnLog(message.Level, $"{message.Facility}:{message.Name} - {message.Message}"); + } + + protected virtual void Kafka_OnStatistics(object sender, string e) + { + if (e is null) + return; + + OnLog(SyslogLevel.Debug, e); + } + + protected virtual void OnLog(SyslogLevel level, string message) + { + switch (level) + { + case SyslogLevel.Emergency: + case SyslogLevel.Alert: + case SyslogLevel.Critical: + Logger.LogCritical(message); + + break; + case SyslogLevel.Error: + Logger.LogError(message); + + break; + case SyslogLevel.Warning: + Logger.LogWarning(message); + + break; + case SyslogLevel.Notice: + case SyslogLevel.Info: + Logger.LogInformation(message); + + break; + default: + Logger.LogDebug(message); + + break; + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/KafkaConsumer.cs b/src/OpenMessage.Apache.Kafka/KafkaConsumer.cs new file mode 100644 index 0000000..20bee7d --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/KafkaConsumer.cs @@ -0,0 +1,198 @@ +using Confluent.Kafka; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenMessage.Apache.Kafka.Configuration; +using OpenMessage.Apache.Kafka.OffsetTracking; +using OpenMessage.Serialization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Apache.Kafka +{ + internal sealed class KafkaConsumer : KafkaClient, IKafkaConsumer + { + private static readonly string TimestampFormat = "o"; + private readonly Action> _acknowledgementAction; + private readonly IDeserializationProvider _deserializationProvider; + + private readonly OffsetTracker _offsetTracker = new OffsetTracker(); + private readonly IOptionsMonitor _options; + private IConsumer? _consumer; + private string? _topicName; + private Task? _trackAcknowledgedTask; + + public KafkaConsumer(ILogger> logger, IDeserializationProvider deserializationProvider, IOptionsMonitor options) + : base(logger) + { + _deserializationProvider = deserializationProvider ?? throw new ArgumentNullException(nameof(deserializationProvider)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _acknowledgementAction = msg => _offsetTracker.AckOffset(msg.Partition, msg.Offset); + } + + public Task?> ConsumeAsync(CancellationToken cancellationToken) + { + return Task.Run(() => + { + try + { + if (_consumer is null) + return null; + + var consumeResult = _consumer.Consume(cancellationToken); + + if (consumeResult is null || consumeResult.IsPartitionEOF) + { + Logger.LogInformation(consumeResult is null ? "No message received from consumer." : $"End of partition reached. Topic: {consumeResult.Topic} Partition: {consumeResult.Partition} Offset: {consumeResult.Offset}"); + + return null; + } + + var messageProperties = ParseMessageHeaders(consumeResult, out var contentType, out var messageType); + + if (string.IsNullOrWhiteSpace(contentType)) + contentType = ContentTypes.Json; + + var keyType = TypeCache.AssemblyQualifiedName; + if (string.IsNullOrWhiteSpace(keyType)) + Throw.Exception("Cannot find key assembly type name for: " + typeof(TKey).Name); + + if (string.IsNullOrWhiteSpace(messageType)) + Throw.Exception("Cannot find message assembly type name for: " + typeof(TKey).Name); + + var key = _deserializationProvider.From(consumeResult.Message.Key, contentType, keyType); + var value = _deserializationProvider.From(consumeResult.Message.Value, contentType, messageType); + + _offsetTracker.AddOffset(consumeResult.Partition, consumeResult.Offset); + + return new KafkaMessage(_acknowledgementAction, consumeResult.Partition.Value, consumeResult.Offset.Value) + { + Id = key, + Properties = messageProperties, + Value = value + }; + } + catch (OperationCanceledException) + { + return null; + } + }); + } + + public void Start(string consumerId) + { + var options = _options.Get(consumerId); + var offsetTrackerInterval = options.KafkaConfiguration.TryGetValue("auto.commit.interval.ms", out var val) && double.TryParse(val, out var ms) ? TimeSpan.FromMilliseconds(ms) : TimeSpan.FromSeconds(1); + _topicName = options.TopicName; + + _consumer = new ConsumerBuilder(options.KafkaConfiguration).SetErrorHandler(Kafka_OnError) + .SetLogHandler(Kafka_OnLog) + .SetOffsetsCommittedHandler(OnOffsetsCommitted) + .SetPartitionsAssignedHandler(OnPartitionsAssigned) + .SetPartitionsRevokedHandler(OnPartitionsRevoked) + .SetStatisticsHandler(Kafka_OnStatistics) + .Build(); + _consumer.Subscribe(_topicName); + + _trackAcknowledgedTask = Task.Run(async () => + { + var loggerEnabled = Logger.IsEnabled(LogLevel.Information); + + while (_consumer is {}) + { + try + { + foreach (var offset in _offsetTracker.GetAcknowledgedOffsets()) + { + if (loggerEnabled) + Logger.LogInformation($"Committing '{_topicName}' on partition '{offset.Partition}' to offset '{offset.Offset}'"); + + _consumer.StoreOffset(new TopicPartitionOffset(new TopicPartition(_topicName, new Partition(offset.Partition)), new Offset(offset.Offset + 1))); + _offsetTracker.PruneCommitted(offset); + } + } + catch (Exception ex) + { + Logger.LogError(ex, ex.Message); + } + + await Task.Delay(offsetTrackerInterval); + } + }); + } + + public void Stop() + { + _consumer?.Unsubscribe(); + _offsetTracker.Clear(); + } + + private IEnumerable> ParseMessageHeaders(ConsumeResult consumeResult, out string contentType, out string? messageType) + { + contentType = ContentTypes.Json; + messageType = null; + + if (consumeResult.Message.Headers is null) + return Enumerable.Empty>(); + + var headers = new List>(consumeResult.Message.Headers.Count + 3) + { + new KeyValuePair(KnownKafkaProperties.Offset, consumeResult.Offset.Value.ToString(CultureInfo.InvariantCulture)), + new KeyValuePair(KnownKafkaProperties.Partition, consumeResult.Partition.Value.ToString(CultureInfo.InvariantCulture)), + new KeyValuePair(KnownKafkaProperties.Timestamp, consumeResult.Message.Timestamp.UtcDateTime.ToString(TimestampFormat, CultureInfo.InvariantCulture)) + }; + + if (!string.IsNullOrWhiteSpace(_topicName)) + headers.Add(new KeyValuePair(KnownKafkaProperties.Topic, _topicName)); + + foreach (var header in consumeResult.Message.Headers) + { + var value = Encoding.UTF8.GetString(header.GetValueBytes()); + headers.Add(new KeyValuePair(header.Key, value)); + + if (header.Key == KnownProperties.ContentType) + contentType = value; + + if (header.Key == KnownProperties.ValueTypeName) + messageType = value; + } + + return headers; + } + + #region Logging + + private void OnOffsetsCommitted(IConsumer consumer, CommittedOffsets e) + { + if (!Logger.IsEnabled(LogLevel.Trace) || e?.Offsets is null) + return; + + foreach (var offset in e.Offsets) + Logger.LogTrace($"Offset Committed On Topic {offset.Topic}. Partition: {offset.Partition.Value} Offset: {offset.Offset}"); + } + + private void OnPartitionsRevoked(IConsumer consumer, List topicPartitions) + { + if (topicPartitions is null || !Logger.IsEnabled(LogLevel.Information)) + return; + + var topics = string.Join(" | ", topicPartitions.Select(x => $"Topic: {x.Topic} Partition: {x.Partition.Value}")); + Logger.LogInformation($"Rebalancing: {topics}"); + } + + private void OnPartitionsAssigned(IConsumer consumer, List topicPartitions) + { + if (topicPartitions is null || !Logger.IsEnabled(LogLevel.Information)) + return; + + var topics = string.Join(" | ", topicPartitions.Select(x => $"Topic: {x.Topic} Partition: {x.Partition.Value}")); + Logger.LogInformation($"Assigning: {topics}"); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/KafkaDispatcher.cs b/src/OpenMessage.Apache.Kafka/KafkaDispatcher.cs new file mode 100644 index 0000000..e3eb91e --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/KafkaDispatcher.cs @@ -0,0 +1,137 @@ +using Confluent.Kafka; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenMessage.Apache.Kafka.Configuration; +using OpenMessage.Serialization; +using System; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Apache.Kafka +{ + internal sealed class KafkaDispatcher : KafkaClient, IDispatcher + { + private readonly byte[] _contentType; + private readonly IOptionsMonitor> _options; + private readonly IProducer _producer; + private readonly ISerializer _serializer; + + public KafkaDispatcher(ILogger> logger, IOptionsMonitor> options, ISerializer serializer) + : base(logger) + { + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _contentType = Encoding.UTF8.GetBytes(_serializer.ContentType); + _options = options ?? throw new ArgumentNullException(nameof(options)); + + _producer = new ProducerBuilder(options.CurrentValue.KafkaConfiguration).SetErrorHandler(Kafka_OnError) + .SetLogHandler(Kafka_OnLog) + .SetStatisticsHandler(Kafka_OnStatistics) + .Build(); + } + + public Task DispatchAsync(T entity, CancellationToken cancellationToken) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return DispatchAsync(new Message + { + Value = entity + }, cancellationToken); + } + + public async Task DispatchAsync(Message message, CancellationToken cancellationToken) + { + if (message is null || message.Value is null) + Throw.ArgumentNullException(nameof(message)); + + cancellationToken.ThrowIfCancellationRequested(); + + var headers = CreateHeadersFromExisting(message); + var key = CreateKeyForMessage(message); + + var msg = new Message + { + Key = key, + Headers = headers, + Value = _serializer.AsBytes(message.Value) + }; + + await _producer.ProduceAsync(_options.CurrentValue.TopicName, msg); + } + + private Headers CreateHeadersFromExisting(Message message) + { + Headers HeadersIncludingDefaults(Headers h, Message msg, bool contentType = false, bool valueType = false) + { + if (!contentType) + h.Add(KnownProperties.ContentType, _contentType); + + var aqn = msg.Value?.GetType().AssemblyQualifiedName; + if (!valueType && aqn is {}) + h.Add(KnownProperties.ValueTypeName, Encoding.UTF8.GetBytes(aqn)); + + if (Activity.Current is {}) + h.Add(KnownProperties.ActivityId, Encoding.UTF8.GetBytes(Activity.Current.Id)); + + return h; + } + + var headers = new Headers(); + + switch (message) + { + case ISupportProperties p: + { + foreach (var prop in p.Properties) + headers.Add(prop.Key, Encoding.UTF8.GetBytes(prop.Value)); + + break; + } + case ISupportProperties p2: + { + foreach (var prop in p2.Properties) + headers.Add(prop.Key, prop.Value); + + break; + } + case ISupportProperties p3: + { + foreach (var prop in p3.Properties) + headers.Add(Encoding.UTF8.GetString(prop.Key), prop.Value); + + break; + } + } + + if (headers.Count == 0) + return HeadersIncludingDefaults(headers, message); + + bool hasContentType = false, + hasValueType = false; + + foreach (var header in headers) + { + if (header.Key == KnownProperties.ContentType) + hasContentType = true; + + if (header.Key == KnownProperties.ValueTypeName) + hasValueType = true; + } + + return HeadersIncludingDefaults(headers, message, hasContentType, hasValueType); + } + + private byte[] CreateKeyForMessage(Message message) + { + return message switch + { + ISupportIdentification mi => mi.Id!, + ISupportIdentification mi2 => _serializer.AsBytes(mi2.Id), + _ => _serializer.AsBytes(Guid.NewGuid()) + }; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/KafkaMessage.cs b/src/OpenMessage.Apache.Kafka/KafkaMessage.cs new file mode 100644 index 0000000..eb29f76 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/KafkaMessage.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; + +namespace OpenMessage.Apache.Kafka +{ + internal sealed class KafkaMessage : Message, ISupportAcknowledgement, ISupportIdentification, ISupportProperties + { + private readonly Action> _postiveAcknowledgeAction; + private AcknowledgementState _acknowledgementState = AcknowledgementState.NotAcknowledged; +#if NETCOREAPP3_1 + private OpenMessageEventSource.ValueStopwatch? _stopwatch; +#endif + + /// + [MaybeNull, AllowNull] + public TKey Id { get; internal set; } = default; + + /// + public IEnumerable> Properties { get; internal set; } = Enumerable.Empty>(); + + internal long Offset { get; } + internal int Partition { get; } + + /// + AcknowledgementState ISupportAcknowledgement.AcknowledgementState => _acknowledgementState; + + internal KafkaMessage(Action> postiveAcknowledgeAction, int partition, long offset) + { + Partition = partition; + Offset = offset; + _postiveAcknowledgeAction = postiveAcknowledgeAction ?? throw new ArgumentNullException(nameof(postiveAcknowledgeAction)); +#if NETCOREAPP3_1 + _stopwatch = OpenMessageEventSource.Instance.ProcessMessageStart(); +#endif + } + + /// + Task ISupportAcknowledgement.AcknowledgeAsync(bool positivelyAcknowledge, Exception? exception) + { + try + { + if (_acknowledgementState != AcknowledgementState.NotAcknowledged) + return Task.CompletedTask; + + if (positivelyAcknowledge) + { + _postiveAcknowledgeAction?.Invoke(this); + _acknowledgementState = AcknowledgementState.Acknowledged; + } + else + { + _acknowledgementState = AcknowledgementState.NegativelyAcknowledged; + } + return Task.CompletedTask; + } + finally + { +#if NETCOREAPP3_1 + if (_stopwatch.HasValue) + { + OpenMessageEventSource.Instance.ProcessMessageStop(_stopwatch.Value); + _stopwatch = null; + } +#endif + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/KafkaMessagePump.cs b/src/OpenMessage.Apache.Kafka/KafkaMessagePump.cs new file mode 100644 index 0000000..9edab56 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/KafkaMessagePump.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Logging; +using OpenMessage.Pipelines.Pumps; +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace OpenMessage.Apache.Kafka.HostedServices +{ + internal sealed class KafkaMessagePump : MessagePump + { + private readonly string _consumerId; + private readonly IKafkaConsumer _kafkaConsumer; + + public KafkaMessagePump(ChannelWriter> channelWriter, ILogger> logger, IKafkaConsumer kafkaConsumer, string consumerId) + : base(channelWriter, logger) + { + _kafkaConsumer = kafkaConsumer ?? throw new ArgumentNullException(nameof(kafkaConsumer)); + _consumerId = consumerId ?? throw new ArgumentNullException(nameof(consumerId)); + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + _kafkaConsumer.Start(_consumerId); + await base.StartAsync(cancellationToken); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await base.StopAsync(cancellationToken); + _kafkaConsumer.Stop(); + } + + protected override async Task ConsumeAsync(CancellationToken cancellationToken) + { + var kafkaMessage = await _kafkaConsumer.ConsumeAsync(cancellationToken); + + if (kafkaMessage is {}) + await ChannelWriter.WriteAsync(kafkaMessage, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/KafkaServiceExtensions.cs b/src/OpenMessage.Apache.Kafka/KafkaServiceExtensions.cs new file mode 100644 index 0000000..3b6f24f --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/KafkaServiceExtensions.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using OpenMessage; +using OpenMessage.Apache.Kafka; +using OpenMessage.Apache.Kafka.Configuration; +using System; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extensions for adding a Kafka Consumer or Dispatcher + /// + public static class KafkaServiceExtensions + { + /// + /// Adds a kafka consumer + /// + /// The host builder + /// The type to consume + /// The modified builder + public static IKafkaConsumerBuilder ConfigureKafkaConsumer(this IMessagingBuilder messagingBuilder) => messagingBuilder.ConfigureKafkaConsumer(); + + /// + /// Adds a kafka consumer + /// + /// The host builder + /// The type of the key + /// The type of the message + /// The modified builder + public static IKafkaConsumerBuilder ConfigureKafkaConsumer(this IMessagingBuilder messagingBuilder) => new KafkaConsumerBuilder(messagingBuilder); + + /// + /// Adds a kafka dispatcher + /// + /// The host builder + /// Options for the dispatcher + /// The type to dispatch + /// The modified builder + public static IMessagingBuilder ConfigureKafkaDispatcher(this IMessagingBuilder messagingBuilder, Action>? options = null) + { + if (options is {}) + messagingBuilder.Services.Configure(options); + + messagingBuilder.Services.AddSingleton, KafkaDispatcher>() + .TryAddSingleton>, KafkaOptionsPostConfigurationProvider>(); + + return messagingBuilder; + } + + /// + /// Adds a kafka dispatcher + /// + /// The host builder + /// Options for the dispatcher + /// The type to dispatch + /// The modified builder + public static IMessagingBuilder ConfigureKafkaDispatcher(this IMessagingBuilder messagingBuilder, Action>? options = null) + { + if (options is {}) + messagingBuilder.Services.Configure>(o => options(messagingBuilder.Context, o)); + + messagingBuilder.Services.AddSingleton, KafkaDispatcher>() + .TryAddSingleton>, KafkaOptionsPostConfigurationProvider>(); + + return messagingBuilder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/KnownKafkaProperties.cs b/src/OpenMessage.Apache.Kafka/KnownKafkaProperties.cs new file mode 100644 index 0000000..f47a87a --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/KnownKafkaProperties.cs @@ -0,0 +1,28 @@ +namespace OpenMessage.Apache.Kafka +{ + /// + /// Properties that are automatically added to a consumed message + /// + public static class KnownKafkaProperties + { + /// + /// The offset the message is located at + /// + public static readonly string Offset = nameof(Offset); + + /// + /// The partition the message is located on + /// + public static readonly string Partition = nameof(Partition); + + /// + /// The timestamp of the message on the partition + /// + public static readonly string Timestamp = nameof(Timestamp); + + /// + /// The topic the message was consumed from + /// + public static readonly string Topic = nameof(Topic); + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/OffsetTracking/AckedOffset.cs b/src/OpenMessage.Apache.Kafka/OffsetTracking/AckedOffset.cs new file mode 100644 index 0000000..5293683 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/OffsetTracking/AckedOffset.cs @@ -0,0 +1,14 @@ +namespace OpenMessage.Apache.Kafka.OffsetTracking +{ + internal readonly struct AckedOffset + { + internal readonly int Partition; + internal readonly long Offset; + + internal AckedOffset(int partition, long offset) + { + Partition = partition; + Offset = offset; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/OffsetTracking/OffsetTracker.cs b/src/OpenMessage.Apache.Kafka/OffsetTracking/OffsetTracker.cs new file mode 100644 index 0000000..6aa75e1 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/OffsetTracking/OffsetTracker.cs @@ -0,0 +1,64 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace OpenMessage.Apache.Kafka.OffsetTracking +{ + internal sealed class OffsetTracker + { + private readonly ConcurrentDictionary> _partitionOffsetMap = new ConcurrentDictionary>(); + + public void AddOffset(in int partition, in long offset) + { + GetOrAddPartition(partition).TryAdd(offset, false); + } + + public void AckOffset(in int partition, in long offset) + { + GetOrAddPartition(partition).TryUpdate(offset, true, false); + } + + public void Clear() + { + _partitionOffsetMap.Clear(); + } + + public IEnumerable GetAcknowledgedOffsets() + { + // For each partition + foreach (var partitionMap in _partitionOffsetMap) + { + long latestContiguousAckedOffset = -1; + foreach (var offset in partitionMap.Value.ToArray().OrderBy(kvp => kvp.Key)) + { + if (!offset.Value) + break; + + latestContiguousAckedOffset = offset.Key; + } + + // If -1, the lowest numbered tracked offset hasn't been acked, so nothing to + // commit for this partition + if (latestContiguousAckedOffset >= 0) + yield return new AckedOffset(partitionMap.Key, latestContiguousAckedOffset); + } + } + + public void PruneCommitted(AckedOffset committedOffset) + { + if (!_partitionOffsetMap.TryGetValue(committedOffset.Partition, out var trackedOffsets)) + return; + + // remove everything with an earlier offset value + foreach (var key in trackedOffsets.Keys.Where(k => k <= committedOffset.Offset)) + trackedOffsets.TryRemove(key, out _); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private ConcurrentDictionary GetOrAddPartition(in int partition) + { + return _partitionOffsetMap.GetOrAdd(partition, _ => new ConcurrentDictionary()); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Apache.Kafka/OpenMessage.Apache.Kafka.csproj b/src/OpenMessage.Apache.Kafka/OpenMessage.Apache.Kafka.csproj new file mode 100644 index 0000000..16ec6a5 --- /dev/null +++ b/src/OpenMessage.Apache.Kafka/OpenMessage.Apache.Kafka.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + Kafka consumer and dispatcher implementation for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Azure.EventHubs/OpenMessage.Azure.EventHubs.csproj b/src/OpenMessage.Azure.EventHubs/OpenMessage.Azure.EventHubs.csproj new file mode 100644 index 0000000..d9057e9 --- /dev/null +++ b/src/OpenMessage.Azure.EventHubs/OpenMessage.Azure.EventHubs.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + false + + + + + + + diff --git a/src/OpenMessage.Azure.ServiceBus/OpenMessage.Azure.ServiceBus.csproj b/src/OpenMessage.Azure.ServiceBus/OpenMessage.Azure.ServiceBus.csproj new file mode 100644 index 0000000..c515d57 --- /dev/null +++ b/src/OpenMessage.Azure.ServiceBus/OpenMessage.Azure.ServiceBus.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + false + + + + + + + diff --git a/src/OpenMessage.EventStore/OpenMessage.EventStore.csproj b/src/OpenMessage.EventStore/OpenMessage.EventStore.csproj new file mode 100644 index 0000000..6354170 --- /dev/null +++ b/src/OpenMessage.EventStore/OpenMessage.EventStore.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + false + + + + + + + diff --git a/src/OpenMessage.MediatR/MediatRBatchMessage.cs b/src/OpenMessage.MediatR/MediatRBatchMessage.cs new file mode 100644 index 0000000..390e5f0 --- /dev/null +++ b/src/OpenMessage.MediatR/MediatRBatchMessage.cs @@ -0,0 +1,26 @@ +using MediatR; +using System.Collections; +using System.Collections.Generic; + +namespace OpenMessage.MediatR +{ + /// + /// Ensure that all listen to a notification type of or + /// + public class MediatRBatch : INotification, IReadOnlyCollection> + { + private readonly IReadOnlyCollection> _messages; + + /// + public int Count => _messages.Count; + + /// + public MediatRBatch(IReadOnlyCollection> messages) => _messages = messages; + + /// + public IEnumerator> GetEnumerator() => _messages.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable) _messages).GetEnumerator(); + } +} \ No newline at end of file diff --git a/src/OpenMessage.MediatR/MediatRBatchPipelineEndpoint.cs b/src/OpenMessage.MediatR/MediatRBatchPipelineEndpoint.cs new file mode 100644 index 0000000..96b6862 --- /dev/null +++ b/src/OpenMessage.MediatR/MediatRBatchPipelineEndpoint.cs @@ -0,0 +1,29 @@ +using MediatR; +using OpenMessage.Pipelines; +using OpenMessage.Pipelines.Endpoints; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.MediatR +{ + /// + /// A batch pipeline endpoint that calls into MediatR + /// + public class MediatRBatchPipelineEndpoint : IBatchPipelineEndpoint + { + private readonly IMediator _mediator; + + /// + public MediatRBatchPipelineEndpoint(IMediator mediator) => _mediator = mediator; + + /// + public async Task Invoke(IReadOnlyCollection> messages, CancellationToken cancellationToken, MessageContext messageContext) + { + await _mediator.Publish(new MediatRBatch(messages), cancellationToken); + + foreach (var message in messages) + await _mediator.Publish>(message, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.MediatR/MediatRExtensions.cs b/src/OpenMessage.MediatR/MediatRExtensions.cs new file mode 100644 index 0000000..b42d77e --- /dev/null +++ b/src/OpenMessage.MediatR/MediatRExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Pipelines.Builders; + +namespace OpenMessage.MediatR +{ + /// + /// MediatR extensions for OpenMessage + /// + public static class MediatRExtensions + { + /// + /// Adds the MediatR pipeline endpoint to the end of the pipeline + /// + public static void RunMediatR(this IPipelineBuilder pipelineBuilder) + { + pipelineBuilder.Services.TryAddScoped>(); + pipelineBuilder.Run>(); + } + + /// + /// Adds the MediatR pipeline endpoint to the end of the pipeline + /// + public static void RunMediatR(this IBatchPipelineBuilder pipelineBuilder) + { + pipelineBuilder.Services.TryAddScoped>(); + pipelineBuilder.Run>(); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.MediatR/MediatRMessage.cs b/src/OpenMessage.MediatR/MediatRMessage.cs new file mode 100644 index 0000000..557f4ee --- /dev/null +++ b/src/OpenMessage.MediatR/MediatRMessage.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace OpenMessage.MediatR +{ + /// + /// Ensure that all listen to a notification type of or + /// + public class MediatRMessage : INotification + { + /// + /// The original message + /// + public Message OriginalMessage { get; } + + /// + public MediatRMessage(Message originalMessage) => OriginalMessage = originalMessage; + + /// + public static implicit operator MediatRMessage(Message message) => new MediatRMessage(message); + + /// + public static implicit operator Message(MediatRMessage message) => message.OriginalMessage; + } +} \ No newline at end of file diff --git a/src/OpenMessage.MediatR/MediatRPipelineEndpoint.cs b/src/OpenMessage.MediatR/MediatRPipelineEndpoint.cs new file mode 100644 index 0000000..09c782c --- /dev/null +++ b/src/OpenMessage.MediatR/MediatRPipelineEndpoint.cs @@ -0,0 +1,25 @@ +using MediatR; +using OpenMessage.Pipelines; +using OpenMessage.Pipelines.Endpoints; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.MediatR +{ + /// + /// A pipeline endpoint that calls into MediatR + /// + public class MediatRPipelineEndpoint : IPipelineEndpoint + { + private readonly IMediator _mediator; + + /// + public MediatRPipelineEndpoint(IMediator mediator) => _mediator = mediator; + + /// + public async Task Invoke(Message message, CancellationToken cancellationToken, MessageContext messageContext) + { + await _mediator.Publish>(message, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.MediatR/OpenMessage.MediatR.csproj b/src/OpenMessage.MediatR/OpenMessage.MediatR.csproj new file mode 100644 index 0000000..f8345e1 --- /dev/null +++ b/src/OpenMessage.MediatR/OpenMessage.MediatR.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + Mediatr implementation for OpenMessage + + + + + + + diff --git a/src/OpenMessage.MediatR/README.md b/src/OpenMessage.MediatR/README.md new file mode 100644 index 0000000..945af5c --- /dev/null +++ b/src/OpenMessage.MediatR/README.md @@ -0,0 +1,21 @@ +# OpenMessage.MediatR + +Because MediatR expects all message types to implement `INotification`, OpenMessage will automatically wrap messages in `MediatRMessage<>` or `MediatRBatchMessage<>` before passing them to MediatR to be handled. + +Ensure that all `INotificationHandler<>` listen for `INotificationHandler>` or `INotificationHandler>` + +## Usage + +``` csharp +Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddMediatR(/** Configuration here **/); + }) + .ConfigureMessaging(builder => + { + builder + .ConfigurePipeline() + .RunMediatR(); + }); +``` diff --git a/src/OpenMessage.NATS/OpenMessage.NATS.csproj b/src/OpenMessage.NATS/OpenMessage.NATS.csproj new file mode 100644 index 0000000..5a192e5 --- /dev/null +++ b/src/OpenMessage.NATS/OpenMessage.NATS.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + false + + + + + + + diff --git a/src/OpenMessage.Polly/OpenMessage.Polly.csproj b/src/OpenMessage.Polly/OpenMessage.Polly.csproj new file mode 100644 index 0000000..74e61fe --- /dev/null +++ b/src/OpenMessage.Polly/OpenMessage.Polly.csproj @@ -0,0 +1,10 @@ + + + $(ProjectTargetFrameworks) + Polly middleware that uses a policy registry + + + + + + diff --git a/src/OpenMessage.Polly/PollyExtensions.cs b/src/OpenMessage.Polly/PollyExtensions.cs new file mode 100644 index 0000000..6dd65a4 --- /dev/null +++ b/src/OpenMessage.Polly/PollyExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Pipelines.Builders; +using Polly.Registry; +using System; + +namespace OpenMessage.Polly +{ + /// + /// Extensions for configuring Polly as middleware + /// + public static class PollyExtensions + { + /// + /// Adds a readonly policy registry for polly + /// + public static IServiceCollection AddPolly(this IServiceCollection services, Action> configuration) + { + var registry = new PolicyRegistry(); + configuration?.Invoke(registry); + + return services.AddSingleton>(registry); + } + + /// + /// Adds a Polly IAsyncPolicy as middleware. + /// + /// The pipeline builder + /// The name of the policy to use + /// The underlying type for the pipeline + /// The modified pipeline builder + public static IPipelineBuilder UsePolly(this IPipelineBuilder pipelineBuilder, string policy) => UsePolly(pipelineBuilder, options => options.PolicyName = policy); + + /// + /// Adds a Polly IAsyncPolicy as middleware. + /// + /// The pipeline builder + /// Option configuration + /// The underlying type for the pipeline + /// The modified pipeline builder + public static IPipelineBuilder UsePolly(this IPipelineBuilder pipelineBuilder, Action> options) + { + pipelineBuilder.Services.TryAddSingleton>(); + pipelineBuilder.Services.Configure(options); + + return pipelineBuilder.Use>(); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Polly/PollyMiddleware.cs b/src/OpenMessage.Polly/PollyMiddleware.cs new file mode 100644 index 0000000..55d4f0c --- /dev/null +++ b/src/OpenMessage.Polly/PollyMiddleware.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Options; +using OpenMessage.Pipelines; +using OpenMessage.Pipelines.Middleware; +using Polly; +using Polly.Registry; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Polly +{ + internal sealed class PollyMiddleware : Middleware + { + private readonly IOptionsMonitor> _optionsMonitor; + private readonly IReadOnlyPolicyRegistry _policyRegistry; + + public PollyMiddleware(IOptionsMonitor> optionsMonitor, IReadOnlyPolicyRegistry policyRegistry) + { + _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + _policyRegistry = policyRegistry ?? throw new ArgumentNullException(nameof(policyRegistry)); + } + + /// + protected override async Task OnInvoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next) + { + if (!string.IsNullOrWhiteSpace(_optionsMonitor.CurrentValue.PolicyName) && _policyRegistry.TryGet(_optionsMonitor.CurrentValue.PolicyName, out var policy)) + { + await policy.ExecuteAsync(async ct => await next(message, ct, messageContext), cancellationToken); + + return; + } + + await next(message, cancellationToken, messageContext); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Polly/PollyMiddlewareOptions.cs b/src/OpenMessage.Polly/PollyMiddlewareOptions.cs new file mode 100644 index 0000000..fea722f --- /dev/null +++ b/src/OpenMessage.Polly/PollyMiddlewareOptions.cs @@ -0,0 +1,13 @@ +namespace OpenMessage.Polly +{ + /// + /// The options for the Polly Middleware + /// + public class PollyMiddlewareOptions + { + /// + /// The name of the policy to use + /// + public string? PolicyName { get; set; } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Providers.Azure/Configuration/OpenMessageAzureProviderOptions.cs b/src/OpenMessage.Providers.Azure/Configuration/OpenMessageAzureProviderOptions.cs deleted file mode 100644 index 77a0e6b..0000000 --- a/src/OpenMessage.Providers.Azure/Configuration/OpenMessageAzureProviderOptions.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.ServiceBus.Messaging; -using System; - -namespace OpenMessage.Providers.Azure.Configuration -{ - public class OpenMessageAzureProviderOptions - { - /// - /// The connection string to use when connection to the Azure Service Bus Namespace. - /// - public string ConnectionString { get; set; } - /// - /// The timeout period for operations that occur in Azure. - /// - public TimeSpan RemoteOperationTimeout { get; set; } = TimeSpan.FromSeconds(15); - /// - /// Sets the idle interval after which the queue/topic/subscription is automatically deleted. The minimum duration is 5 minutes. - /// - public TimeSpan AutoDeleteOnIdle { get; set; } = TimeSpan.MaxValue; - /// - /// Determines whether or not to enable the queue to be partitioned across multiple message brokers. An express queue holds a message in memory temporarily before writing it to persistent storage. - /// - public bool EnableExpress { get; set; } = true; - /// - /// Determines whether or not the queue should be partitioned across multiple message brokers when enabled. - /// - public bool EnablePartitioning { get; set; } = true; - /// - /// Determines whether server-side batched operations are enabled. - /// - public bool EnableServerSideBatchedOperations { get; set; } = true; - /// - /// Sets the maximum delivery count. A message is automatically deadlettered after this number of deliveries. - /// - public int MaximumDeliveryCount { get; set; } = 10; - /// - /// Gets or sets the duration of a peek lock; that is, the amount of time that the message is locked for other receivers. The maximum value for is 5 minutes; the default value is 1 minute. - /// - public TimeSpan MessageLockDuration { get; set; } = TimeSpan.FromMinutes(1); - /// - /// Gets or sets the default message time to live value. This is the duration after which the message expires, starting from when the message is sent to Service Bus. - /// - public TimeSpan MessageTimeToLive { get; set; } = TimeSpan.MaxValue; - /// - /// Gets or sets the receive mode for the message. Change with caution!!! - /// - public ReceiveMode ReceiveMode { get; set; } = ReceiveMode.PeekLock; - /// - /// Gets or sets the transport to use for the connection to Azure. Default: NetMessaging - /// - public Transport Transport { get; set; } = Transport.NetMessaging; - /// - /// Gets or Sets the Azure batch flushing mechanism. Default: 20ms - /// - public TimeSpan BatchFlushInterval { get; set; } = TimeSpan.FromMilliseconds(20); - /// - /// (AMQP ONLY) Gets or sets the maximum frame size - /// - public bool EnableLinkRedirect { get; set; } = true; - /// - /// (AMQP ONLY) Gets or sets the maximum frame size - /// - public int MaximumFrameSize { get; set; } = 1024; - /// - /// (AMQP ONLY) Gets a value that indicates whether the SSL stream uses a custom binding element - /// - public bool UseSslStreamSecurity { get; set; } = true; - /// - /// Gets or sets the number of messages that the queue receiver can simultaneously request. - /// - public int PrefetchCount { get; set; } = Environment.ProcessorCount * 4; - } -} diff --git a/src/OpenMessage.Providers.Azure/Configuration/OpenMessageAzureProviderOptionsConfigurator.cs b/src/OpenMessage.Providers.Azure/Configuration/OpenMessageAzureProviderOptionsConfigurator.cs deleted file mode 100644 index 0061b59..0000000 --- a/src/OpenMessage.Providers.Azure/Configuration/OpenMessageAzureProviderOptionsConfigurator.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.Extensions.Options; -using System; - -namespace OpenMessage.Providers.Azure.Configuration -{ - internal sealed class OpenMessageAzureProviderOptionsConfigurator : IConfigureOptions> - { - private readonly OpenMessageAzureProviderOptions _defaultOptions; - - public OpenMessageAzureProviderOptionsConfigurator(IOptions defaultOptions) - { - if (defaultOptions == null) - throw new ArgumentNullException(nameof(defaultOptions)); - - _defaultOptions = defaultOptions.Value; - } - - public void Configure(OpenMessageAzureProviderOptions options) - { - options.ConnectionString = _defaultOptions.ConnectionString; - } - } -} diff --git a/src/OpenMessage.Providers.Azure/Configuration/OpenMessageAzureProviderOptions_T.cs b/src/OpenMessage.Providers.Azure/Configuration/OpenMessageAzureProviderOptions_T.cs deleted file mode 100644 index c61ab82..0000000 --- a/src/OpenMessage.Providers.Azure/Configuration/OpenMessageAzureProviderOptions_T.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System; - -namespace OpenMessage.Providers.Azure.Configuration -{ - public class OpenMessageAzureProviderOptions : OpenMessageAzureProviderOptions - { - } -} diff --git a/src/OpenMessage.Providers.Azure/Configuration/Transport.cs b/src/OpenMessage.Providers.Azure/Configuration/Transport.cs deleted file mode 100644 index c8475fb..0000000 --- a/src/OpenMessage.Providers.Azure/Configuration/Transport.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OpenMessage.Providers.Azure.Configuration -{ - public enum Transport - { - NetMessaging = 0, - Amqp = 1 - } -} diff --git a/src/OpenMessage.Providers.Azure/Conventions/DefaultNamingConventions.cs b/src/OpenMessage.Providers.Azure/Conventions/DefaultNamingConventions.cs deleted file mode 100644 index f80a989..0000000 --- a/src/OpenMessage.Providers.Azure/Conventions/DefaultNamingConventions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; - -namespace OpenMessage.Providers.Azure.Conventions -{ - internal sealed class DefaultNamingConventions : IQueueNamingConvention, ISubscriptionNamingConvention, ITopicNamingConvention - { - string IQueueNamingConvention.GenerateName() => $"{typeof(T).Namespace}.{typeof(T).GetFriendlyName()}".AsAzureSafeString(); - - string ISubscriptionNamingConvention.GenerateName() => Environment.MachineName.AsAzureSafeString(); - - string ITopicNamingConvention.GenerateName() => $"{typeof(T).Namespace}.{typeof(T).GetFriendlyName()}".AsAzureSafeString(); - } -} diff --git a/src/OpenMessage.Providers.Azure/Conventions/IQueueNamingConvention.cs b/src/OpenMessage.Providers.Azure/Conventions/IQueueNamingConvention.cs deleted file mode 100644 index e15ac04..0000000 --- a/src/OpenMessage.Providers.Azure/Conventions/IQueueNamingConvention.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OpenMessage.Providers.Azure.Conventions -{ - public interface IQueueNamingConvention - { - string GenerateName(); - } -} diff --git a/src/OpenMessage.Providers.Azure/Conventions/ISubscriptionNamingConvention.cs b/src/OpenMessage.Providers.Azure/Conventions/ISubscriptionNamingConvention.cs deleted file mode 100644 index 623d514..0000000 --- a/src/OpenMessage.Providers.Azure/Conventions/ISubscriptionNamingConvention.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OpenMessage.Providers.Azure.Conventions -{ - public interface ISubscriptionNamingConvention - { - string GenerateName(); - } -} diff --git a/src/OpenMessage.Providers.Azure/Conventions/ITopicNamingConvention.cs b/src/OpenMessage.Providers.Azure/Conventions/ITopicNamingConvention.cs deleted file mode 100644 index 5a7664d..0000000 --- a/src/OpenMessage.Providers.Azure/Conventions/ITopicNamingConvention.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OpenMessage.Providers.Azure.Conventions -{ - public interface ITopicNamingConvention - { - string GenerateName(); - } -} diff --git a/src/OpenMessage.Providers.Azure/Dispatchers/QueueDispatcher.cs b/src/OpenMessage.Providers.Azure/Dispatchers/QueueDispatcher.cs deleted file mode 100644 index d1d1f65..0000000 --- a/src/OpenMessage.Providers.Azure/Dispatchers/QueueDispatcher.cs +++ /dev/null @@ -1,21 +0,0 @@ -using OpenMessage.Providers.Azure.Management; -using System; -using System.Threading.Tasks; - -namespace OpenMessage.Providers.Azure.Dispatchers -{ - internal sealed class QueueDispatcher : IDispatcher - { - private readonly IQueueClient _client; - - public QueueDispatcher(IQueueFactory queueFactory) - { - if (queueFactory == null) - throw new ArgumentNullException(nameof(queueFactory)); - - _client = queueFactory.Create(); - } - - public Task DispatchAsync(T entity, TimeSpan scheduleIn) => _client.SendAsync(entity, scheduleIn); - } -} diff --git a/src/OpenMessage.Providers.Azure/Dispatchers/TopicDispatcher.cs b/src/OpenMessage.Providers.Azure/Dispatchers/TopicDispatcher.cs deleted file mode 100644 index dddf1a3..0000000 --- a/src/OpenMessage.Providers.Azure/Dispatchers/TopicDispatcher.cs +++ /dev/null @@ -1,21 +0,0 @@ -using OpenMessage.Providers.Azure.Management; -using System; -using System.Threading.Tasks; - -namespace OpenMessage.Providers.Azure.Dispatchers -{ - internal sealed class TopicDispatcher : IDispatcher - { - private readonly ITopicClient _client; - - public TopicDispatcher(ITopicFactory topicFactory) - { - if (topicFactory == null) - throw new ArgumentNullException(nameof(topicFactory)); - - _client = topicFactory.Create(); - } - - public Task DispatchAsync(T entity, TimeSpan scheduleIn) => _client.SendAsync(entity, scheduleIn); - } -} diff --git a/src/OpenMessage.Providers.Azure/IMessageExtension.cs b/src/OpenMessage.Providers.Azure/IMessageExtension.cs deleted file mode 100644 index fc23796..0000000 --- a/src/OpenMessage.Providers.Azure/IMessageExtension.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.ServiceBus.Messaging; - -namespace OpenMessage.Providers.Azure -{ - public interface IMessageExtension - { - void Extend(BrokeredMessage message); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/AwaitableLazy.cs b/src/OpenMessage.Providers.Azure/Management/AwaitableLazy.cs deleted file mode 100644 index 632de41..0000000 --- a/src/OpenMessage.Providers.Azure/Management/AwaitableLazy.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.ComponentModel; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; - -namespace OpenMessage.Providers.Azure.Management -{ - internal sealed class AwaitableLazy - { - private readonly Lazy> _instance; - - public AwaitableLazy(Func> factory) - { - if (factory == null) - throw new ArgumentNullException(nameof(factory)); - - _instance = new Lazy>(() => Task.Run(factory)); - } - - internal T Value => _instance.Value.Result; - internal bool IsValueCreated => _instance.IsValueCreated; - - [EditorBrowsable(EditorBrowsableState.Never)] - public TaskAwaiter GetAwaiter() => _instance.Value.GetAwaiter(); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/ClientBase.cs b/src/OpenMessage.Providers.Azure/Management/ClientBase.cs deleted file mode 100644 index 3d3f663..0000000 --- a/src/OpenMessage.Providers.Azure/Management/ClientBase.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.ServiceBus.Messaging; -using OpenMessage.Providers.Azure.Serialization; -using System; -using System.Collections.Generic; - -namespace OpenMessage.Providers.Azure.Management -{ - internal abstract class ClientBase : IDisposable - { - private readonly List> _callbacks = new List>(); - private readonly ISerializationProvider _provider; - - protected int CallbackCount => _callbacks.Count; - protected ILogger> Logger { get; } - protected string TypeName { get; } = typeof(T).GetFriendlyName(); - - protected ClientBase(ISerializationProvider provider, - ILogger> logger) - { - if (provider == null) - throw new ArgumentNullException(nameof(provider)); - - if (logger == null) - throw new ArgumentNullException(nameof(logger)); - - _provider = provider; - Logger = logger; - } - ~ClientBase() - { - Dispose(false); - } - - protected void AddCallback(Action callback) - { - if (callback == null) - throw new ArgumentNullException(nameof(callback)); - - _callbacks.Add(callback); - } - - protected void OnMessage(BrokeredMessage message) - { - if (message == null) - throw new ArgumentNullException(nameof(message)); - - try - { - var entity = _provider.Deserialize(message); - foreach (var callback in _callbacks) - callback(entity); - } - catch(Exception ex) - { - Logger.LogError(ex.Message, ex); - message.Abandon(new Dictionary - { - { "Exception", ex.Message } - }); - throw; - } - } - - protected BrokeredMessage Serialize(T entity) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - return _provider.Serialize(entity); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - public virtual void Dispose(bool disposing) - { - } - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/INamespaceManager.cs b/src/OpenMessage.Providers.Azure/Management/INamespaceManager.cs deleted file mode 100644 index 3e8b4f9..0000000 --- a/src/OpenMessage.Providers.Azure/Management/INamespaceManager.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.ServiceBus.Messaging; -using System.Threading.Tasks; - -namespace OpenMessage.Providers.Azure.Management -{ - internal interface INamespaceManager - { - QueueClient CreateQueueClient(); - TopicClient CreateTopicClient(); - SubscriptionClient CreateSubscriptionClient(); - - Task ProvisionQueueAsync(); - Task ProvisionTopicAsync(); - Task ProvisionSubscriptionAsync(); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/IQueueClient.cs b/src/OpenMessage.Providers.Azure/Management/IQueueClient.cs deleted file mode 100644 index 580c67b..0000000 --- a/src/OpenMessage.Providers.Azure/Management/IQueueClient.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace OpenMessage.Providers.Azure.Management -{ - internal interface IQueueClient : IDisposable - { - void RegisterCallback(Action callback); - Task SendAsync(T entity, TimeSpan scheduleIn); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/IQueueFactory.cs b/src/OpenMessage.Providers.Azure/Management/IQueueFactory.cs deleted file mode 100644 index cce4cb0..0000000 --- a/src/OpenMessage.Providers.Azure/Management/IQueueFactory.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OpenMessage.Providers.Azure.Management -{ - internal interface IQueueFactory - { - IQueueClient Create(); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/ISubscriptionClient.cs b/src/OpenMessage.Providers.Azure/Management/ISubscriptionClient.cs deleted file mode 100644 index 8f65e37..0000000 --- a/src/OpenMessage.Providers.Azure/Management/ISubscriptionClient.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace OpenMessage.Providers.Azure.Management -{ - internal interface ISubscriptionClient : IDisposable - { - void RegisterCallback(Action callback); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/ISubscriptionFactory.cs b/src/OpenMessage.Providers.Azure/Management/ISubscriptionFactory.cs deleted file mode 100644 index 34f3f15..0000000 --- a/src/OpenMessage.Providers.Azure/Management/ISubscriptionFactory.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OpenMessage.Providers.Azure.Management -{ - internal interface ISubscriptionFactory - { - ISubscriptionClient Create(); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/ITopicClient.cs b/src/OpenMessage.Providers.Azure/Management/ITopicClient.cs deleted file mode 100644 index e3a02c1..0000000 --- a/src/OpenMessage.Providers.Azure/Management/ITopicClient.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace OpenMessage.Providers.Azure.Management -{ - internal interface ITopicClient : IDisposable - { - Task SendAsync(T entity, TimeSpan scheduleIn); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/ITopicFactory.cs b/src/OpenMessage.Providers.Azure/Management/ITopicFactory.cs deleted file mode 100644 index e38bdfa..0000000 --- a/src/OpenMessage.Providers.Azure/Management/ITopicFactory.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OpenMessage.Providers.Azure.Management -{ - internal interface ITopicFactory - { - ITopicClient Create(); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/NamespaceManager.cs b/src/OpenMessage.Providers.Azure/Management/NamespaceManager.cs deleted file mode 100644 index de3cfc4..0000000 --- a/src/OpenMessage.Providers.Azure/Management/NamespaceManager.cs +++ /dev/null @@ -1,207 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.ServiceBus; -using Microsoft.ServiceBus.Messaging; -using OpenMessage.Providers.Azure.Configuration; -using OpenMessage.Providers.Azure.Conventions; -using System; -using System.Collections.Concurrent; -using System.Threading.Tasks; -using ServiceBus = Microsoft.ServiceBus.NamespaceManager; - -namespace OpenMessage.Providers.Azure.Management -{ - internal sealed class NamespaceManager : INamespaceManager - { - private readonly ConcurrentDictionary _pendingOperations = new ConcurrentDictionary(); - private readonly ILogger> _logger; - private readonly OpenMessageAzureProviderOptions _options; - private readonly IQueueNamingConvention _queueNamingConvention; - private readonly ISubscriptionNamingConvention _subscriptionNamingConvention; - private readonly ITopicNamingConvention _topicNamingConvention; - - public NamespaceManager(IOptions> options, - IQueueNamingConvention queueNamingConvention, - ITopicNamingConvention topicNamingConvention, - ISubscriptionNamingConvention subscriptionNamingConvention, - ILogger> logger) - { - if (options == null) - throw new ArgumentNullException(nameof(options)); - - if (queueNamingConvention == null) - throw new ArgumentNullException(nameof(queueNamingConvention)); - - if (topicNamingConvention == null) - throw new ArgumentNullException(nameof(topicNamingConvention)); - - if (subscriptionNamingConvention == null) - throw new ArgumentNullException(nameof(subscriptionNamingConvention)); - - if (logger == null) - throw new ArgumentNullException(nameof(logger)); - - if (string.IsNullOrWhiteSpace(options.Value?.ConnectionString)) - throw new ArgumentException($"The connection string has not been set for the type: {typeof(T).GetFriendlyName()}"); - - _options = options.Value; - _logger = logger; - _queueNamingConvention = queueNamingConvention; - _topicNamingConvention = topicNamingConvention; - _subscriptionNamingConvention = subscriptionNamingConvention; - } - - public QueueClient CreateQueueClient() - { - var client = QueueClient.CreateFromConnectionString(_options.ConnectionString, _queueNamingConvention.GenerateName(), _options.ReceiveMode); - client.PrefetchCount = _options.PrefetchCount; - return client; - } - public TopicClient CreateTopicClient() => TopicClient.CreateFromConnectionString(_options.ConnectionString, _topicNamingConvention.GenerateName()); - public SubscriptionClient CreateSubscriptionClient() - { - var client = SubscriptionClient.CreateFromConnectionString(_options.ConnectionString, _topicNamingConvention.GenerateName(), _subscriptionNamingConvention.GenerateName(), _options.ReceiveMode); - client.PrefetchCount = _options.PrefetchCount; - return client; - } - - public Task ProvisionQueueAsync() => _pendingOperations.GetOrAdd(_queueNamingConvention.GenerateName(), key => ProvisionQueueAsync(CreateServiceBusManager(), key)); - public Task ProvisionSubscriptionAsync() => _pendingOperations.GetOrAdd(_topicNamingConvention.GenerateName(), key => ProvisionSubscriptionAsync(CreateServiceBusManager(), key, _subscriptionNamingConvention.GenerateName())); - public Task ProvisionTopicAsync() => _pendingOperations.GetOrAdd(_topicNamingConvention.GenerateName(), key => ProvisionTopicAsync(CreateServiceBusManager(), key)); - - private async Task ProvisionQueueAsync(ServiceBus manager, string queueName) - { - if (!(await manager.QueueExistsAsync(queueName))) - { - _logger.LogDebug($"Provisioning queue: '{queueName}'"); - - // TODO :: Implement retry policies - try - { - await manager.CreateQueueAsync(new QueueDescription(queueName) - { - AutoDeleteOnIdle = _options.AutoDeleteOnIdle, - DefaultMessageTimeToLive = _options.MessageTimeToLive, - EnableBatchedOperations = _options.EnableServerSideBatchedOperations, - EnableExpress = _options.EnableExpress, - EnablePartitioning = _options.EnablePartitioning, - LockDuration = _options.MessageLockDuration, - MaxDeliveryCount = _options.MaximumDeliveryCount - }); - - _logger.LogInformation($"Provisioned queue: '{queueName}'"); - } - catch (Exception ex) - { - _logger.LogError($"An error occured whilst provisioning queue: '{queueName}'", ex); - } - } - else - _logger.LogDebug($"Queue '{queueName}' already exists, no need to provision."); - - RemoveOperation(queueName); - } - private async Task ProvisionSubscriptionAsync(ServiceBus manager, string topicName, string subscriptionName) - { - await ProvisionTopicAsync(manager, topicName, true); - - if (!(await manager.SubscriptionExistsAsync(topicName, subscriptionName))) - { - _logger.LogDebug($"Provisioning subscription: '{topicName}/{subscriptionName}'"); - - // TODO :: Implement retry policies - try - { - await manager.CreateSubscriptionAsync(new SubscriptionDescription(topicName, subscriptionName) - { - AutoDeleteOnIdle = _options.AutoDeleteOnIdle, - DefaultMessageTimeToLive = _options.MessageTimeToLive, - EnableBatchedOperations = _options.EnableServerSideBatchedOperations, - LockDuration = _options.MessageLockDuration, - MaxDeliveryCount = _options.MaximumDeliveryCount - }); - - _logger.LogInformation($"Provisioned subscription: '{topicName}/{subscriptionName}'"); - } - catch (Exception ex) - { - _logger.LogError($"An error occured whilst provisioning subscription: '{topicName}/{subscriptionName}'", ex); - } - } - else - _logger.LogDebug($"Subscription '{topicName}/{subscriptionName}' already exists, no need to provision."); - - RemoveOperation(topicName); - } - private async Task ProvisionTopicAsync(ServiceBus manager, string topicName, bool keepOperationPending = false) - { - if (!(await manager.TopicExistsAsync(topicName))) - { - _logger.LogDebug($"Provisioning topic: '{topicName}'"); - - // TODO :: Implement retry policies - try - { - await manager.CreateTopicAsync(new TopicDescription(topicName) - { - AutoDeleteOnIdle = _options.AutoDeleteOnIdle, - DefaultMessageTimeToLive = _options.MessageTimeToLive, - EnableBatchedOperations = _options.EnableServerSideBatchedOperations, - EnableExpress = _options.EnableExpress, - EnablePartitioning = _options.EnablePartitioning - }); - - _logger.LogInformation($"Provisioned topic: '{topicName}'"); - } - catch (Exception ex) - { - _logger.LogError($"An error occured whilst provisioning topic: '{topicName}'", ex); - } - } - else - _logger.LogDebug($"Topic '{topicName}' already exists, no need to provision."); - - if(!keepOperationPending) - RemoveOperation(topicName); - } - private void RemoveOperation(string key) - { - Task ughhh; - _pendingOperations.TryRemove(key, out ughhh); - } - private ServiceBus CreateServiceBusManager() - { - var manager = ServiceBus.CreateFromConnectionString(_options.ConnectionString); - manager.Settings.OperationTimeout = _options.RemoteOperationTimeout; - return manager; - } - private MessagingFactory CreateMessagingFactory() - { - var nm = CreateServiceBusManager(); - return MessagingFactory.Create(nm.Address, new MessagingFactorySettings - { - AmqpTransportSettings = new Microsoft.ServiceBus.Messaging.Amqp.AmqpTransportSettings - { - BatchFlushInterval = _options.BatchFlushInterval, - EnableLinkRedirect = _options.EnableLinkRedirect, - MaxFrameSize = _options.MaximumFrameSize, - UseSslStreamSecurity = _options.UseSslStreamSecurity - }, - OperationTimeout = _options.RemoteOperationTimeout, - TokenProvider = nm.Settings.TokenProvider, - TransportType = _options.Transport == Transport.Amqp ? TransportType.Amqp : TransportType.NetMessaging, - NetMessagingTransportSettings = new NetMessagingTransportSettings - { - BatchFlushInterval = _options.BatchFlushInterval - } - }); - } - - private class NamespaceDetails - { - internal string Namespace { get; set; } - internal string Name { get; set; } - internal string Key { get; set; } - } - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/QueueClient.cs b/src/OpenMessage.Providers.Azure/Management/QueueClient.cs deleted file mode 100644 index 530362b..0000000 --- a/src/OpenMessage.Providers.Azure/Management/QueueClient.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Microsoft.Extensions.Logging; -using OpenMessage.Providers.Azure.Serialization; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using AzureClient = Microsoft.ServiceBus.Messaging.QueueClient; - -namespace OpenMessage.Providers.Azure.Management -{ - internal sealed class QueueClient : ClientBase, IQueueClient - { - private readonly AwaitableLazy _client; - private readonly IMessageExtension[] _extensions; - - public QueueClient(INamespaceManager namespaceManager, - ISerializationProvider serializationProvider, - ILogger> logger, - IEnumerable> extensions) - : base(serializationProvider, logger) - { - if (namespaceManager == null) - throw new ArgumentNullException(nameof(namespaceManager)); - - if (extensions == null) - throw new ArgumentNullException(nameof(extensions)); - - _extensions = extensions.ToArray(); - _client = new AwaitableLazy(async() => - { - await namespaceManager.ProvisionQueueAsync(); - return namespaceManager.CreateQueueClient(); - }); - } - - public void RegisterCallback(Action callback) - { - Task.Run(() => { - lock(_client) - if (CallbackCount == 0) - _client.Value.OnMessage(OnMessage); - - AddCallback(callback); - }); - } - - public async Task SendAsync(T entity, TimeSpan scheduleIn) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - if (scheduleIn < TimeSpan.Zero) - throw new ArgumentException("You cannot schedule a message to arrive in the past; time travel isn't a thing yet."); - - var message = Serialize(entity); - if (scheduleIn > TimeSpan.Zero) - message.ScheduledEnqueueTimeUtc = DateTime.UtcNow.Add(scheduleIn); - - Logger.LogInformation($"Sending message of type: {TypeName}"); - try - { - foreach (var extension in _extensions) - extension.Extend(message); - - await (await _client).SendAsync(message); - } - catch (Exception ex) - { - Logger.LogError($"Error sending message of type: {TypeName}; Error: {ex.Message}", ex); - throw; - } - } - - public override void Dispose(bool disposing) - { - if (_client.IsValueCreated) - _client.Value?.Close(); - } - } -} \ No newline at end of file diff --git a/src/OpenMessage.Providers.Azure/Management/QueueFactory.cs b/src/OpenMessage.Providers.Azure/Management/QueueFactory.cs deleted file mode 100644 index 5783169..0000000 --- a/src/OpenMessage.Providers.Azure/Management/QueueFactory.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.Extensions.Logging; -using OpenMessage.Providers.Azure.Serialization; -using System; -using System.Collections.Generic; - -namespace OpenMessage.Providers.Azure.Management -{ - internal sealed class QueueFactory : IQueueFactory - { - private readonly IEnumerable> _extensions; - private readonly ILogger> _logger; - private readonly INamespaceManager _namespaceManager; - private readonly ISerializationProvider _serializationProvider; - - public QueueFactory(INamespaceManager namespaceManager, - ISerializationProvider serializationProvider, - ILogger> logger, - IEnumerable> extensions) - { - if (namespaceManager == null) - throw new ArgumentNullException(nameof(namespaceManager)); - - if (serializationProvider == null) - throw new ArgumentNullException(nameof(serializationProvider)); - - if (logger == null) - throw new ArgumentNullException(nameof(logger)); - - if (extensions == null) - throw new ArgumentNullException(nameof(extensions)); - - _namespaceManager = namespaceManager; - _serializationProvider = serializationProvider; - _logger = logger; - _extensions = extensions; - } - - public IQueueClient Create() => new QueueClient(_namespaceManager, _serializationProvider, _logger, _extensions); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/SubscriptionClient.cs b/src/OpenMessage.Providers.Azure/Management/SubscriptionClient.cs deleted file mode 100644 index bab363c..0000000 --- a/src/OpenMessage.Providers.Azure/Management/SubscriptionClient.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.Extensions.Logging; -using OpenMessage.Providers.Azure.Serialization; -using System; -using System.Threading.Tasks; -using AzureClient = Microsoft.ServiceBus.Messaging.SubscriptionClient; - -namespace OpenMessage.Providers.Azure.Management -{ - internal sealed class SubscriptionClient : ClientBase, ISubscriptionClient - { - private AwaitableLazy _client; - - public SubscriptionClient(INamespaceManager namespaceManager, - ISerializationProvider serializationProvider, - ILogger> logger) - : base(serializationProvider, logger) - { - if (namespaceManager == null) - throw new ArgumentNullException(nameof(namespaceManager)); - - _client = new AwaitableLazy(async () => - { - await namespaceManager.ProvisionSubscriptionAsync(); - return namespaceManager.CreateSubscriptionClient(); - }); - } - - public void RegisterCallback(Action callback) - { - Task.Run(() => { - lock (_client) - if (CallbackCount == 0) - _client.Value.OnMessage(OnMessage); - - AddCallback(callback); - }); - } - - public override void Dispose(bool disposing) - { - if (_client.IsValueCreated) - _client.Value?.Close(); - } - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/SubscriptionFactory.cs b/src/OpenMessage.Providers.Azure/Management/SubscriptionFactory.cs deleted file mode 100644 index 7fd0d52..0000000 --- a/src/OpenMessage.Providers.Azure/Management/SubscriptionFactory.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.Extensions.Logging; -using OpenMessage.Providers.Azure.Serialization; -using System; - -namespace OpenMessage.Providers.Azure.Management -{ - internal sealed class SubscriptionFactory : ISubscriptionFactory - { - private readonly ILogger> _logger; - private readonly INamespaceManager _namespaceManager; - private readonly ISerializationProvider _serializationProvider; - - public SubscriptionFactory(INamespaceManager namespaceManager, - ISerializationProvider serializationProvider, - ILogger> logger) - { - if (namespaceManager == null) - throw new ArgumentNullException(nameof(namespaceManager)); - - if (serializationProvider == null) - throw new ArgumentNullException(nameof(serializationProvider)); - - if (logger == null) - throw new ArgumentNullException(nameof(logger)); - - _namespaceManager = namespaceManager; - _serializationProvider = serializationProvider; - _logger = logger; - } - - public ISubscriptionClient Create() => new SubscriptionClient(_namespaceManager, _serializationProvider, _logger); - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/TopicClient.cs b/src/OpenMessage.Providers.Azure/Management/TopicClient.cs deleted file mode 100644 index ed60f7f..0000000 --- a/src/OpenMessage.Providers.Azure/Management/TopicClient.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.Extensions.Logging; -using OpenMessage.Providers.Azure.Serialization; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using AzureClient = Microsoft.ServiceBus.Messaging.TopicClient; - -namespace OpenMessage.Providers.Azure.Management -{ - internal sealed class TopicClient : ClientBase, ITopicClient - { - private readonly AwaitableLazy _client; - private readonly IMessageExtension[] _extensions; - - public TopicClient(INamespaceManager namespaceManager, - ISerializationProvider serializationProvider, - ILogger> logger, - IEnumerable> extensions) - : base(serializationProvider, logger) - { - if (namespaceManager == null) - throw new ArgumentNullException(nameof(namespaceManager)); - - if (extensions == null) - throw new ArgumentNullException(nameof(extensions)); - - _extensions = extensions.ToArray(); - _client = new AwaitableLazy(async () => - { - await namespaceManager.ProvisionTopicAsync(); - return namespaceManager.CreateTopicClient(); - }); - } - - public async Task SendAsync(T entity, TimeSpan scheduleIn) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - if (scheduleIn < TimeSpan.Zero) - throw new ArgumentException("You cannot schedule a message to arrive in the past; time travel isn't a thing yet."); - - var message = Serialize(entity); - if (scheduleIn > TimeSpan.Zero) - message.ScheduledEnqueueTimeUtc = DateTime.UtcNow + scheduleIn; - - Logger.LogInformation($"Sending message of type: {TypeName}"); - try - { - foreach (var extension in _extensions) - extension.Extend(message); - - await (await _client).SendAsync(message); - } - catch (Exception ex) - { - Logger.LogError($"Error sending message of type: {TypeName}; Error: {ex.Message}", ex); - throw; - } - } - - public override void Dispose(bool disposing) - { - if (_client.IsValueCreated) - _client.Value?.Close(); - } - } -} diff --git a/src/OpenMessage.Providers.Azure/Management/TopicFactory.cs b/src/OpenMessage.Providers.Azure/Management/TopicFactory.cs deleted file mode 100644 index 4af9273..0000000 --- a/src/OpenMessage.Providers.Azure/Management/TopicFactory.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.Extensions.Logging; -using OpenMessage.Providers.Azure.Serialization; -using System; -using System.Collections.Generic; - -namespace OpenMessage.Providers.Azure.Management -{ - internal sealed class TopicFactory : ITopicFactory - { - private readonly IEnumerable> _extensions; - private readonly ILogger> _logger; - private readonly INamespaceManager _namespaceManager; - private readonly ISerializationProvider _serializationProvider; - - public TopicFactory(INamespaceManager namespaceManager, - ISerializationProvider serializationProvider, - ILogger> logger, - IEnumerable> extensions) - { - if (namespaceManager == null) - throw new ArgumentNullException(nameof(namespaceManager)); - - if (serializationProvider == null) - throw new ArgumentNullException(nameof(serializationProvider)); - - if (logger == null) - throw new ArgumentNullException(nameof(logger)); - - if (extensions == null) - throw new ArgumentNullException(nameof(extensions)); - - _namespaceManager = namespaceManager; - _serializationProvider = serializationProvider; - _logger = logger; - _extensions = extensions; - } - - public ITopicClient Create() => new TopicClient(_namespaceManager, _serializationProvider, _logger, _extensions); - } -} diff --git a/src/OpenMessage.Providers.Azure/Observables/QueueObservable.cs b/src/OpenMessage.Providers.Azure/Observables/QueueObservable.cs deleted file mode 100644 index 7a22fd7..0000000 --- a/src/OpenMessage.Providers.Azure/Observables/QueueObservable.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.Extensions.Logging; -using OpenMessage.Providers.Azure.Management; -using System; - -namespace OpenMessage.Providers.Azure.Observables -{ - internal sealed class QueueObservable : ManagedObservable - { - private readonly IQueueClient _queueClient; - - public QueueObservable(ILogger> logger, - IQueueFactory queueFactory) : base(logger) - { - if (queueFactory == null) - throw new ArgumentNullException(nameof(queueFactory)); - - _queueClient = queueFactory.Create(); - _queueClient.RegisterCallback(Notify); - } - - public override void Dispose(bool disposing) - { - base.Dispose(disposing); - - _queueClient?.Dispose(); - } - } -} diff --git a/src/OpenMessage.Providers.Azure/Observables/SubscriptionObservable.cs b/src/OpenMessage.Providers.Azure/Observables/SubscriptionObservable.cs deleted file mode 100644 index 2abb6fd..0000000 --- a/src/OpenMessage.Providers.Azure/Observables/SubscriptionObservable.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.Extensions.Logging; -using OpenMessage.Providers.Azure.Management; -using System; - -namespace OpenMessage.Providers.Azure.Observables -{ - internal sealed class SubscriptionObservable : ManagedObservable - { - private readonly ISubscriptionClient _subscriptionClient; - - public SubscriptionObservable(ILogger> logger, - ISubscriptionFactory subscriptionFactory) : base(logger) - { - if (subscriptionFactory == null) - throw new ArgumentNullException(nameof(subscriptionFactory)); - - _subscriptionClient = subscriptionFactory.Create(); - _subscriptionClient.RegisterCallback(Notify); - } - - public override void Dispose(bool disposing) - { - base.Dispose(disposing); - - _subscriptionClient?.Dispose(); - } - } -} diff --git a/src/OpenMessage.Providers.Azure/OpenMessage.Providers.Azure.xproj b/src/OpenMessage.Providers.Azure/OpenMessage.Providers.Azure.xproj deleted file mode 100644 index 48bb579..0000000 --- a/src/OpenMessage.Providers.Azure/OpenMessage.Providers.Azure.xproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - 66f710f3-460a-450b-8b07-9cd1476e5cf3 - OpenMessage.Providers.Azure - .\obj - .\bin\ - v4.5.2 - - - - 2.0 - - - diff --git a/src/OpenMessage.Providers.Azure/Properties/AssemblyInfo.cs b/src/OpenMessage.Providers.Azure/Properties/AssemblyInfo.cs deleted file mode 100644 index 51a9330..0000000 --- a/src/OpenMessage.Providers.Azure/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("OpenMessage.Providers.Azure")] -[assembly: AssemblyTrademark("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("66f710f3-460a-450b-8b07-9cd1476e5cf3")] -[assembly: InternalsVisibleTo("OpenMessage.Providers.Azure.Tests")] -[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/OpenMessage.Providers.Azure/Serialization/ISerializationProvider.cs b/src/OpenMessage.Providers.Azure/Serialization/ISerializationProvider.cs deleted file mode 100644 index 835d946..0000000 --- a/src/OpenMessage.Providers.Azure/Serialization/ISerializationProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.ServiceBus.Messaging; -using System.IO; - -namespace OpenMessage.Providers.Azure.Serialization -{ - internal interface ISerializationProvider - { - BrokeredMessage Serialize(T entity); - T Deserialize(BrokeredMessage entity); - } -} diff --git a/src/OpenMessage.Providers.Azure/Serialization/SerializationProvider.cs b/src/OpenMessage.Providers.Azure/Serialization/SerializationProvider.cs deleted file mode 100644 index bd7b1d3..0000000 --- a/src/OpenMessage.Providers.Azure/Serialization/SerializationProvider.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.ServiceBus.Messaging; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace OpenMessage.Providers.Azure.Serialization -{ - internal sealed class SerializationProvider : ISerializationProvider - { - private readonly ISerializer _defaultSerializer; - private readonly ISerializer[] _providers; - - public SerializationProvider(IEnumerable providers, - ISerializer defaultSerializer) - { - if (providers == null) - throw new ArgumentNullException(nameof(providers)); - - if (defaultSerializer == null) - throw new ArgumentNullException(nameof(defaultSerializer)); - - _providers = providers.ToArray(); - _defaultSerializer = defaultSerializer; - } - - public T Deserialize(BrokeredMessage entity) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - if (entity.ContentType == null) - throw new ArgumentException($"No content type has been set on the brokered message. Unable to source the correct deserializer. Message id: {entity.MessageId}"); - - var deserializer = _providers.FirstOrDefault(provider => provider.TypeName.Equals(entity.ContentType, StringComparison.OrdinalIgnoreCase)); - if (deserializer == null) - throw new Exception($"No deserializer found that is capable of deserializing the type '{entity.ContentType}'. Message id: '{entity.MessageId}'"); - - using(var messageBody = entity.GetBody()) - return deserializer.Deserialize(messageBody); - } - - public BrokeredMessage Serialize(T entity) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - var messageStream = _defaultSerializer.Serialize(entity); - - return new BrokeredMessage(messageStream) - { - ContentType = _defaultSerializer.TypeName - }; - } - } -} diff --git a/src/OpenMessage.Providers.Azure/ServiceExtensions.cs b/src/OpenMessage.Providers.Azure/ServiceExtensions.cs deleted file mode 100644 index 1776e58..0000000 --- a/src/OpenMessage.Providers.Azure/ServiceExtensions.cs +++ /dev/null @@ -1,112 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; -using OpenMessage.Providers.Azure.Configuration; -using OpenMessage.Providers.Azure.Conventions; -using OpenMessage.Providers.Azure.Dispatchers; -using OpenMessage.Providers.Azure.Management; -using OpenMessage.Providers.Azure.Observables; -using OpenMessage.Providers.Azure.Serialization; -using System; -using System.Linq; - -namespace OpenMessage -{ - public static class ServiceExtensions - { - public static IServiceCollection AddOpenMessage(this IServiceCollection services) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - services.TryAddScoped(); - services.TryAddScoped(); - services.TryAddScoped(); - - return services.AddOptions().AddScoped(); - } - - public static IServiceCollection AddQueueObservable(this IServiceCollection services) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - services.AddBroker(); - - return services.AddQueue().AddScoped, QueueObservable>(); - } - public static IServiceCollection AddQueueObservable(this IServiceCollection services, Action callback) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - if (callback == null) - throw new ArgumentNullException(nameof(callback)); - - return services.AddQueueObservable().AddObserver(callback); - } - - public static IServiceCollection AddSubscriptionObservable(this IServiceCollection services) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - services.AddBroker(); - - return services.AddSubscription().AddScoped, SubscriptionObservable>(); - } - public static IServiceCollection AddSubscriptionObservable(this IServiceCollection services, Action callback) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - if (callback == null) - throw new ArgumentNullException(nameof(callback)); - - return services.AddSubscriptionObservable().AddObserver(callback); - } - - public static IServiceCollection AddQueueDispatcher(this IServiceCollection services) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - return services.AddQueue().AddScoped, QueueDispatcher>(); - } - - public static IServiceCollection AddTopicDispatcher(this IServiceCollection services) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - return services.AddTopic().AddScoped, TopicDispatcher>(); - } - - private static IServiceCollection AddQueue(this IServiceCollection services) - { - return services.AddBaseServices().AddScoped, QueueFactory>(); - } - private static IServiceCollection AddTopic(this IServiceCollection services) - { - return services.AddBaseServices().AddScoped, TopicFactory>(); - } - private static IServiceCollection AddSubscription(this IServiceCollection services) - { - return services.AddBaseServices().AddTopic().AddScoped, SubscriptionFactory>(); - } - private static IServiceCollection AddBroker(this IServiceCollection services) - { - if (services.Any(service => service.ServiceType == typeof(IBroker) && service.ServiceType == typeof(MessageBroker))) - return services; - - return services.AddScoped>(); - } - private static IServiceCollection AddBaseServices(this IServiceCollection services) - { - services.TryAddScoped>, OpenMessageAzureProviderOptionsConfigurator>(); - services.TryAddScoped, NamespaceManager>(); - - return services; - } - } -} diff --git a/src/OpenMessage.Providers.Azure/TypeNameExtensions.cs b/src/OpenMessage.Providers.Azure/TypeNameExtensions.cs deleted file mode 100644 index 50024ff..0000000 --- a/src/OpenMessage.Providers.Azure/TypeNameExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Linq; - -namespace OpenMessage.Providers.Azure -{ - internal static class TypeNameExtensions - { - internal static string GetFriendlyName(this Type type) - { - if (type.IsGenericType) - return $"{type.Name.Remove(type.Name.IndexOf('`'))}<{string.Join(",", type.GetGenericArguments().Select(t => t.GetFriendlyName()))}>"; - - return type.Name; - } - - internal static string AsAzureSafeString(this string str) - { - var tempStr = str.Replace('<', '_').Replace('>', '_'); - - if (tempStr.EndsWith("_")) - return tempStr.Substring(0, tempStr.Length - 1); - - return tempStr; - } - } -} diff --git a/src/OpenMessage.Providers.Azure/project.json b/src/OpenMessage.Providers.Azure/project.json deleted file mode 100644 index 1c8c776..0000000 --- a/src/OpenMessage.Providers.Azure/project.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "0.1.1", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "1.0.0", - "Microsoft.Extensions.Logging.Abstractions": "1.0.0", - "Microsoft.Extensions.Options": "1.0.0", - "OpenMessage": { - "target": "project" - }, - "WindowsAzure.ServiceBus": "3.4.0" - }, - "frameworks": { - "net451": { - "frameworkAssemblies": { - "System.Threading.Tasks": { "type": "build" }, - "System.Linq": { "type": "build" } - } - } - } -} diff --git a/src/OpenMessage.RabbitMq/OpenMessage.RabbitMq.csproj b/src/OpenMessage.RabbitMq/OpenMessage.RabbitMq.csproj new file mode 100644 index 0000000..5fa49c4 --- /dev/null +++ b/src/OpenMessage.RabbitMq/OpenMessage.RabbitMq.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + false + + + + + + + diff --git a/src/OpenMessage.Redis/OpenMessage.Redis.csproj b/src/OpenMessage.Redis/OpenMessage.Redis.csproj new file mode 100644 index 0000000..89e813f --- /dev/null +++ b/src/OpenMessage.Redis/OpenMessage.Redis.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + false + + + + + + + diff --git a/src/OpenMessage.Serializer.Hyperion/HyperionSerializer.cs b/src/OpenMessage.Serializer.Hyperion/HyperionSerializer.cs new file mode 100644 index 0000000..d799924 --- /dev/null +++ b/src/OpenMessage.Serializer.Hyperion/HyperionSerializer.cs @@ -0,0 +1,57 @@ +using Hyperion; +using OpenMessage.Serialization; +using System; +using System.Collections.Generic; +using System.IO; + +namespace OpenMessage.Serializer.Hyperion +{ + // TODO :: Expose settings via options + + internal sealed class HyperionSerializer : ISerializer, IDeserializer + { + private static readonly string _contentType = "binary/hyperion"; + private static readonly global::Hyperion.Serializer _serialiser = new global::Hyperion.Serializer(new SerializerOptions(preserveObjectReferences: true)); + + public string ContentType { get; } = _contentType; + public IEnumerable SupportedContentTypes { get; } = new[] {_contentType}; + + public byte[] AsBytes(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + using var ms = new MemoryStream(); + _serialiser.Serialize(entity, ms); + + return ms.ToArray(); + } + + public string AsString(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return Convert.ToBase64String(AsBytes(entity)); + } + + public T From(string data, Type messageType) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + return From(Convert.FromBase64String(data), messageType); + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + using var ms = new MemoryStream(data); + + // TODO : work out how to make this work with Type + return _serialiser.Deserialize(ms); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.Hyperion/HyperionSerializerServiceExtensions.cs b/src/OpenMessage.Serializer.Hyperion/HyperionSerializerServiceExtensions.cs new file mode 100644 index 0000000..1edeb3c --- /dev/null +++ b/src/OpenMessage.Serializer.Hyperion/HyperionSerializerServiceExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.Hyperion +{ + /// + /// Hyperion Service Extensions + /// + public static class HyperionSerializerServiceExtensions + { + /// + /// Adds the hyperion serializer & deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureHyperion(this IMessagingBuilder messagingBuilder) => messagingBuilder.ConfigureHyperionDeserializer() + .ConfigureHyperionSerializer(); + + /// + /// Adds the hyperion deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureHyperionDeserializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + + /// + /// Adds the hyperion serializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureHyperionSerializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.Hyperion/OpenMessage.Serializer.Hyperion.csproj b/src/OpenMessage.Serializer.Hyperion/OpenMessage.Serializer.Hyperion.csproj new file mode 100644 index 0000000..6798880 --- /dev/null +++ b/src/OpenMessage.Serializer.Hyperion/OpenMessage.Serializer.Hyperion.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + Hyperion serializer for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Serializer.Jil/JilSerializer.cs b/src/OpenMessage.Serializer.Jil/JilSerializer.cs index b369aea..fbbbd0c 100644 --- a/src/OpenMessage.Serializer.Jil/JilSerializer.cs +++ b/src/OpenMessage.Serializer.Jil/JilSerializer.cs @@ -1,40 +1,48 @@ -using Jil; using System; -using System.IO; +using Jil; +using OpenMessage.Serialization; +using System.Collections.Generic; using System.Text; -using JilOptions = Jil.Options; namespace OpenMessage.Serializer.Jil { - public class JilSerializer : ISerializer + internal sealed class JilSerializer : ISerializer, IDeserializer { - private readonly JilOptions _settings; + private static readonly string _contentType = "application/json"; - public string TypeName => "application/json"; + public string ContentType { get; } = _contentType; + public IEnumerable SupportedContentTypes { get; } = new[] {_contentType}; - public JilSerializer(JilOptions options) + public byte[] AsBytes(T entity) { - if (options == null) - throw new ArgumentNullException(nameof(options)); + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); - _settings = options; + return Encoding.UTF8.GetBytes(JSON.Serialize(entity)); } - public T Deserialize(Stream entity) + public string AsString(T entity) { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); - using (var streamReader = new StreamReader(entity)) - return JSON.Deserialize(streamReader.ReadToEnd(), _settings); + return JSON.Serialize(entity); } - public Stream Serialize(T entity) + public T From(string data, Type messageType) { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); - return new MemoryStream(Encoding.UTF8.GetBytes(JSON.Serialize(entity, _settings))); + return (T)JSON.Deserialize(data, messageType); + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + return (T)JSON.Deserialize(Encoding.UTF8.GetString(data), messageType); } } -} +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.Jil/JilServiceExtensions.cs b/src/OpenMessage.Serializer.Jil/JilServiceExtensions.cs new file mode 100644 index 0000000..17b065a --- /dev/null +++ b/src/OpenMessage.Serializer.Jil/JilServiceExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.Jil +{ + /// + /// Jil Service Extensions + /// + public static class JilSerializerServiceExtensions + { + /// + /// Adds the Jil serializer & deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureJil(this IMessagingBuilder messagingBuilder) => messagingBuilder.ConfigureJilDeserializer() + .ConfigureJilSerializer(); + + /// + /// Adds the Jil deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureJilDeserializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + + /// + /// Adds the Jil serializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureJilSerializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.Jil/OpenMessage.Serializer.Jil.csproj b/src/OpenMessage.Serializer.Jil/OpenMessage.Serializer.Jil.csproj new file mode 100644 index 0000000..7a82437 --- /dev/null +++ b/src/OpenMessage.Serializer.Jil/OpenMessage.Serializer.Jil.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + Jil (Json) serializer for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Serializer.Jil/OpenMessage.Serializer.Jil.xproj b/src/OpenMessage.Serializer.Jil/OpenMessage.Serializer.Jil.xproj deleted file mode 100644 index 66f1f88..0000000 --- a/src/OpenMessage.Serializer.Jil/OpenMessage.Serializer.Jil.xproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - e70c427c-7285-48ac-a13b-d18519463786 - OpenMessage.Serializer.Jil - .\obj - .\bin\ - v4.5.2 - - - - 2.0 - - - diff --git a/src/OpenMessage.Serializer.Jil/Properties/AssemblyInfo.cs b/src/OpenMessage.Serializer.Jil/Properties/AssemblyInfo.cs deleted file mode 100644 index 96b69fd..0000000 --- a/src/OpenMessage.Serializer.Jil/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("OpenMessage.Serializer.Jil")] -[assembly: AssemblyTrademark("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("e70c427c-7285-48ac-a13b-d18519463786")] diff --git a/src/OpenMessage.Serializer.Jil/ServiceExtensions.cs b/src/OpenMessage.Serializer.Jil/ServiceExtensions.cs deleted file mode 100644 index ef2f4c6..0000000 --- a/src/OpenMessage.Serializer.Jil/ServiceExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Jil; -using Microsoft.Extensions.DependencyInjection; -using OpenMessage.Serializer.Jil; -using System; - -namespace OpenMessage -{ - public static class ServiceExtensions - { - /// - /// Adds a JIL serializer to OpenMessage. - /// - public static IServiceCollection AddJilSerializer(this IServiceCollection services, Options options = null) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - return services.AddTransient().AddSingleton(options ?? new Options()); - } - } -} diff --git a/src/OpenMessage.Serializer.Jil/project.json b/src/OpenMessage.Serializer.Jil/project.json deleted file mode 100644 index 2db49a3..0000000 --- a/src/OpenMessage.Serializer.Jil/project.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": "0.0.2", - "title": "OpenMessage.Serializer.Jil", - "authors": [ "Im5tu", "Stuart Blackler" ], - "description": "A JIL serializer for OpenMessage", - "packOptions": { - "owners": [ "Im5tu" ], - "tags": [ "OpenMessage", "Messaging", "ServiceBus", "aspnetcore", "netstandard", "JIL" ], - "projectUrl": "https://github.com/Im5tu/OpenMessage", - "requireLicenseAcceptance": false, - "repository": { - "type": "git", - "url": "https://github.com/Im5tu/OpenMessage.git" - } - }, - "dependencies": { - "Jil": "2.14.5", - "Microsoft.Extensions.Options": "1.0.0", - "OpenMessage": { - "target": "project" - } - }, - - "frameworks": { - "net451": {} - } -} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.JsonDotNet/Constants.cs b/src/OpenMessage.Serializer.JsonDotNet/Constants.cs new file mode 100644 index 0000000..742cf5d --- /dev/null +++ b/src/OpenMessage.Serializer.JsonDotNet/Constants.cs @@ -0,0 +1,7 @@ +namespace OpenMessage.Serializer.JsonDotNet +{ + internal static class Constants + { + internal static readonly string ContentType = "application/json"; + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.JsonDotNet/JsonDotNetDeserialiser.cs b/src/OpenMessage.Serializer.JsonDotNet/JsonDotNetDeserialiser.cs new file mode 100644 index 0000000..fbac706 --- /dev/null +++ b/src/OpenMessage.Serializer.JsonDotNet/JsonDotNetDeserialiser.cs @@ -0,0 +1,44 @@ +using System; +using Newtonsoft.Json; +using OpenMessage.Serialization; +using System.Collections.Generic; +using System.Text; +using Microsoft.Extensions.Options; + +namespace OpenMessage.Serializer.JsonDotNet +{ + internal sealed class JsonDotNetDeserializer : IDeserializer + { + private JsonSerializerSettings _settings; + public IEnumerable SupportedContentTypes { get; } = new[] {Constants.ContentType}; + + public JsonDotNetDeserializer(IOptionsMonitor settings) + { + _settings = settings.Get(SerializationConstants.DeserializerSettings); + } + + public T From(string data, Type messageType) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + var response = JsonConvert.DeserializeObject(data, messageType, _settings); + if (response is null) + Throw.Exception("Deserialization returned a null response"); + + return (T)response; + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + var response = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(data), messageType, _settings); + if (response is null) + Throw.Exception("Deserialization returned a null response"); + + return (T)response; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.JsonDotNet/JsonDotNetSerialiser.cs b/src/OpenMessage.Serializer.JsonDotNet/JsonDotNetSerialiser.cs new file mode 100644 index 0000000..057c26e --- /dev/null +++ b/src/OpenMessage.Serializer.JsonDotNet/JsonDotNetSerialiser.cs @@ -0,0 +1,34 @@ +using System.Text; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.JsonDotNet +{ + internal sealed class JsonDotNetSerializer : ISerializer + { + private JsonSerializerSettings _settings; + public string ContentType => Constants.ContentType; + + public JsonDotNetSerializer(IOptionsMonitor settings) + { + _settings = settings.Get(SerializationConstants.SerializerSettings); + } + + public byte[] AsBytes(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(entity, _settings)); + } + + public string AsString(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return JsonConvert.SerializeObject(entity, _settings); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.JsonDotNet/JsonDotNetServiceExtensions.cs b/src/OpenMessage.Serializer.JsonDotNet/JsonDotNetServiceExtensions.cs new file mode 100644 index 0000000..3159780 --- /dev/null +++ b/src/OpenMessage.Serializer.JsonDotNet/JsonDotNetServiceExtensions.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.JsonDotNet +{ + /// + /// Service Extensions + /// + public static class JsonDotNetServiceExtensions + { + /// + /// Adds both the JsonDotNet serializer & deserializer + /// + /// The service collection to modify + /// Configure the serializer options + /// Configure the deserializer options + /// The modified service collection + public static IMessagingBuilder ConfigureJsonDotNet(this IMessagingBuilder builder, Action? serializerConfigurator = null, Action? deserializerConfigurator = null) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + return builder.ConfigureJsonDotNetDeserializer(deserializerConfigurator) + .ConfigureJsonDotNetSerializer(serializerConfigurator); + } + + /// + /// Sets the specified settings to a snake_case_naming_strategy + /// + public static JsonSerializerSettings WithSnakeCaseNamingStrategy(this JsonSerializerSettings settings) + { + settings.ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + }; + return settings; + } + + /// + /// Adds the JsonDotNet deserializer only. + /// + /// The service collection to modify + /// Configure the deserializer options + /// The modified service collection + public static IMessagingBuilder ConfigureJsonDotNetDeserializer(this IMessagingBuilder builder, Action? configurator = null) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + builder.Services.AddSerialization() + .AddDeserializer() + .Configure(SerializationConstants.DeserializerSettings, settings => + { + settings.NullValueHandling = NullValueHandling.Ignore; + configurator?.Invoke(settings); + }); + + return builder; + } + + /// + /// Adds the JsonDotNet serializer only. + /// + /// The service collection to modify + /// Configure the serializer options + /// The modified service collection + public static IMessagingBuilder ConfigureJsonDotNetSerializer(this IMessagingBuilder builder, Action? configurator = null) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + builder.Services.AddSerialization() + .AddSerializer() + .Configure(SerializationConstants.SerializerSettings, settings => + { + settings.NullValueHandling = NullValueHandling.Ignore; + configurator?.Invoke(settings); + });; + + return builder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.JsonDotNet/OpenMessage.Serializer.JsonDotNet.csproj b/src/OpenMessage.Serializer.JsonDotNet/OpenMessage.Serializer.JsonDotNet.csproj new file mode 100644 index 0000000..49b8eb4 --- /dev/null +++ b/src/OpenMessage.Serializer.JsonDotNet/OpenMessage.Serializer.JsonDotNet.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + Json.Net serializer for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Serializer.JsonNet/JsonNetSerializer.cs b/src/OpenMessage.Serializer.JsonNet/JsonNetSerializer.cs deleted file mode 100644 index 374df4e..0000000 --- a/src/OpenMessage.Serializer.JsonNet/JsonNetSerializer.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.Extensions.Options; -using Newtonsoft.Json; -using System; -using System.IO; -using System.Text; - -namespace OpenMessage.Serializer.JsonNet -{ - internal sealed class JsonNetSerializer : ISerializer - { - private JsonSerializerSettings _settings; - - public string TypeName => "application/json"; - - public JsonNetSerializer(IOptions options) - { - if (options == null) - throw new ArgumentNullException(nameof(options)); - - _settings = options.Value; - } - - public T Deserialize(Stream entity) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - using (var streamReader = new StreamReader(entity)) - return JsonConvert.DeserializeObject(streamReader.ReadToEnd(), _settings); - } - - public Stream Serialize(T entity) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - return new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(entity, _settings))); - } - } -} diff --git a/src/OpenMessage.Serializer.JsonNet/OpenMessage.Serializer.JsonNet.xproj b/src/OpenMessage.Serializer.JsonNet/OpenMessage.Serializer.JsonNet.xproj deleted file mode 100644 index 5d05df4..0000000 --- a/src/OpenMessage.Serializer.JsonNet/OpenMessage.Serializer.JsonNet.xproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - 3ab720e9-27b4-4a28-9c1f-b89ea1a4257f - OpenMessage.Serializer.JsonNet - .\obj - .\bin\ - v4.5.2 - - - - 2.0 - - - diff --git a/src/OpenMessage.Serializer.JsonNet/Properties/AssemblyInfo.cs b/src/OpenMessage.Serializer.JsonNet/Properties/AssemblyInfo.cs deleted file mode 100644 index dc9c44b..0000000 --- a/src/OpenMessage.Serializer.JsonNet/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("OpenMessage.Serializer.JsonNet")] -[assembly: AssemblyTrademark("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("3ab720e9-27b4-4a28-9c1f-b89ea1a4257f")] diff --git a/src/OpenMessage.Serializer.JsonNet/ServiceExtensions.cs b/src/OpenMessage.Serializer.JsonNet/ServiceExtensions.cs deleted file mode 100644 index a7f81fb..0000000 --- a/src/OpenMessage.Serializer.JsonNet/ServiceExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using OpenMessage.Serializer.JsonNet; -using System; - -namespace OpenMessage -{ - public static class ServiceExtensions - { - /// - /// Adds a Newtonsoft.Json serializer to OpenMessage. - /// - public static IServiceCollection AddJsonNetSerializer(this IServiceCollection services) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - return services.AddTransient(); - } - } -} diff --git a/src/OpenMessage.Serializer.JsonNet/project.json b/src/OpenMessage.Serializer.JsonNet/project.json deleted file mode 100644 index e67a567..0000000 --- a/src/OpenMessage.Serializer.JsonNet/project.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "version": "0.0.1", - "title": "OpenMessage.Serializer.JsonNet", - "authors": [ "Im5tu", "Stuart Blackler" ], - "description": "A Json.Net serializer for OpenMessage", - "packOptions": { - "owners": [ "Im5tu" ], - "tags": [ "OpenMessage", "Messaging", "ServiceBus", "aspnetcore", "netstandard", "Json.Net" ], - "projectUrl": "https://github.com/Im5tu/OpenMessage", - "requireLicenseAcceptance": false, - "repository": { - "type": "git", - "url": "https://github.com/Im5tu/OpenMessage.git" - } - }, - "dependencies": { - "Microsoft.Extensions.Options": "1.0.0", - "Newtonsoft.Json": "9.0.1", - "OpenMessage": { - "target": "project" - } - }, - - "frameworks": { - "netstandard1.5": { - "imports": "dnxcore50", - "dependencies": { - "NETStandard.Library": "1.6.0" - } - }, - "net451": {} - } -} diff --git a/src/OpenMessage.Serializer.MessagePack/MessagePackSerializer.cs b/src/OpenMessage.Serializer.MessagePack/MessagePackSerializer.cs new file mode 100644 index 0000000..7420a4e --- /dev/null +++ b/src/OpenMessage.Serializer.MessagePack/MessagePackSerializer.cs @@ -0,0 +1,48 @@ +using OpenMessage.Serialization; +using System; +using System.Collections.Generic; +using serializer = MessagePack.MessagePackSerializer; + +namespace OpenMessage.Serializer.MessagePack +{ + internal sealed class MessagePackSerializer : ISerializer, IDeserializer + { + private static readonly string _contentType = "binary/messagepack"; + + public string ContentType { get; } = _contentType; + + public IEnumerable SupportedContentTypes { get; } = new[] {_contentType}; + + public byte[] AsBytes(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return serializer.Serialize(entity); + } + + public string AsString(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return Convert.ToBase64String(AsBytes(entity)); + } + + public T From(string data, Type messageType) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + return From(Convert.FromBase64String(data), messageType); + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + return (T)serializer.Deserialize(messageType, data); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.MessagePack/MessagePackServiceExtensions.cs b/src/OpenMessage.Serializer.MessagePack/MessagePackServiceExtensions.cs new file mode 100644 index 0000000..1a4c14c --- /dev/null +++ b/src/OpenMessage.Serializer.MessagePack/MessagePackServiceExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.MessagePack +{ + /// + /// MessagePack Service Extensions + /// + public static class MessagePackSerializerServiceExtensions + { + /// + /// Adds the MessagePack serializer & deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureMessagePack(this IMessagingBuilder messagingBuilder) => messagingBuilder.ConfigureMessagePackDeserializer() + .ConfigureMessagePackSerializer(); + + /// + /// Adds the MessagePack deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureMessagePackDeserializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + + /// + /// Adds the MessagePack serializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureMessagePackSerializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.MessagePack/OpenMessage.Serializer.MessagePack.csproj b/src/OpenMessage.Serializer.MessagePack/OpenMessage.Serializer.MessagePack.csproj new file mode 100644 index 0000000..daa2068 --- /dev/null +++ b/src/OpenMessage.Serializer.MessagePack/OpenMessage.Serializer.MessagePack.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + MessagePack serializer for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Serializer.MsgPackCli/MsgPackSerializer.cs b/src/OpenMessage.Serializer.MsgPackCli/MsgPackSerializer.cs new file mode 100644 index 0000000..2e6a03c --- /dev/null +++ b/src/OpenMessage.Serializer.MsgPackCli/MsgPackSerializer.cs @@ -0,0 +1,52 @@ +using MsgPack.Serialization; +using OpenMessage.Serialization; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace OpenMessage.Serializer.MsgPackCli +{ + internal sealed class MsgPackSerializer : ISerializer, IDeserializer + { + private static readonly string _contentType = "binary/msgpack"; + private readonly ConcurrentDictionary _serialisers = new ConcurrentDictionary(); + + public string ContentType { get; } = _contentType; + + public IEnumerable SupportedContentTypes { get; } = new[] {_contentType}; + + public byte[] AsBytes(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return _serialisers.GetOrAdd(typeof(T), key => MessagePackSerializer.Get(key)) + .PackSingleObject(entity); + } + + public string AsString(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return Convert.ToBase64String(AsBytes(entity)); + } + + public T From(string data, Type messageType) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + return From(Convert.FromBase64String(data), messageType); + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + return (T) _serialisers.GetOrAdd(messageType, key => MessagePackSerializer.Get(key)) + .UnpackSingleObject(data); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.MsgPackCli/MsgPackServiceExtensions.cs b/src/OpenMessage.Serializer.MsgPackCli/MsgPackServiceExtensions.cs new file mode 100644 index 0000000..42159d5 --- /dev/null +++ b/src/OpenMessage.Serializer.MsgPackCli/MsgPackServiceExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.MsgPackCli +{ + /// + /// MsgPack Service Extensions + /// + public static class MsgPackSerializerServiceExtensions + { + /// + /// Adds the MsgPack serializer & deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureMsgPack(this IMessagingBuilder messagingBuilder) => messagingBuilder.ConfigureMsgPackDeserializer() + .ConfigureMsgPackSerializer(); + + /// + /// Adds the MsgPack deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureMsgPackDeserializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + + /// + /// Adds the MsgPack serializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureMsgPackSerializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.MsgPackCli/OpenMessage.Serializer.MsgPackCli.csproj b/src/OpenMessage.Serializer.MsgPackCli/OpenMessage.Serializer.MsgPackCli.csproj new file mode 100644 index 0000000..4229fe6 --- /dev/null +++ b/src/OpenMessage.Serializer.MsgPackCli/OpenMessage.Serializer.MsgPackCli.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + MsgPack serializer for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Serializer.Protobuf/OpenMessage.Serializer.Protobuf.csproj b/src/OpenMessage.Serializer.Protobuf/OpenMessage.Serializer.Protobuf.csproj new file mode 100644 index 0000000..786099d --- /dev/null +++ b/src/OpenMessage.Serializer.Protobuf/OpenMessage.Serializer.Protobuf.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + Protobuf serializer for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Serializer.Protobuf/ProtobufSerializer.cs b/src/OpenMessage.Serializer.Protobuf/ProtobufSerializer.cs new file mode 100644 index 0000000..08d0cfe --- /dev/null +++ b/src/OpenMessage.Serializer.Protobuf/ProtobufSerializer.cs @@ -0,0 +1,53 @@ +using OpenMessage.Serialization; +using System; +using System.Collections.Generic; +using System.IO; + +namespace OpenMessage.Serializer.Protobuf +{ + internal sealed class ProtobufSerializer : ISerializer, IDeserializer + { + private static readonly string _contentType = "binary/protobuf"; + + public string ContentType { get; } = _contentType; + + public IEnumerable SupportedContentTypes { get; } = new[] {_contentType}; + + public byte[] AsBytes(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + using var ms = new MemoryStream(); + ProtoBuf.Serializer.Serialize(ms, entity); + + return ms.ToArray(); + } + + public string AsString(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return Convert.ToBase64String(AsBytes(entity)); + } + + public T From(string data, Type messageType) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + return From(Convert.FromBase64String(data), messageType); + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + using var ms = new MemoryStream(data); + + return (T)ProtoBuf.Serializer.Deserialize(messageType, ms); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.Protobuf/ProtobufServiceExtensions.cs b/src/OpenMessage.Serializer.Protobuf/ProtobufServiceExtensions.cs new file mode 100644 index 0000000..9361142 --- /dev/null +++ b/src/OpenMessage.Serializer.Protobuf/ProtobufServiceExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.Protobuf +{ + /// + /// Protobuf Service Extensions + /// + public static class ProtobufSerializerServiceExtensions + { + /// + /// Adds the Protobuf serializer & deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureProtobuf(this IMessagingBuilder messagingBuilder) => messagingBuilder.ConfigureProtobufDeserializer() + .ConfigureProtobufSerializer(); + + /// + /// Adds the Protobuf deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureProtobufDeserializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + + /// + /// Adds the Protobuf serializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureProtobufSerializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.ProtobufNet/OpenMessage.Serializer.ProtobufNet.xproj b/src/OpenMessage.Serializer.ProtobufNet/OpenMessage.Serializer.ProtobufNet.xproj deleted file mode 100644 index 9ea6797..0000000 --- a/src/OpenMessage.Serializer.ProtobufNet/OpenMessage.Serializer.ProtobufNet.xproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - 987e4e03-ff13-496a-9b72-bc2adf3a35d2 - OpenMessage.Serializer.ProtobufNet - .\obj - .\bin\ - v4.5.2 - - - - 2.0 - - - diff --git a/src/OpenMessage.Serializer.ProtobufNet/Properties/AssemblyInfo.cs b/src/OpenMessage.Serializer.ProtobufNet/Properties/AssemblyInfo.cs deleted file mode 100644 index bebc5c6..0000000 --- a/src/OpenMessage.Serializer.ProtobufNet/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("OpenMessage.Serializer.ProtobufNet")] -[assembly: AssemblyTrademark("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("987e4e03-ff13-496a-9b72-bc2adf3a35d2")] diff --git a/src/OpenMessage.Serializer.ProtobufNet/ProtoBufOptions.cs b/src/OpenMessage.Serializer.ProtobufNet/ProtoBufOptions.cs deleted file mode 100644 index c1b66f4..0000000 --- a/src/OpenMessage.Serializer.ProtobufNet/ProtoBufOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -using ProtoBuf.Meta; - -namespace OpenMessage.Serializer.ProtobufNet -{ - public class ProtoBufOptions - { - public TypeModel TypeModel { get; set; } = RuntimeTypeModel.Default; - } -} diff --git a/src/OpenMessage.Serializer.ProtobufNet/ProtobufNetSerializer.cs b/src/OpenMessage.Serializer.ProtobufNet/ProtobufNetSerializer.cs deleted file mode 100644 index 81c7386..0000000 --- a/src/OpenMessage.Serializer.ProtobufNet/ProtobufNetSerializer.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.Extensions.Options; -using ProtoBuf.Meta; -using System; -using System.IO; - -namespace OpenMessage.Serializer.ProtobufNet -{ - public class ProtobufNetSerializer : ISerializer - { - private readonly TypeModel _model; - - public string TypeName => "application/protobuf"; - - public ProtobufNetSerializer(IOptions options) - { - if (options == null) - throw new ArgumentNullException(nameof(options)); - - _model = options.Value.TypeModel; - } - - public T Deserialize(Stream entity) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - return (T)_model.Deserialize(entity, null, typeof(T)); - } - - public Stream Serialize(T entity) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - var result = new MemoryStream(); - _model.Serialize(result, entity); - return result; - } - } -} diff --git a/src/OpenMessage.Serializer.ProtobufNet/ServiceExtensions.cs b/src/OpenMessage.Serializer.ProtobufNet/ServiceExtensions.cs deleted file mode 100644 index e17a046..0000000 --- a/src/OpenMessage.Serializer.ProtobufNet/ServiceExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using OpenMessage.Serializer.ProtobufNet; -using System; - -namespace OpenMessage -{ - public static class ServiceExtensions - { - /// - /// Adds a protobuf-net serializer to OpenMessage. - /// - public static IServiceCollection AddProtobufNetSerializer(this IServiceCollection services) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - return services.AddTransient(); - } - } -} diff --git a/src/OpenMessage.Serializer.ProtobufNet/project.json b/src/OpenMessage.Serializer.ProtobufNet/project.json deleted file mode 100644 index 1270e78..0000000 --- a/src/OpenMessage.Serializer.ProtobufNet/project.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "version": "0.0.1", - "title": "OpenMessage.Serializer.ProtobufNet", - "authors": [ "Im5tu", "Stuart Blackler" ], - "description": "A protobuf-net serializer for OpenMessage", - "packOptions": { - "owners": [ "Im5tu" ], - "tags": [ "OpenMessage", "Messaging", "ServiceBus", "aspnetcore", "netstandard", "protobuf-net", "protobuf" ], - "projectUrl": "https://github.com/Im5tu/OpenMessage", - "requireLicenseAcceptance": false, - "repository": { - "type": "git", - "url": "https://github.com/Im5tu/OpenMessage.git" - } - }, - "dependencies": { - "Microsoft.Extensions.Options": "1.0.0", - "protobuf-net": "2.1.0", - "OpenMessage": { - "target": "project" - } - }, - - "frameworks": { - "netstandard1.5": { - "imports": "dnxcore50", - "dependencies": { - "NETStandard.Library": "1.6.0" - } - }, - "net451": {} - } -} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.ServiceStackJson/OpenMessage.Serializer.ServiceStackJson.csproj b/src/OpenMessage.Serializer.ServiceStackJson/OpenMessage.Serializer.ServiceStackJson.csproj new file mode 100644 index 0000000..d8a14be --- /dev/null +++ b/src/OpenMessage.Serializer.ServiceStackJson/OpenMessage.Serializer.ServiceStackJson.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + ServiceStackJson serializer for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Serializer.ServiceStackJson/ServiceStackSerializer.cs b/src/OpenMessage.Serializer.ServiceStackJson/ServiceStackSerializer.cs new file mode 100644 index 0000000..752eb80 --- /dev/null +++ b/src/OpenMessage.Serializer.ServiceStackJson/ServiceStackSerializer.cs @@ -0,0 +1,52 @@ +using System; +using OpenMessage.Serialization; +using ServiceStack.Text; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace OpenMessage.Serializer.ServiceStackJson +{ + internal sealed class ServiceStackSerializer : ISerializer, IDeserializer + { + private static readonly string _contentType = "application/json"; + + public string ContentType { get; } = _contentType; + + public IEnumerable SupportedContentTypes { get; } = new[] {_contentType}; + + public byte[] AsBytes(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return Encoding.UTF8.GetBytes(JsonSerializer.SerializeToString(entity)); + } + + public string AsString(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return JsonSerializer.SerializeToString(entity); + } + + public T From(string data, Type messageType) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + return (T) JsonSerializer.DeserializeFromString(data, messageType); + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + using var ms = new MemoryStream(data); + + return (T) JsonSerializer.DeserializeFromStream(messageType, ms); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.ServiceStackJson/ServiceStackServiceExtensions.cs b/src/OpenMessage.Serializer.ServiceStackJson/ServiceStackServiceExtensions.cs new file mode 100644 index 0000000..ac451a7 --- /dev/null +++ b/src/OpenMessage.Serializer.ServiceStackJson/ServiceStackServiceExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.ServiceStackJson +{ + /// + /// ServiceStackJson Service Extensions + /// + public static class ServiceStackJsonSerializerServiceExtensions + { + /// + /// Adds the ServiceStackJson serializer & deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureServiceStackJson(this IMessagingBuilder messagingBuilder) => messagingBuilder.ConfigureServiceStackJsonDeserializer() + .ConfigureServiceStackJsonSerializer(); + + /// + /// Adds the ServiceStackJson deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureServiceStackJsonDeserializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + + /// + /// Adds the ServiceStackJson serializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureServiceStackJsonSerializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.Utf8Json/OpenMessage.Serializer.Utf8Json.csproj b/src/OpenMessage.Serializer.Utf8Json/OpenMessage.Serializer.Utf8Json.csproj new file mode 100644 index 0000000..664e6b5 --- /dev/null +++ b/src/OpenMessage.Serializer.Utf8Json/OpenMessage.Serializer.Utf8Json.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + Utf8Json serializer for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Serializer.Utf8Json/Utf8Serializer.cs b/src/OpenMessage.Serializer.Utf8Json/Utf8Serializer.cs new file mode 100644 index 0000000..cc03ff8 --- /dev/null +++ b/src/OpenMessage.Serializer.Utf8Json/Utf8Serializer.cs @@ -0,0 +1,47 @@ +using System; +using OpenMessage.Serialization; +using System.Collections.Generic; +using Utf8Json; + +namespace OpenMessage.Serializer.Utf8Json +{ + internal sealed class Utf8Serializer : ISerializer, IDeserializer + { + private static readonly string _contentType = "application/json"; + + public string ContentType { get; } = _contentType; + public IEnumerable SupportedContentTypes { get; } = new[] {_contentType}; + + public byte[] AsBytes(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return JsonSerializer.Serialize(entity); + } + + public string AsString(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return JsonSerializer.ToJsonString(entity); + } + + public T From(string data, Type messageType) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + return (T) JsonSerializer.NonGeneric.Deserialize(messageType, data); + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + return (T) JsonSerializer.NonGeneric.Deserialize(messageType, data); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.Utf8Json/Utf8ServiceExtensions.cs b/src/OpenMessage.Serializer.Utf8Json/Utf8ServiceExtensions.cs new file mode 100644 index 0000000..b4e8988 --- /dev/null +++ b/src/OpenMessage.Serializer.Utf8Json/Utf8ServiceExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.Utf8Json +{ + /// + /// Utf8Json Service Extensions + /// + public static class Utf8JsonSerializerServiceExtensions + { + /// + /// Adds the Utf8Json serializer & deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureUtf8Json(this IMessagingBuilder messagingBuilder) => messagingBuilder.ConfigureUtf8JsonDeserializer() + .ConfigureUtf8JsonSerializer(); + + /// + /// Adds the Utf8Json deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureUtf8JsonDeserializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + + /// + /// Adds the Utf8Json serializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureUtf8JsonSerializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.Wire/OpenMessage.Serializer.Wire.csproj b/src/OpenMessage.Serializer.Wire/OpenMessage.Serializer.Wire.csproj new file mode 100644 index 0000000..77b7ccb --- /dev/null +++ b/src/OpenMessage.Serializer.Wire/OpenMessage.Serializer.Wire.csproj @@ -0,0 +1,12 @@ + + + + $(ProjectTargetFrameworks) + Wire serializer for OpenMessage + + + + + + + diff --git a/src/OpenMessage.Serializer.Wire/WireSerializer.cs b/src/OpenMessage.Serializer.Wire/WireSerializer.cs new file mode 100644 index 0000000..fa23ae0 --- /dev/null +++ b/src/OpenMessage.Serializer.Wire/WireSerializer.cs @@ -0,0 +1,54 @@ +using OpenMessage.Serialization; +using System; +using System.Collections.Generic; +using System.IO; + +namespace OpenMessage.Serializer.Wire +{ + internal sealed class WireSerializer : ISerializer, IDeserializer + { + private static readonly string _contentType = "binary/wire"; + private static readonly global::Wire.Serializer _serialiser = new global::Wire.Serializer(); + + public string ContentType { get; } = _contentType; + public IEnumerable SupportedContentTypes { get; } = new[] {_contentType}; + + public byte[] AsBytes(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + using var ms = new MemoryStream(); + _serialiser.Serialize(entity, ms); + + return ms.ToArray(); + } + + public string AsString(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return Convert.ToBase64String(AsBytes(entity)); + } + + public T From(string data, Type messageType) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + return From(Convert.FromBase64String(data), messageType); + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + using var ms = new MemoryStream(data); + + // TODO :: work out how to do this properly with messageType + return _serialiser.Deserialize(ms); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Serializer.Wire/WireServiceExtensions.cs b/src/OpenMessage.Serializer.Wire/WireServiceExtensions.cs new file mode 100644 index 0000000..6fc91eb --- /dev/null +++ b/src/OpenMessage.Serializer.Wire/WireServiceExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenMessage.Serialization; + +namespace OpenMessage.Serializer.Wire +{ + /// + /// Wire Service Extensions + /// + public static class WireSerializerServiceExtensions + { + /// + /// Adds the Wire serializer & deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureWire(this IMessagingBuilder messagingBuilder) => messagingBuilder.ConfigureWireDeserializer() + .ConfigureWireSerializer(); + + /// + /// Adds the Wire deserializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureWireDeserializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + + /// + /// Adds the Wire serializer + /// + /// The host to configure + /// The modified builder + public static IMessagingBuilder ConfigureWireSerializer(this IMessagingBuilder messagingBuilder) + { + messagingBuilder.Services.TryAddSingleton(); + + messagingBuilder.Services.AddSerialization() + .AddSingleton(sp => sp.GetRequiredService()); + + return messagingBuilder; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Testing/Memory/AwaitableMemoryDispatcher.cs b/src/OpenMessage.Testing/Memory/AwaitableMemoryDispatcher.cs new file mode 100644 index 0000000..daf3644 --- /dev/null +++ b/src/OpenMessage.Testing/Memory/AwaitableMemoryDispatcher.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenMessage.Testing.Memory +{ + internal sealed class AwaitableMemoryDispatcher : DispatcherBase + { + private readonly ChannelWriter> _channelWriter; + + public AwaitableMemoryDispatcher(ChannelWriter> channelWriter, ILogger> logger) + : base(logger) => _channelWriter = channelWriter ?? throw new ArgumentNullException(nameof(channelWriter)); + + public override async Task DispatchAsync(Message entity, CancellationToken cancellationToken) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + cancellationToken.ThrowIfCancellationRequested(); + + LogDispatch(entity); + + if (!await _channelWriter.WaitToWriteAsync(cancellationToken)) + Throw.Exception("Cannot write to channel"); + + var awaitableMessage = new AwaitableMessage(entity); + + await _channelWriter.WriteAsync(awaitableMessage, cancellationToken); + + await awaitableMessage; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage.Testing/Memory/AwaitableMessage.cs b/src/OpenMessage.Testing/Memory/AwaitableMessage.cs new file mode 100644 index 0000000..74a45da --- /dev/null +++ b/src/OpenMessage.Testing/Memory/AwaitableMessage.cs @@ -0,0 +1,47 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace OpenMessage.Testing.Memory +{ + /// + /// A that can be awaited as a . The task will complete when the message is acknowledged by the consumer. + /// + internal sealed class AwaitableMessage : Message, ISupportAcknowledgement, ISupportIdentification + { + private readonly Message? _message; + private readonly TaskCompletionSource _messageConsumedTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public AcknowledgementState AcknowledgementState { get; private set; } + public string Id { get; set; } + + public AwaitableMessage([CallerMemberName] string? id = null) + { + Id = id ?? Guid.NewGuid().ToString("N"); + } + + public AwaitableMessage(Message message, [CallerMemberName]string? id = null) : this(id) + { + _message = message; + Value = message.Value; + } + + public async Task AcknowledgeAsync(bool positivelyAcknowledge = true, Exception? exception = null) + { + if (positivelyAcknowledge) + AcknowledgementState = AcknowledgementState.Acknowledged; + else + AcknowledgementState = AcknowledgementState.NegativelyAcknowledged; + + if (_message is ISupportAcknowledgement ack) + await ack.AcknowledgeAsync(positivelyAcknowledge, exception); + + if (exception == null) + _messageConsumedTaskCompletionSource.TrySetResult(positivelyAcknowledge); + else + _messageConsumedTaskCompletionSource.TrySetException(exception); + } + + public TaskAwaiter GetAwaiter() => _messageConsumedTaskCompletionSource.Task.GetAwaiter(); + } +} \ No newline at end of file diff --git a/src/OpenMessage.Testing/Memory/MemoryProviderExtensions.cs b/src/OpenMessage.Testing/Memory/MemoryProviderExtensions.cs new file mode 100644 index 0000000..ceda03d --- /dev/null +++ b/src/OpenMessage.Testing/Memory/MemoryProviderExtensions.cs @@ -0,0 +1,20 @@ +using OpenMessage; +using OpenMessage.Pipelines.Middleware; +using OpenMessage.Testing.Memory; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for adding an InMemory Dispatcher/Consumer + /// + public static class MemoryProviderExtensions + { + /// + /// Adds an InMemory that will wait for the the message to be consumed before returning. + /// must be added to the pipeline for this function correctly + /// + /// + /// The type add the dispatcher for + public static IServiceCollection AddAwaitableMemoryDispatcher(this IServiceCollection services) => services.AddSingleton, AwaitableMemoryDispatcher>(); + } +} \ No newline at end of file diff --git a/src/OpenMessage.Testing/Memory/readme.md b/src/OpenMessage.Testing/Memory/readme.md new file mode 100644 index 0000000..d282671 --- /dev/null +++ b/src/OpenMessage.Testing/Memory/readme.md @@ -0,0 +1,13 @@ +# OpenMessage.Testing.Memory + +For testing purposes it is sometimes beneficial to wait for messages to be consumed before returning from the dispatcher. + +To do this you need to override the existing `MemoryDispatcher<>`: + +```csharp +services.AddAwaitableMemoryDispatcher(); +``` + +This functionality relies on the `ISupportAcknowledgment.AcknowledgeAsync()` method on the message to be called when the consumer finishes executing. + +`ISupportAcknowledgment.AcknowledgeAsync()` functionality is provided by `AutoAcknowledgeMiddleware<>`, which is present in the default pipeline (`Pipeline.CreateDefaultBuilder<>()`) \ No newline at end of file diff --git a/src/OpenMessage.Testing/OpenMessage.Testing.csproj b/src/OpenMessage.Testing/OpenMessage.Testing.csproj new file mode 100644 index 0000000..ada77ae --- /dev/null +++ b/src/OpenMessage.Testing/OpenMessage.Testing.csproj @@ -0,0 +1,8 @@ + + + + $(ProjectTargetFrameworks) + Helpful testing extensions for OpenMessage + + + diff --git a/src/OpenMessage/AcknowledgementState.cs b/src/OpenMessage/AcknowledgementState.cs new file mode 100644 index 0000000..c66e398 --- /dev/null +++ b/src/OpenMessage/AcknowledgementState.cs @@ -0,0 +1,23 @@ +namespace OpenMessage +{ + /// + /// The current status of the message acknowledgement + /// + public enum AcknowledgementState + { + /// + /// The message has not been acknowledged + /// + NotAcknowledged = 0, + + /// + /// The message has been acknowledged + /// + Acknowledged = 1, + + /// + /// The message has been negatively acknowledged + /// + NegativelyAcknowledged = 2 + } +} \ No newline at end of file diff --git a/src/OpenMessage/ActionObserver.cs b/src/OpenMessage/ActionObserver.cs deleted file mode 100644 index f6e1d12..0000000 --- a/src/OpenMessage/ActionObserver.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; - -namespace OpenMessage -{ - internal sealed class ActionObserver : IObserver - { - private readonly Action _action; - private bool _completed = false; - - internal ActionObserver(Action action) - { - if (action == null) - throw new ArgumentNullException(nameof(action)); - - _action = action; - } - - public void OnCompleted() - { - _completed = true; - } - - public void OnError(Exception error) - { - } - - public void OnNext(T value) - { - if (_completed) - return; - - _action(value); - } - } -} diff --git a/src/OpenMessage/AnonymousObject.cs b/src/OpenMessage/AnonymousObject.cs new file mode 100644 index 0000000..705b87d --- /dev/null +++ b/src/OpenMessage/AnonymousObject.cs @@ -0,0 +1,14 @@ +namespace OpenMessage +{ + /// + /// Holder class for an anonymous object + /// + public static class AnonymousObject + { + /// + /// Represents an object with no properties etc. + /// + public static readonly object Empty = new + { }; + } +} \ No newline at end of file diff --git a/src/OpenMessage/Builders/Builder.cs b/src/OpenMessage/Builders/Builder.cs new file mode 100644 index 0000000..8c74814 --- /dev/null +++ b/src/OpenMessage/Builders/Builder.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System; + +namespace OpenMessage.Builders +{ + /// + /// Defines a common base for a consumer builder. + /// + public abstract class Builder : IBuilder + { + /// + /// The unique ID associated with this consumer. + /// + public string ConsumerId { get; } = Guid.NewGuid() + .ToString("N"); + + /// + /// The underlying host builder. + /// + public IMessagingBuilder HostBuilder { get; } + + /// + /// ctor + /// + public Builder(IMessagingBuilder hostBuilder) => HostBuilder = hostBuilder ?? throw new ArgumentNullException(nameof(hostBuilder)); + + /// + /// Build the consumer + /// + public abstract void Build(); + + /// + /// Use the specified action to configure options for this consumer. + /// + /// The options configuration action + /// Determines whether or not to setup the default options. Default: false + /// The type of options to configure + protected void ConfigureOptions(Action configurator, bool defaultOptions = false) + where T : class + { + if (configurator is null) + return; + + HostBuilder.Services.Configure(ConsumerId, configurator); + } + + /// + /// Use the specified action to configure options for this consumer. + /// + /// The options configuration action + /// Determines whether or not to setup the default options. Default: false + /// The type of options to configure + protected void ConfigureOptions(Action configurator, bool defaultOptions = false) + where T : class + { + if (configurator is null) + return; + + if (!defaultOptions) + HostBuilder.Services.Configure(ConsumerId, options => configurator(HostBuilder.Context, options)); + else + HostBuilder.Services.Configure(options => configurator(HostBuilder.Context, options)); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Builders/IBuilder.cs b/src/OpenMessage/Builders/IBuilder.cs new file mode 100644 index 0000000..b94e898 --- /dev/null +++ b/src/OpenMessage/Builders/IBuilder.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OpenMessage.Builders +{ + /// + /// Defines a common base for a builder. + /// + public interface IBuilder + { + /// + /// The underlying host builder. + /// + IMessagingBuilder HostBuilder { get; } + + /// + /// Build. + /// + void Build(); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Builders/IMessagingBuilder.cs b/src/OpenMessage/Builders/IMessagingBuilder.cs new file mode 100644 index 0000000..27aa648 --- /dev/null +++ b/src/OpenMessage/Builders/IMessagingBuilder.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Helps construct the OpenMessage framework and serves as the base for helpful extension methods + /// + public interface IMessagingBuilder + { + /// + /// The context of the application being constructed + /// + HostBuilderContext Context { get; } + + /// + /// The service collection of the application being constructed + /// + IServiceCollection Services { get; } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Builders/MessagingBuilder.cs b/src/OpenMessage/Builders/MessagingBuilder.cs new file mode 100644 index 0000000..c0d9da9 --- /dev/null +++ b/src/OpenMessage/Builders/MessagingBuilder.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System; + +namespace OpenMessage.Builders +{ + /// + /// Helps construct the OpenMessage framework and serves as the base for helpful extension methods + /// + internal sealed class MessagingBuilder : IMessagingBuilder + { + public HostBuilderContext Context { get; } + public IServiceCollection Services { get; } + + internal MessagingBuilder(HostBuilderContext context, IServiceCollection services) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Services = services ?? throw new ArgumentNullException(nameof(services)); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/ContentTypes.cs b/src/OpenMessage/ContentTypes.cs new file mode 100644 index 0000000..9a061c9 --- /dev/null +++ b/src/OpenMessage/ContentTypes.cs @@ -0,0 +1,13 @@ +namespace OpenMessage +{ + /// + /// A list of common content types + /// + public static class ContentTypes + { + /// + /// application/json + /// + public static readonly string Json = "application/json"; + } +} \ No newline at end of file diff --git a/src/OpenMessage/DispatcherBase.cs b/src/OpenMessage/DispatcherBase.cs new file mode 100644 index 0000000..dba6110 --- /dev/null +++ b/src/OpenMessage/DispatcherBase.cs @@ -0,0 +1,43 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenMessage +{ + /// + /// Base implementation for all dispatchers + /// + /// The type to dispatch + public abstract class DispatcherBase : IDispatcher + { + private readonly ILogger _logger; + private readonly Action _dispatchMessage; + + protected DispatcherBase(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _dispatchMessage = LoggerMessage.Define(LogLevel.Debug, 0, "Dispatching message with id: '{0}'"); + } + + /// + public Task DispatchAsync(T entity, CancellationToken cancellationToken) => DispatchAsync(new Message + { + Value = entity + }, cancellationToken); + + /// + public abstract Task DispatchAsync(Message message, CancellationToken cancellationToken); + + /// + /// Logs the message id that's being dispatched + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected void LogDispatch(Message message) + { + if (message is ISupportIdentification msgId && _logger.IsEnabled(LogLevel.Debug)) + _dispatchMessage(_logger, msgId.Id ?? string.Empty, null); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/DispatcherExtensions.cs b/src/OpenMessage/DispatcherExtensions.cs index d818a84..d648960 100644 --- a/src/OpenMessage/DispatcherExtensions.cs +++ b/src/OpenMessage/DispatcherExtensions.cs @@ -1,16 +1,91 @@ -using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; namespace OpenMessage { + /// + /// Extensions for the dispatcher + /// public static class DispatcherExtensions { - public static Task DispatchAsync(this IDispatcher dispatcher, T entity) + /// + /// Dispatches the specified entity + /// + /// The dispatcher in use + /// The entity to dispatch + /// A task that completes when the message has been acknowledged by the receiver + public static Task DispatchAsync(this IDispatcher dispatcher, T entity) => dispatcher.DispatchAsync(entity, default); + + /// + /// Dispatches the specified entity + /// + /// The dispatcher in use + /// The entity to dispatch + /// The attributes to send along with the message + /// A task that completes when the message has been acknowledged by the receiver + public static Task DispatchAsync(this IDispatcher dispatcher, T entity, IEnumerable> attributes) + { + var message = new ExtendedMessage { Value = entity, Properties = attributes ?? Enumerable.Empty>() }; + return dispatcher.DispatchAsync(message, default); + } + + /// + /// Dispatches the specified entity + /// + /// The dispatcher in use + /// The entity to dispatch + /// The attributes to send along with the message + /// The id to use for the message + /// A task that completes when the message has been acknowledged by the receiver + public static Task DispatchAsync(this IDispatcher dispatcher, T entity, IEnumerable> attributes, string id) { - if (dispatcher == null) - throw new ArgumentNullException(nameof(dispatcher)); + if (string.IsNullOrWhiteSpace(id)) + Throw.ArgumentNullException(nameof(id)); - return dispatcher.DispatchAsync(entity, TimeSpan.Zero); + var message = new ExtendedMessage { Value = entity, Properties = attributes ?? Enumerable.Empty>(), Id = id }; + return dispatcher.DispatchAsync(message, default); } + + /// + /// Dispatches the specified entity + /// + /// The dispatcher in use + /// The entity to dispatch + /// The attributes to send along with the message + /// The id to use for the message + /// A task that completes when the message has been acknowledged by the receiver + public static Task DispatchAsync(this IDispatcher dispatcher, T entity, KeyValuePair attribute, string id) + { + if (string.IsNullOrWhiteSpace(id)) + Throw.ArgumentNullException(nameof(id)); + + var message = new ExtendedMessage { Value = entity, Properties = new [] { attribute }, Id = id }; + return dispatcher.DispatchAsync(message, default); + } + + /// + /// Dispatches the specified entity + /// + /// The dispatcher in use + /// The entity to dispatch + /// The id to use for the message + /// A task that completes when the message has been acknowledged by the receiver + public static Task DispatchAsync(this IDispatcher dispatcher, T entity, string id) + { + if (string.IsNullOrWhiteSpace(id)) + Throw.ArgumentNullException(nameof(id)); + + var message = new ExtendedMessage { Value = entity, Id = id }; + return dispatcher.DispatchAsync(message, default); + } + + /// + /// Dispatches the specified entity + /// + /// The dispatcher in use + /// The message to dispatch + /// A task that completes when the message has been acknowledged by the receiver + public static Task DispatchAsync(this IDispatcher dispatcher, Message message) => dispatcher.DispatchAsync(message, default); } -} +} \ No newline at end of file diff --git a/src/OpenMessage/Disposable.cs b/src/OpenMessage/Disposable.cs deleted file mode 100644 index 81a7b12..0000000 --- a/src/OpenMessage/Disposable.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; - -namespace OpenMessage -{ - internal sealed class Disposable : IDisposable - { - private readonly Action _onDispose; - - internal Disposable(Action onDispose) - { - if (onDispose == null) - throw new ArgumentNullException(nameof(onDispose)); - - _onDispose = onDispose; - } - - public void Dispose() - { - _onDispose(); - } - } -} diff --git a/src/OpenMessage/ExtendedMessage{T}.cs b/src/OpenMessage/ExtendedMessage{T}.cs new file mode 100644 index 0000000..8344569 --- /dev/null +++ b/src/OpenMessage/ExtendedMessage{T}.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenMessage +{ + /// + /// Like a normal message, but supports properties and identification + /// + public class ExtendedMessage : Message, ISupportProperties, ISupportIdentification, ISupportSendDelay + { + /// + public IEnumerable> Properties { get; set; } = Enumerable.Empty>(); + + /// + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + + /// + public TimeSpan SendDelay { get; set; } = TimeSpan.Zero; + + /// + /// Creates a new message + /// + public ExtendedMessage() + { + } + + /// + /// Creates a new message with the specified value + /// + public ExtendedMessage(T value) + { + Value = value; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Handlers/ActionHandler.cs b/src/OpenMessage/Handlers/ActionHandler.cs new file mode 100644 index 0000000..1f10538 --- /dev/null +++ b/src/OpenMessage/Handlers/ActionHandler.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenMessage.Handlers +{ + internal sealed class ActionHandler : Handler + { + private readonly Func, CancellationToken, Task> _action; + + public ActionHandler(Action> action, ILogger> logger) + : this((msg, ct) => Task.Run(() => action(msg), ct), logger) { } + + public ActionHandler(Action, CancellationToken> action, ILogger> logger) + : this((msg, ct) => Task.Run(() => action(msg, ct), ct), logger) { } + + public ActionHandler(Func, Task> action, ILogger> logger) + : this((msg, ct) => action(msg), logger) { } + + public ActionHandler(Func, CancellationToken, Task> action, ILogger> logger) : base(logger) => _action = action; + + protected override Task OnHandleAsync(Message message, CancellationToken cancellationToken) + { + return _action(message, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Handlers/BatchHandler.cs b/src/OpenMessage/Handlers/BatchHandler.cs new file mode 100644 index 0000000..525c2da --- /dev/null +++ b/src/OpenMessage/Handlers/BatchHandler.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Handlers +{ + /// + /// Standard handler that checks for empty collections and cancelled cancellation tokens + /// + /// The type to handle + public abstract class BatchHandler : IBatchHandler + { + /// + /// The logger passed in + /// + protected ILogger Logger { get; } + + /// + /// ctor + /// + /// The logger to use + protected BatchHandler(ILogger logger) => Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + /// + public Task HandleAsync(IReadOnlyCollection> messages, CancellationToken cancellationToken) + { + if (messages is null || messages.Count == 0) + return Task.CompletedTask; + + cancellationToken.ThrowIfCancellationRequested(); + + return OnHandleAsync(messages, cancellationToken); + } + + /// + /// Handles the specified messages + /// + /// The messages to handle + /// The cancellation token used + /// A task that completes when the handle method has completed + protected abstract Task OnHandleAsync(IReadOnlyCollection> messages, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Handlers/Handler.cs b/src/OpenMessage/Handlers/Handler.cs new file mode 100644 index 0000000..be09470 --- /dev/null +++ b/src/OpenMessage/Handlers/Handler.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Handlers +{ + /// + /// Standard handler that checks for null messages and cancelled cancellation tokens + /// + /// The type to handle + public abstract class Handler : IHandler + { + /// + /// The logger passed in + /// + protected ILogger Logger { get; } + + /// + /// ctor + /// + /// The logger to use + protected Handler(ILogger logger) => Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + /// + public Task HandleAsync(Message? message, CancellationToken cancellationToken) + { + if (message is null) + Throw.ArgumentNullException(nameof(message)); + + cancellationToken.ThrowIfCancellationRequested(); + + return OnHandleAsync(message, cancellationToken); + } + + /// + /// Handles the specified message + /// + /// The message to handle + /// The cancellation token used + /// A task that completes when the handle method has completed + protected abstract Task OnHandleAsync(Message message, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Handlers/IBatchHandler.cs b/src/OpenMessage/Handlers/IBatchHandler.cs new file mode 100644 index 0000000..d7856bb --- /dev/null +++ b/src/OpenMessage/Handlers/IBatchHandler.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Handlers +{ + /// + /// Defines a handler for a batch of messages + /// + /// The type contained in a message + public interface IBatchHandler + { + /// + /// Handles the batch of messages + /// + /// Messages to be handled + /// The cancellation token used + /// A task that completes when the handle method has completed + Task HandleAsync(IReadOnlyCollection> messages, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Handlers/IHandler.cs b/src/OpenMessage/Handlers/IHandler.cs new file mode 100644 index 0000000..022a083 --- /dev/null +++ b/src/OpenMessage/Handlers/IHandler.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Handlers +{ + /// + /// Defines a handler for a message + /// + /// The type contained in a message + public interface IHandler + { + /// + /// Handles the specified message + /// + /// The message to handle + /// The cancellation token used + /// A task that completes when the handle method has completed + Task HandleAsync(Message? message, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/OpenMessage/IBroker.cs b/src/OpenMessage/IBroker.cs deleted file mode 100644 index a6ef5ed..0000000 --- a/src/OpenMessage/IBroker.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace OpenMessage -{ - /// - /// Marker interface for IBroker making DI scenarios easier. - /// - public interface IBroker : IDisposable - { - } - - /// - /// Receives messages before handing off to observers. - /// - public interface IBroker : IObservable, IBroker - { - } -} diff --git a/src/OpenMessage/IDispatcher.cs b/src/OpenMessage/IDispatcher.cs index fd1dc8a..59c121d 100644 --- a/src/OpenMessage/IDispatcher.cs +++ b/src/OpenMessage/IDispatcher.cs @@ -1,10 +1,28 @@ -using System; +using System.Threading; using System.Threading.Tasks; namespace OpenMessage { + /// + /// Sends a message to a messaging component + /// + /// The type of message to send public interface IDispatcher { - Task DispatchAsync(T entity, TimeSpan scheduleIn); + /// + /// Dispatches the specified entity + /// + /// The entity to dispatch + /// The cancellation token to use where applicable + /// A task that completes when the message has been acknowledged by the receiver + Task DispatchAsync(T entity, CancellationToken cancellationToken); + + /// + /// Dispatches the specified entity + /// + /// The message to dispatch + /// The cancellation token to use where applicable + /// A task that completes when the message has been acknowledged by the receiver + Task DispatchAsync(Message message, CancellationToken cancellationToken); } -} +} \ No newline at end of file diff --git a/src/OpenMessage/ISerializer.cs b/src/OpenMessage/ISerializer.cs deleted file mode 100644 index 071c566..0000000 --- a/src/OpenMessage/ISerializer.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.IO; - -namespace OpenMessage -{ - public interface ISerializer - { - string TypeName { get; } - - Stream Serialize(T entity); - T Deserialize(Stream entity); - } -} diff --git a/src/OpenMessage/ISupportAcknowledgement.cs b/src/OpenMessage/ISupportAcknowledgement.cs new file mode 100644 index 0000000..d660a50 --- /dev/null +++ b/src/OpenMessage/ISupportAcknowledgement.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; + +namespace OpenMessage +{ + /// + /// Indicates that the message supports acknowledgement + /// + public interface ISupportAcknowledgement + { + /// + /// The current state of the message, in terms of acknowledgement + /// + AcknowledgementState AcknowledgementState { get; } + + /// + /// Acknowledges the message back to the message source. + /// + /// Indicates whether or not to positively acknowledge the message, or negatively acknowledge + /// The exception that caused the acknowledgement + /// A task that completes when the message source has completed the acknowledgement + Task AcknowledgeAsync(bool positivelyAcknowledge = true, Exception? exception = null); + } +} \ No newline at end of file diff --git a/src/OpenMessage/ISupportIdentification.cs b/src/OpenMessage/ISupportIdentification.cs new file mode 100644 index 0000000..1de2d40 --- /dev/null +++ b/src/OpenMessage/ISupportIdentification.cs @@ -0,0 +1,9 @@ +namespace OpenMessage +{ + /// + /// Indicates the message supports identification + /// + public interface ISupportIdentification : ISupportIdentification + { + } +} \ No newline at end of file diff --git a/src/OpenMessage/ISupportIdentification{T}.cs b/src/OpenMessage/ISupportIdentification{T}.cs new file mode 100644 index 0000000..a31d753 --- /dev/null +++ b/src/OpenMessage/ISupportIdentification{T}.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + +namespace OpenMessage +{ + /// + /// Indicates the message supports identification + /// + /// The type of the message identifier + public interface ISupportIdentification + { + /// + /// The message id + /// + [MaybeNull, AllowNull] + T Id { get; } + } +} \ No newline at end of file diff --git a/src/OpenMessage/ISupportProperties.cs b/src/OpenMessage/ISupportProperties.cs new file mode 100644 index 0000000..a146c4f --- /dev/null +++ b/src/OpenMessage/ISupportProperties.cs @@ -0,0 +1,9 @@ +namespace OpenMessage +{ + /// + /// Indicates that the message supports properties + /// + public interface ISupportProperties : ISupportProperties + { + } +} \ No newline at end of file diff --git a/src/OpenMessage/ISupportProperties{TKey, TValue}.cs b/src/OpenMessage/ISupportProperties{TKey, TValue}.cs new file mode 100644 index 0000000..2e55bd5 --- /dev/null +++ b/src/OpenMessage/ISupportProperties{TKey, TValue}.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace OpenMessage +{ + /// + /// Indicates that the message supports properties + /// + public interface ISupportProperties + { + /// + /// The properties associated with the message + /// + IEnumerable> Properties { get; } + } +} \ No newline at end of file diff --git a/src/OpenMessage/ISupportProperties{T}.cs b/src/OpenMessage/ISupportProperties{T}.cs new file mode 100644 index 0000000..9a2dc84 --- /dev/null +++ b/src/OpenMessage/ISupportProperties{T}.cs @@ -0,0 +1,7 @@ +namespace OpenMessage +{ + /// + /// Indicates that the message supports properties + /// + public interface ISupportProperties : ISupportProperties { } +} \ No newline at end of file diff --git a/src/OpenMessage/ISupportSendDelay.cs b/src/OpenMessage/ISupportSendDelay.cs new file mode 100644 index 0000000..2c1d21a --- /dev/null +++ b/src/OpenMessage/ISupportSendDelay.cs @@ -0,0 +1,15 @@ +using System; + +namespace OpenMessage +{ + /// + /// Indicates the message supports a send delay + /// + public interface ISupportSendDelay + { + /// + /// How long to delay the send of the message. The value is limited by the maximum value allowed by the messaging protocol + /// + public TimeSpan SendDelay { get; set; } + } +} diff --git a/src/OpenMessage/KnownProperties.cs b/src/OpenMessage/KnownProperties.cs new file mode 100644 index 0000000..080a749 --- /dev/null +++ b/src/OpenMessage/KnownProperties.cs @@ -0,0 +1,28 @@ +namespace OpenMessage +{ + /// + /// A list of known properties on a message + /// + public static class KnownProperties + { + /// + /// The id of the activity that triggered the message + /// + public static readonly string ActivityId = nameof(ActivityId); + + /// + /// The type the entity has been serialized as + /// + public static readonly string ContentType = nameof(ContentType); + + /// + /// The type of key that has been serialized + /// + public static readonly string KeyTypeName = nameof(KeyTypeName); + + /// + /// The type of the value that has been serialized + /// + public static readonly string ValueTypeName = nameof(ValueTypeName); + } +} \ No newline at end of file diff --git a/src/OpenMessage/ManagedObservable.cs b/src/OpenMessage/ManagedObservable.cs deleted file mode 100644 index 0873aa2..0000000 --- a/src/OpenMessage/ManagedObservable.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; - -namespace OpenMessage -{ - internal abstract class ManagedObservable : IObservable, IDisposable - { - private readonly ILogger> _logger; - private readonly HashSet> _observers = new HashSet>(); - - public ManagedObservable(ILogger> logger) - { - if (logger == null) - throw new ArgumentNullException(nameof(logger)); - - _logger = logger; - } - - public IDisposable Subscribe(IObserver observer) - { - if (observer == null) - throw new ArgumentNullException(nameof(observer)); - - lock (_observers) - { - _observers.Add(observer); - return new Disposable(() => - { - lock (_observers) - if (_observers.Remove(observer)) - observer.OnCompleted(); - }); - } - } - - protected void Notify(T entity) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - lock (_observers) - { - var errors = new List(); - foreach (var observer in _observers) - try - { - observer.OnNext(entity); - } - catch (Exception ex) - { - _logger.LogError(ex.Message, ex); - errors.Add(ex); - } - - if (errors.Count > 0) - throw new AggregateException(errors); - } - } - - public void Dispose() - { - Dispose(true); - } - - public virtual void Dispose(bool disposing) - { - lock (_observers) - { - foreach (var observer in _observers) - observer.OnCompleted(); - - _observers.Clear(); - } - } - } -} diff --git a/src/OpenMessage/Memory/IMemoryProviderBuilder.cs b/src/OpenMessage/Memory/IMemoryProviderBuilder.cs new file mode 100644 index 0000000..71ee5b7 --- /dev/null +++ b/src/OpenMessage/Memory/IMemoryProviderBuilder.cs @@ -0,0 +1,20 @@ +using OpenMessage.Builders; +using System; +using System.Threading.Channels; + +namespace OpenMessage.Memory +{ + /// + /// The builder for an memory consumer and dispatcher + /// + /// The type to be consumed/dispatched + public interface IMemoryProviderBuilder : IBuilder + { + /// + /// Configures the underlying of the memory provider + /// + /// + /// + IMemoryProviderBuilder ConfigureChannel(Func>> channelCreator); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Memory/MemoryDispatcher.cs b/src/OpenMessage/Memory/MemoryDispatcher.cs new file mode 100644 index 0000000..75632ba --- /dev/null +++ b/src/OpenMessage/Memory/MemoryDispatcher.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace OpenMessage.Memory +{ + internal sealed class MemoryDispatcher : DispatcherBase + { + private readonly ChannelWriter> _channelWriter; + + public MemoryDispatcher(ChannelWriter> channelWriter, ILogger> logger) + : base(logger) + { + _channelWriter = channelWriter ?? throw new ArgumentNullException(nameof(channelWriter)); + } + + public override async Task DispatchAsync(Message entity, CancellationToken cancellationToken) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + cancellationToken.ThrowIfCancellationRequested(); + + LogDispatch(entity); + + if (!await _channelWriter.WaitToWriteAsync(cancellationToken)) + Throw.Exception("Cannot write to channel"); + + await _channelWriter.WriteAsync(entity, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Memory/MemoryProviderBuilder.cs b/src/OpenMessage/Memory/MemoryProviderBuilder.cs new file mode 100644 index 0000000..7d05b9e --- /dev/null +++ b/src/OpenMessage/Memory/MemoryProviderBuilder.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Threading.Channels; + +namespace OpenMessage.Memory +{ + internal sealed class MemoryProviderBuilder : IMemoryProviderBuilder + { + private Func>>? _channelCreator = null; + + public IMessagingBuilder HostBuilder { get; } + + public MemoryProviderBuilder(IMessagingBuilder builder) => HostBuilder = builder; + + public void Build() + { + HostBuilder.Services.TryAddChannel(_channelCreator).TryAddConsumerService(_channelCreator).AddSingleton, MemoryDispatcher>(); + HostBuilder.TryConfigureDefaultPipeline(); + } + + public IMemoryProviderBuilder ConfigureChannel(Func>> channelCreator) + { + _channelCreator = channelCreator; + + return this; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Memory/MemoryProviderExtensions.cs b/src/OpenMessage/Memory/MemoryProviderExtensions.cs new file mode 100644 index 0000000..e6896bd --- /dev/null +++ b/src/OpenMessage/Memory/MemoryProviderExtensions.cs @@ -0,0 +1,18 @@ +using OpenMessage.Memory; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for adding an InMemory Dispatcher/Consumer + /// + public static class MemoryProviderExtensions + { + /// + /// Adds an InMemory consumer and dispatcher + /// + /// The current host builder + /// The type to handle + /// An instance of + public static IMemoryProviderBuilder ConfigureMemory(this IMessagingBuilder builder) => new MemoryProviderBuilder(builder); + } +} \ No newline at end of file diff --git a/src/OpenMessage/MessageBroker.cs b/src/OpenMessage/MessageBroker.cs deleted file mode 100644 index 7c19a26..0000000 --- a/src/OpenMessage/MessageBroker.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace OpenMessage -{ - internal sealed class MessageBroker : ManagedObservable, IBroker - { - private readonly HashSet _subscriptions = new HashSet(); - private readonly HashSet> _observables = new HashSet>(); - - public MessageBroker(IEnumerable> observers, - IEnumerable> observables, - ILogger> logger) - : base(logger) - { - if (observers == null) - throw new ArgumentNullException(nameof(observers)); - - if (observables == null) - throw new ArgumentNullException(nameof(observables)); - - foreach (var observer in observers) - _subscriptions.Add(Subscribe(observer)); - - foreach (var observable in observables) - { - _observables.Add(observable); - - var disposableObservable = observable as IDisposable; - if (disposableObservable != null) - _subscriptions.Add(disposableObservable); - - _subscriptions.Add(observable.Subscribe(new ActionObserver(Notify))); - } - } - - public override void Dispose(bool disposing) - { - foreach (var subscription in _subscriptions) - subscription.Dispose(); - - _subscriptions.Clear(); - - foreach (var observable in _observables.OfType()) - observable.Dispose(); - } - } -} diff --git a/src/OpenMessage/Message{T}.cs b/src/OpenMessage/Message{T}.cs new file mode 100644 index 0000000..8ecf5c6 --- /dev/null +++ b/src/OpenMessage/Message{T}.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; + +namespace OpenMessage +{ + /// + /// Represents a message from another system of a give type + /// + /// The type of the message sent by the counterpart system + public class Message + { + /// + /// The entity sent by the counterpart system + /// + [MaybeNull, AllowNull] + public T Value { get; set; } + + /// + /// Creates a new message + /// + public Message() + { + Value = default; + } + + /// + /// Creates a new message with the specified value + /// + public Message(T value) + { + Value = value; + } + + /// + /// Implicitly converts the message to the type T + /// + /// The message to convert + /// Default if the message is null, otherwise the Value + [return: MaybeNull] + public static implicit operator T(Message message) => message is null ? default : message.Value; + } +} \ No newline at end of file diff --git a/src/OpenMessage/OpenMessage.csproj b/src/OpenMessage/OpenMessage.csproj new file mode 100644 index 0000000..0d65130 --- /dev/null +++ b/src/OpenMessage/OpenMessage.csproj @@ -0,0 +1,19 @@ + + + + OpenMessage is an easy to use abstraction for sending and receiving messages between applications. + 1.0.0 + $(ProjectTargetFrameworks) + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/OpenMessage/OpenMessage.xproj b/src/OpenMessage/OpenMessage.xproj deleted file mode 100644 index ffbf90f..0000000 --- a/src/OpenMessage/OpenMessage.xproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - 7d68a283-6d90-4a50-b015-d8c2bb5c7184 - OpenMessage - .\obj - .\bin\ - v4.5.2 - - - - 2.0 - - - diff --git a/src/OpenMessage/OpenMessageEventSource.cs b/src/OpenMessage/OpenMessageEventSource.cs new file mode 100644 index 0000000..cd48f4a --- /dev/null +++ b/src/OpenMessage/OpenMessageEventSource.cs @@ -0,0 +1,170 @@ +#if NETCOREAPP3_1 +using System; +using System.Diagnostics; +using System.Diagnostics.Tracing; +using System.Threading; + +namespace OpenMessage +{ + // Inspo: https://github.com/aspnet/AspNetCore/blob/f3f9a1cdbcd06b298035b523732b9f45b1408461/src/Hosting/Hosting/src/Internal/HostingEventSource.cs + [EventSource(Name = "OpenMessage")] + internal class OpenMessageEventSource : EventSource + { + internal static readonly OpenMessageEventSource Instance = new OpenMessageEventSource(); + + private long _inflightMessages = 0; + private long _processedCount = 0; + private long _dispatchingMessages = 0; + private long _dispatchedMessages = 0; + private EventCounter? _messageProcessDurationCounter; + private EventCounter? _messageDispatchDurationCounter; + private IncrementingPollingCounter? _inflightMessagesCounter; + private IncrementingPollingCounter? _processedCountCounter; + private IncrementingPollingCounter? _dispatchingCounter; + private IncrementingPollingCounter? _dispatchedCounter; + + private OpenMessageEventSource() { } + + // NOTE + // - The 'Start' and 'Stop' suffixes on the following event names have special meaning in EventSource. They enable creating 'activities'. + // For more information, take a look at the following blog post: https://blogs.msdn.microsoft.com/vancem/2015/09/14/exploring-eventsource-activity-correlation-and-causation-features/ + // - A stop event's event id must be next one after its start event. + // - Avoid renaming methods or parameters marked with EventAttribute. EventSource uses these to form the event object. + [NonEvent] + public ValueStopwatch? ProcessMessageStart() + { + if (!IsEnabled()) return null; + + MessageStart(); + + return ValueStopwatch.StartNew(); + } + + [Event(1, Level = EventLevel.Informational, Message = "Consumed Message")] + private void MessageStart() + { + Interlocked.Increment(ref _inflightMessages); + Interlocked.Increment(ref _processedCount); + } + + [NonEvent] + public void ProcessMessageStop(ValueStopwatch stopwatch) + { + if (!IsEnabled()) return; + + MessageStop(stopwatch.IsActive ? stopwatch.GetElapsedTime().TotalMilliseconds : 0.0); + } + + [Event(2, Level = EventLevel.Informational, Message = "Message Completed")] + private void MessageStop(double duration) + { + Interlocked.Decrement(ref _inflightMessages); + _messageProcessDurationCounter?.WriteMetric(duration); + } + + [NonEvent] + public ValueStopwatch? ProcessMessageDispatchStart() + { + if (!IsEnabled()) return null; + + MessageDispatchStart(); + + return ValueStopwatch.StartNew(); + } + + [Event(3, Level = EventLevel.Informational, Message = "Dispatching message")] + private void MessageDispatchStart() + { + Interlocked.Increment(ref _dispatchingMessages); + } + + [NonEvent] + public void ProcessMessageDispatchStop(ValueStopwatch stopwatch) + { + if (!IsEnabled()) return; + + MessageDispatchStop(stopwatch.IsActive ? stopwatch.GetElapsedTime().TotalMilliseconds : 0.0); + } + + [Event(4, Level = EventLevel.Informational, Message = "Message dispatched")] + private void MessageDispatchStop(double duration) + { + Interlocked.Decrement(ref _dispatchingMessages); + Interlocked.Increment(ref _dispatchedMessages); + _messageDispatchDurationCounter?.WriteMetric(duration); + } + + protected override void OnEventCommand(EventCommandEventArgs command) + { + if (command.Command == EventCommand.Enable) + { + // This is the convention for initializing counters in the RuntimeEventSource (lazily on the first enable command). + // They aren't disabled afterwards... + _inflightMessagesCounter ??= new IncrementingPollingCounter("inflight-messages", this, () => _inflightMessages) + { + DisplayName = "Inflight Messages", + DisplayUnits = "Messages" + }; + _messageProcessDurationCounter ??= new EventCounter("message-process-duration", this) + { + DisplayName = "Average Message Process Duration", + DisplayUnits = "ms" + }; + _messageDispatchDurationCounter ??= new EventCounter("message-dispatch-duration", this) + { + DisplayName = "Average Message Dispatch Duration", + DisplayUnits = "ms" + }; + _processedCountCounter ??= new IncrementingPollingCounter("processed-count", this, () => _processedCount) + { + DisplayName = "Messages Processed", + DisplayRateTimeScale = TimeSpan.FromSeconds(1) + }; + _dispatchingCounter ??= new IncrementingPollingCounter("dispatching-count", this, () => _dispatchingMessages) + { + DisplayName = "Messages Dispatching", + DisplayRateTimeScale = TimeSpan.FromSeconds(1) + }; + _dispatchedCounter ??= new IncrementingPollingCounter("dispatched-count", this, () => _dispatchedMessages) + { + DisplayName = "Messages Dispatched", + DisplayRateTimeScale = TimeSpan.FromSeconds(1) + }; + } + } + + // inspo: https://github.com/aspnet/Extensions/blob/34204b6bc41de865f5310f5f237781a57a83976c/src/Shared/src/ValueStopwatch/ValueStopwatch.cs + internal struct ValueStopwatch + { + private static readonly double TimestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; + private long _startTimestamp; + public bool IsActive => _startTimestamp != 0; + + private ValueStopwatch(long startTimestamp) + { + _startTimestamp = startTimestamp; + } + + public static ValueStopwatch StartNew() => new ValueStopwatch(Stopwatch.GetTimestamp()); + + public TimeSpan GetElapsedTime() + { + // Start timestamp can't be zero in an initialized ValueStopwatch. It would have to be literally the first thing executed when the machine boots to be 0. + // So it being 0 is a clear indication of default(ValueStopwatch) + if (!IsActive) + { + ThrowUninitializedException(); + } + + var end = Stopwatch.GetTimestamp(); + var timestampDelta = end - _startTimestamp; + var ticks = (long)(TimestampToTicks * timestampDelta); + return new TimeSpan(ticks); + } + + private void ThrowUninitializedException() + => throw new InvalidOperationException("An uninitialized, or 'default', ValueStopwatch cannot be used to get elapsed time."); + } + } +} +#endif diff --git a/src/OpenMessage/OpenMessageExtensions.cs b/src/OpenMessage/OpenMessageExtensions.cs new file mode 100644 index 0000000..0291ac2 --- /dev/null +++ b/src/OpenMessage/OpenMessageExtensions.cs @@ -0,0 +1,429 @@ +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using OpenMessage; +using OpenMessage.Builders; +using OpenMessage.Handlers; +using OpenMessage.Pipelines; +using OpenMessage.Pipelines.Builders; +using OpenMessage.Pipelines.Endpoints; +using OpenMessage.Pipelines.Middleware; +using OpenMessage.Pipelines.Pumps; +using OpenMessage.Serialization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Adds OpenMessage to the specified host + /// + public static class OpenMessageExtensions + { + /// + /// Adds OpenMessage + /// + /// The host configuration + /// The OpenMessage builder - use this to configure consumers and dispatchers + /// The modified host builder + public static IHostBuilder ConfigureMessaging(this IHostBuilder hostBuilder, Action builder) + { + return hostBuilder.ConfigureServices((context, services) => + { + builder?.Invoke(new MessagingBuilder(context, services)); + services.AddSerialization(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.Configure(SerializationConstants.SerializerSettings, x => + { + x.IgnoreNullValues = true; + }); + services.Configure(SerializationConstants.DeserializerSettings, x => { }); + services.AddSingleton(typeof(BatchPipelineEndpoint<>)); + services.AddScoped(typeof(HandlerPipelineEndpoint<>)); + services.AddScoped(typeof(BatchHandlerPipelineEndpoint<>)); + }); + } + + #region Middleware Extensions + + /// + /// Automatically acknowledges a message + /// + /// The pipeline to add to + /// The underlying message type of the pipeline + /// The pipeline builder + public static IPipelineBuilder UseAutoAcknowledge(this IPipelineBuilder pipelineBuilder) + { + pipelineBuilder.Services.TryAddSingleton>(); + + return pipelineBuilder.Use>(); + } + + /// + /// Creates a new service scope for each pipeline + /// + /// The pipeline to add to + /// The underlying message type of the pipeline + /// The pipeline builder + public static IPipelineBuilder UseServiceScope(this IPipelineBuilder pipelineBuilder) + { + pipelineBuilder.Services.TryAddSingleton>(); + + return pipelineBuilder.Use>(); + } + + /// + /// Automatically times out a message after a specified duration. + /// + /// The pipeline to add to + /// The underlying message type of the pipeline + /// The pipeline builder + public static IPipelineBuilder UseTimeout(this IPipelineBuilder pipelineBuilder) + { + pipelineBuilder.Services.TryAddSingleton>(); + + return pipelineBuilder.Use>(); + } + + /// + /// Creates a new for message + /// + /// The pipeline to add to + /// The underlying message type of the pipeline + /// The pipeline builder + public static IPipelineBuilder UseTracing(this IPipelineBuilder pipelineBuilder) + { + pipelineBuilder.Services.TryAddSingleton>(); + + return pipelineBuilder.Use>(); + } + + /// + /// Creates a new log scope when the message supports identification + /// + /// The pipeline to add to + /// The underlying message type of the pipeline + /// The pipeline builder + public static IPipelineBuilder UseLoggingScope(this IPipelineBuilder pipelineBuilder) + { + pipelineBuilder.Services.TryAddSingleton>(); + + return pipelineBuilder.Use>(); + } + + #endregion + + #region IMessagingBuilder + + /// + /// Adds the specified handler + /// + /// The OpenMessage builder + /// The type that the handler handles + /// The type of the handler + /// The OpenMessageBuilder + public static IMessagingBuilder ConfigureHandler(this IMessagingBuilder messagingBuilder) + where THandler : class, IHandler + { + messagingBuilder.Services.AddScoped, THandler>(); + + return messagingBuilder; + } + + /// + /// Adds the specified batch handler + /// + /// The type that the handler handles + /// The type of the batch handler to add + /// The OpenMessageBuilder + /// The OpenMessageBuilder + public static IMessagingBuilder ConfigureBatchHandler(this IMessagingBuilder messagingBuilder) + where TBatchHandler : class, IBatchHandler + { + messagingBuilder.Services.AddScoped, TBatchHandler>(); + + return messagingBuilder; + } + + /// + /// Adds the specified handler + /// + /// The OpenMessage builder + /// The handler to use + /// The type that the handler handles + /// The OpenMessageBuilder + public static IMessagingBuilder ConfigureHandler(this IMessagingBuilder messagingBuilder, IHandler instance) + { + messagingBuilder.Services.AddSingleton(instance); + + return messagingBuilder; + } + + /// + /// Adds the specified handler + /// + /// The OpenMessage builder + /// The implementation of the handler + /// The type that the handler handles + /// The OpenMessageBuilder + public static IMessagingBuilder ConfigureHandler(this IMessagingBuilder messagingBuilder, Action, CancellationToken> action) + { + messagingBuilder.Services.AddSingleton>(sp => ActivatorUtilities.CreateInstance>(sp, action)); + return messagingBuilder; + } + + /// + /// Adds the specified handler + /// + /// The OpenMessage builder + /// The implementation of the handler + /// The type that the handler handles + /// The OpenMessageBuilder + public static IMessagingBuilder ConfigureHandler(this IMessagingBuilder messagingBuilder, Action> action) + { + messagingBuilder.Services.AddSingleton>(sp => ActivatorUtilities.CreateInstance>(sp, action)); + return messagingBuilder; + } + + /// + /// Adds the specified handler + /// + /// The OpenMessage builder + /// The implementation of the handler + /// The type that the handler handles + /// The OpenMessageBuilder + public static IMessagingBuilder ConfigureHandler(this IMessagingBuilder messagingBuilder, Func, CancellationToken, Task> action) + { + messagingBuilder.Services.AddSingleton>(sp => ActivatorUtilities.CreateInstance>(sp, action)); + return messagingBuilder; + } + + /// + /// Adds the specified handler + /// + /// The OpenMessage builder + /// The implementation of the handler + /// The type that the handler handles + /// The OpenMessageBuilder + public static IMessagingBuilder ConfigureHandler(this IMessagingBuilder messagingBuilder, Func, Task> action) + { + messagingBuilder.Services.AddSingleton>(sp => ActivatorUtilities.CreateInstance>(sp, action)); + return messagingBuilder; + } + + /// + /// Configures a default pipeline + /// + /// The OpenMessage builder + /// The type that the handler handles + public static void TryConfigureDefaultPipeline(this IMessagingBuilder messagingBuilder) + { + if (messagingBuilder.Services.Any(x => x.ServiceType == typeof(IPipelineBuilder))) + return; + + messagingBuilder.Services.TryAddSingleton>(new PipelineBuilder(messagingBuilder).UseDefaultMiddleware()); + } + + /// + /// Configures the pipeline for a specified type. + /// + /// The OpenMessage builder + /// The configuration to use + /// The type that the handler handles + /// The OpenMessage builder + public static IPipelineBuilder ConfigurePipeline(this IMessagingBuilder messagingBuilder, Action>? configurator = null) + { + return ConfigurePipeline(messagingBuilder, (_, options) => configurator?.Invoke(options)); + } + + /// + /// Configures the pipeline for a specified type. + /// + /// The OpenMessage builder + /// The configuration to use + /// The type that the handler handles + /// The OpenMessage builder + public static IPipelineBuilder ConfigurePipeline(this IMessagingBuilder messagingBuilder, Action> configurator) + { + if (configurator is {}) + messagingBuilder.Services.Configure>(options => { configurator(messagingBuilder.Context, options); }); + + messagingBuilder.Services.AddSingleton>, PipelineOptionsPostConfigurationProvider>(); + + return new PipelineBuilder(messagingBuilder); + } + + /// + /// Configures all handlers that can be found in the specified assemblies + /// + /// The OpenMessage builder + /// The assemblies to scan + /// The OpenMessageBuilder + public static IMessagingBuilder ConfigureAllHandlers(this IMessagingBuilder messagingBuilder, params Assembly[] assembliesToScan) + { + if (assembliesToScan?.Length == 0) + { + var entryAssembly = Assembly.GetEntryAssembly(); + var executingAssembly = Assembly.GetExecutingAssembly(); + + var assemblyList = new List(); + + if (entryAssembly is { }) + assemblyList.Add(entryAssembly); + + if (executingAssembly is { } && !executingAssembly.Equals(entryAssembly)) + assemblyList.Add(executingAssembly); + + var thisAssembly = typeof(OpenMessageExtensions).Assembly; + assembliesToScan = assemblyList.Where(x => !x.Equals(thisAssembly)).ToArray(); + } + + var handlerTypes = new[] {typeof(IHandler<>), typeof(IBatchHandler<>)}; + + IEnumerable HandlerInterfaceFilter(TypeInfo ti) + { + return ti.ImplementedInterfaces.Where(x => x.IsGenericType && handlerTypes.Contains(x.GetGenericTypeDefinition())); + } + + var handlersFound = 0; + + foreach (var assembly in assembliesToScan?.Where(x => x != null) ?? Enumerable.Empty()) + { + var types = assembly.GetTypes() + .Where(x => !x.IsAbstract && + !x.IsInterface && + HandlerInterfaceFilter(x.GetTypeInfo()) + .Any()); + + foreach (var handlerType in types) + { + var implementedHandlers = HandlerInterfaceFilter(handlerType.GetTypeInfo()); + + foreach (var implementedHandler in implementedHandlers) + { + handlersFound += 1; + messagingBuilder.Services.AddScoped(implementedHandler, handlerType); + } + } + } + + if (handlersFound == 0) + throw new Exception("No handlers found in assemblies. " + string.Join(", ", assembliesToScan.Select(x => x.FullName))); + + return messagingBuilder; + } + + #endregion + + #region IServiceCollection + + /// + /// Adds the core services required for serialization + /// + /// The service collection to modify + /// The modified service collection + public static IServiceCollection AddSerialization(this IServiceCollection services) + { + services.TryAddSingleton(); + + return services; + } + + /// + /// Adds the specified serializer + /// + /// The service collection to modify + /// The type to handle + /// The modified service collection + public static IServiceCollection AddSerializer(this IServiceCollection services) + where T : class, ISerializer + => services.AddSingleton(); + + /// + /// Adds the specified deserializer + /// + /// The service collection to modify + /// The type to handle + /// The modified service collection + public static IServiceCollection AddDeserializer(this IServiceCollection services) + where T : class, IDeserializer + => services.AddSingleton(); + + /// + /// Adds the background channel if it has not already been added + /// + /// The service collection to modify + /// A function that creates a channel + /// The type to handle + /// The modified service collection + public static IServiceCollection TryAddChannel(this IServiceCollection services, Func>? channelCreator = null) + { + if (channelCreator is null) + services.TryAddSingleton(sp => + { + var pipelineOptions = sp.GetRequiredService>>() + .CurrentValue; + + return pipelineOptions.UseBoundedChannel.GetValueOrDefault(true) ? Channel.CreateBounded(new BoundedChannelOptions(pipelineOptions.BoundedChannelLimit.GetValueOrDefault(Environment.ProcessorCount * 10)) + { + SingleReader = true, + SingleWriter = false + }) : Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + }); + else + services.TryAddSingleton(channelCreator); + + services.TryAddSingleton(sp => sp.GetRequiredService>() + .Reader); + + services.TryAddSingleton(sp => sp.GetRequiredService>() + .Writer); + + return services; + } + + /// + /// Adds the background channel if it has not already been added + /// + /// The service collection to modify + /// A function that creates a channel + /// The type to handle + /// The modified service collection + public static IServiceCollection TryAddConsumerService(this IServiceCollection services, Func>>? channelCreator = null) + { + services.TryAddChannel(channelCreator); + services.TryAddSingleton>, PipelineOptionsPostConfigurationProvider>(); + + if (!services.Any(x => x.ServiceType == typeof(IHostedService) && x.ImplementationType == typeof(ConsumerPump))) + services.AddSingleton>(); + + return services; + } + + /// + /// Adds the background channel + /// + /// The service collection to modify + /// The consumer id + /// The type to handle + /// The modified service collection + public static IServiceCollection AddConsumerService(this IServiceCollection services, string consumerId) + where T : IHostedService + { + return services.AddSingleton(sp => ActivatorUtilities.CreateInstance(sp, consumerId)); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Builders/BatchPipelineBuilder.cs b/src/OpenMessage/Pipelines/Builders/BatchPipelineBuilder.cs new file mode 100644 index 0000000..449023b --- /dev/null +++ b/src/OpenMessage/Pipelines/Builders/BatchPipelineBuilder.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenMessage.Pipelines.Endpoints; +using OpenMessage.Pipelines.Middleware; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Builders +{ + /// + /// https://github.com/aspnet/HttpAbstractions/blob/master/src/Microsoft.AspNetCore.Http/Internal/ApplicationBuilder.cs + /// + internal sealed class BatchPipelineBuilder : IBatchPipelineBuilder + { + private readonly IMessagingBuilder _builder; + private readonly IList, PipelineDelegate.BatchMiddleware>> _middleware = new List, PipelineDelegate.BatchMiddleware>>(); + + /// + public IServiceCollection Services => _builder.Services; + + public BatchPipelineBuilder(IMessagingBuilder builder) + { + _builder = builder; + _builder.Services.AddSingleton>(this); + } + + public PipelineDelegate.BatchMiddleware Build() + { + Run>(); + + PipelineDelegate.BatchMiddleware batchApp = (messages, cancellationToken, context) => Task.CompletedTask; + + foreach (var middleware in _middleware.Reverse()) + batchApp = middleware(batchApp); + + return batchApp; + } + + public void Run(Func> endpoint) + { + _middleware.Add(_ => endpoint()); + } + + public void Run(params object[] constructorParameters) + where TBatchPipelineEndpoint : IBatchPipelineEndpoint + { + _middleware.Add(_ => + { + return (message, cancellationToken, messageContext) => + { + var pipelineEndpoint = constructorParameters.Length > 0 ? ActivatorUtilities.CreateInstance(messageContext.ServiceProvider, constructorParameters) : messageContext.ServiceProvider.GetRequiredService(); + + return pipelineEndpoint.Invoke(message, cancellationToken, messageContext); + }; + }); + } + + public IBatchPipelineBuilder Use(Func, PipelineDelegate.BatchMiddleware> middleware) + { + _middleware.Add(middleware); + + return this; + } + + public IBatchPipelineBuilder Use(params object[] constructorParameters) + where TMiddleware : IBatchMiddleware + { + _middleware.Add(next => + { + return (messages, cancellationToken, messageContext) => + { + IBatchMiddleware middleware = constructorParameters.Length > 0 ? ActivatorUtilities.CreateInstance(messageContext.ServiceProvider, constructorParameters) : messageContext.ServiceProvider.GetRequiredService(); + + return middleware.Invoke(messages, cancellationToken, messageContext, next); + }; + }); + + return this; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Builders/BatchPipelineBuilderExtensions.cs b/src/OpenMessage/Pipelines/Builders/BatchPipelineBuilderExtensions.cs new file mode 100644 index 0000000..277e57d --- /dev/null +++ b/src/OpenMessage/Pipelines/Builders/BatchPipelineBuilderExtensions.cs @@ -0,0 +1,139 @@ +using OpenMessage.Pipelines.Endpoints; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Builders +{ + /// + /// Helpers for configuring a + /// + public static class BatchPipelineBuilderExtensions + { + #region Use + + /// + /// Adds a middleware step into the pipeline + /// + public static IBatchPipelineBuilder Use(this IBatchPipelineBuilder builder, Func>, CancellationToken, MessageContext, Func>, CancellationToken, MessageContext, Task>, Task> middleware) + { + return builder.Use(next => + { + return (messages, cancellationToken, messageContext) => + { + return middleware(messages, cancellationToken, messageContext, (m, ct, ctx) => next(m, ct, ctx)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IBatchPipelineBuilder Use(this IBatchPipelineBuilder builder, Func>, CancellationToken, MessageContext, Func, Task> middleware) + { + return builder.Use(next => + { + return (messages, cancellationToken, messageContext) => + { + return middleware(messages, cancellationToken, messageContext, () => next(messages, cancellationToken, messageContext)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IBatchPipelineBuilder Use(this IBatchPipelineBuilder builder, Func>, CancellationToken, Func>, CancellationToken, Task>, Task> middleware) + { + return builder.Use(next => + { + return (messages, cancellationToken, messageContext) => + { + return middleware(messages, cancellationToken, (m, ctx) => next(m, cancellationToken, messageContext)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IBatchPipelineBuilder Use(this IBatchPipelineBuilder builder, Func>, CancellationToken, Func, Task> middleware) + { + return builder.Use(next => + { + return (messages, cancellationToken, messageContext) => + { + return middleware(messages, cancellationToken, () => next(messages, cancellationToken, messageContext)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IBatchPipelineBuilder Use(this IBatchPipelineBuilder builder, Func>, Func>, Task>, Task> middleware) + { + return builder.Use(next => + { + return (messages, cancellationToken, messageContext) => + { + return middleware(messages, m => next(m, cancellationToken, messageContext)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IBatchPipelineBuilder Use(this IBatchPipelineBuilder builder, Func>, Func, Task> middleware) + { + return builder.Use(next => + { + return (messages, cancellationToken, messageContext) => + { + return middleware(messages, () => next(messages, cancellationToken, messageContext)); + }; + }); + } + + #endregion + + #region Run + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + public static void Run(this IBatchPipelineBuilder builder, Func>, CancellationToken, MessageContext, Task> action) + { + builder.Run(() => + { + return (messages, cancellationToken, messageContext) => action(messages, cancellationToken, messageContext); + }); + } + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + public static void Run(this IBatchPipelineBuilder builder, Func>, CancellationToken, Task> action) + { + builder.Run(() => + { + return (messages, cancellationToken, messageContext) => action(messages, cancellationToken); + }); + } + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + public static void Run(this IBatchPipelineBuilder builder, Func>, Task> action) + { + builder.Run(() => + { + return (messages, cancellationToken, messageContext) => action(messages); + }); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Builders/BatcherBase.cs b/src/OpenMessage/Pipelines/Builders/BatcherBase.cs new file mode 100644 index 0000000..4c3d94d --- /dev/null +++ b/src/OpenMessage/Pipelines/Builders/BatcherBase.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Builders +{ + internal abstract class BatcherBase + { + private Batch _currentBatch; + + protected BatcherBase(int batchSize, TimeSpan timeout) + { + _currentBatch = new Batch(batchSize); + + _ = Task.Factory.StartNew(async () => + { + while (true) + { + var nextBatch = new Batch(batchSize); + var batch = Interlocked.Exchange(ref _currentBatch, nextBatch); + + if (batch is {}) + { + // General flow: + //-------------------- + // 1. Complete the batch to prevent further additions + // 2. Check to see whether we should trigger the OnBatchAsync method + // 3. Set the task completion source + var currentBatch = batch.Flush(); + + if (currentBatch.Count > 0) + _ = Task.Factory.StartNew(async () => + { + //"Fire and forget"; lets not block up the batcher while waiting for it to process + try + { + await OnBatchAsync(currentBatch); + batch.BatchProcessedTaskCompletionSource.SetResult(true); + } + catch (Exception ex) + { + // Leave the logging of the exception to the consumer + batch.BatchProcessedTaskCompletionSource.SetException(ex); + } + }); + } + + //Wait for a timeout, or the next batch to complete + await Task.WhenAny(Task.Delay(timeout), nextBatch.BatchFullTaskCompletionSource.Task); + } + }, default, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + public Task BatchAsync(T entity) + { + var successful = false; + + while (!successful) + { + // Take a reference to the current instance + // This prevents a race condition between adding and returning the task + var current = _currentBatch; + + if (successful = current.AddToBatch(entity)) + return current.BatchProcessedTaskCompletionSource.Task; + } + + throw new InvalidOperationException("Batch failed to add successfully. This should never happen..."); + } + + /// + /// The action to execute when a batch is created + /// + /// + /// + protected abstract Task OnBatchAsync(IReadOnlyCollection batch); + + private class Batch + { + private readonly List _batch; + private readonly int _batchSize; + private bool _batchCompleted; + internal TaskCompletionSource BatchFullTaskCompletionSource { get; } = new TaskCompletionSource(TaskContinuationOptions.RunContinuationsAsynchronously); + + internal TaskCompletionSource BatchProcessedTaskCompletionSource { get; } = new TaskCompletionSource(TaskContinuationOptions.RunContinuationsAsynchronously); + + public Batch(int batchSize) + { + _batchSize = batchSize; + _batch = new List(_batchSize); + } + + internal bool AddToBatch(T entity) + { + if (_batchCompleted) + // Quicker exit if we've already finished + return false; + + lock (_batch) + { + // Double check that we haven't already completed this batch + if (!_batchCompleted) + { + _batch.Add(entity); + + //If the batch is full, trigger the batch to process early + if (_batch.Count >= _batchSize) + { + BatchFullTaskCompletionSource.TrySetResult(true); + _batchCompleted = true; + + return true; + } + } + + return !_batchCompleted; + } + } + + internal List Flush() + { + lock (_batch) + { + _batchCompleted = true; + + return _batch; + } + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Builders/IBatchPipelineBuilder.cs b/src/OpenMessage/Pipelines/Builders/IBatchPipelineBuilder.cs new file mode 100644 index 0000000..83a589e --- /dev/null +++ b/src/OpenMessage/Pipelines/Builders/IBatchPipelineBuilder.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenMessage.Pipelines.Endpoints; +using OpenMessage.Pipelines.Middleware; +using System; + +namespace OpenMessage.Pipelines.Builders +{ + /// + /// A builder for configuring a batched pipeline + /// + public interface IBatchPipelineBuilder + { + /// + /// The service collection this pipeline is being built on + /// + IServiceCollection Services { get; } + + /// + /// Builds the pipeline + /// + /// + PipelineDelegate.BatchMiddleware Build(); + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + /// + void Run(Func> endpoint); + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + /// Parameters to be passed into the middleware constructor + void Run(params object[] constructorParameters) + where TBatchPipelineEndpoint : IBatchPipelineEndpoint; + + /// + /// Adds a middleware step into the pipeline + /// + /// + /// + IBatchPipelineBuilder Use(Func, PipelineDelegate.BatchMiddleware> middleware); + + /// + /// Adds an type to the pipeline + /// + /// Parameters to be passed into the middleware constructor + /// + IBatchPipelineBuilder Use(params object[] constructorParameters) + where TBatchMiddleware : IBatchMiddleware; + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Builders/IPipelineBuilder.cs b/src/OpenMessage/Pipelines/Builders/IPipelineBuilder.cs new file mode 100644 index 0000000..7260a30 --- /dev/null +++ b/src/OpenMessage/Pipelines/Builders/IPipelineBuilder.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenMessage.Pipelines.Endpoints; +using OpenMessage.Pipelines.Middleware; +using System; + +namespace OpenMessage.Pipelines.Builders +{ + /// + /// A builder for configuring a pipeline + /// + public interface IPipelineBuilder + { + /// + /// The service collection this pipeline is being built on + /// + IServiceCollection Services { get; } + + /// + /// Funnels a pipeline into a batched pipeline + /// + /// A builder to configure an + IBatchPipelineBuilder Batch(); + + /// + /// Builds the pipeline + /// + /// + PipelineDelegate.SingleMiddleware Build(); + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + /// + void Run(Func> endpoint); + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + /// Parameters to be passed into the middleware constructor + void Run(params object[] constructorParameters) + where TPipelineEndpoint : IPipelineEndpoint; + + /// + /// Adds a middleware step into the pipeline + /// + /// + /// + IPipelineBuilder Use(Func, PipelineDelegate.SingleMiddleware> middleware); + + /// + /// Adds an type to the pipeline + /// + /// Parameters to be passed into the middleware constructor + /// + IPipelineBuilder Use(params object[] constructorParameters) + where TMiddleware : IMiddleware; + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Builders/PipelineBuilder.cs b/src/OpenMessage/Pipelines/Builders/PipelineBuilder.cs new file mode 100644 index 0000000..54a418f --- /dev/null +++ b/src/OpenMessage/Pipelines/Builders/PipelineBuilder.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenMessage.Pipelines.Endpoints; +using OpenMessage.Pipelines.Middleware; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Builders +{ + /// + /// https://github.com/aspnet/HttpAbstractions/blob/master/src/Microsoft.AspNetCore.Http/Internal/ApplicationBuilder.cs + /// + internal sealed class PipelineBuilder : IPipelineBuilder + { + private readonly IMessagingBuilder _builder; + private readonly IList, PipelineDelegate.SingleMiddleware>> _middleware = new List, PipelineDelegate.SingleMiddleware>>(); + + public IServiceCollection Services => _builder.Services; + + public PipelineBuilder(IMessagingBuilder builder) + { + _builder = builder; + _builder.Services.AddSingleton>(this); + } + + public IBatchPipelineBuilder Batch() + { + if (_builder is null) + throw new Exception($"Batched pipelines can only be created when configured via an {nameof(IMessagingBuilder)}"); + + Run>(); + + return new BatchPipelineBuilder(_builder); + } + + public PipelineDelegate.SingleMiddleware Build() + { + Run>(); + + PipelineDelegate.SingleMiddleware app = (message, cancellationToken, context) => Task.CompletedTask; + + foreach (var middleware in _middleware.Reverse()) + app = middleware(app); + + return app; + } + + public void Run(Func> endpoint) + { + _middleware.Add(_ => endpoint()); + } + + public void Run(params object[] constructorParameters) + where TPipelineEndpoint : IPipelineEndpoint + { + if (constructorParameters.Length == 0) + { + _middleware.Add(_ => (message, cancellationToken, messageContext) => messageContext.ServiceProvider.GetRequiredService().Invoke(message, cancellationToken, messageContext)); + return; + } + + _middleware.Add(_ => (message, cancellationToken, messageContext) => ActivatorUtilities.CreateInstance(messageContext.ServiceProvider, constructorParameters).Invoke(message, cancellationToken, messageContext)); + } + + public IPipelineBuilder Use(Func, PipelineDelegate.SingleMiddleware> middleware) + { + _middleware.Add(middleware); + + return this; + } + + public IPipelineBuilder Use(params object[] constructorParameters) + where TMiddleware : IMiddleware + { + if (constructorParameters.Length == 0) + { + _middleware.Add(next => (message, cancellationToken, context) => context.ServiceProvider.GetRequiredService().Invoke(message, cancellationToken, context, next)); + return this; + } + + _middleware.Add(next => (message, cancellationToken, messageContext) => ActivatorUtilities.CreateInstance(messageContext.ServiceProvider, constructorParameters).Invoke(message, cancellationToken, messageContext, next)); + return this; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Builders/PipelineBuilderExtensions.cs b/src/OpenMessage/Pipelines/Builders/PipelineBuilderExtensions.cs new file mode 100644 index 0000000..fccad5f --- /dev/null +++ b/src/OpenMessage/Pipelines/Builders/PipelineBuilderExtensions.cs @@ -0,0 +1,148 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenMessage.Pipelines.Endpoints; +using OpenMessage.Pipelines.Middleware; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Builders +{ + /// + /// Helpers for configuring a + /// + public static class PipelineBuilderExtensions + { + #region Use + + /// + /// Adds , , , to the pipeline + /// + public static IPipelineBuilder UseDefaultMiddleware(this IPipelineBuilder builder) => builder.UseTracing() + .UseServiceScope() + .UseTimeout() + .UseAutoAcknowledge(); + + /// + /// Adds a middleware step into the pipeline + /// + public static IPipelineBuilder Use(this IPipelineBuilder builder, Func, CancellationToken, MessageContext, Func, CancellationToken, MessageContext, Task>, Task> middleware) + { + return builder.Use(next => + { + return (message, cancellationToken, messageContext) => + { + return middleware(message, cancellationToken, messageContext, (m, ct, ctx) => next(m, ct, ctx)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IPipelineBuilder Use(this IPipelineBuilder builder, Func, CancellationToken, MessageContext, Func, Task> middleware) + { + return builder.Use(next => + { + return (message, cancellationToken, messageContext) => + { + return middleware(message, cancellationToken, messageContext, () => next(message, cancellationToken, messageContext)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IPipelineBuilder Use(this IPipelineBuilder builder, Func, CancellationToken, Func, CancellationToken, Task>, Task> middleware) + { + return builder.Use(next => + { + return (message, cancellationToken, messageContext) => + { + return middleware(message, cancellationToken, (m, ctx) => next(m, cancellationToken, messageContext)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IPipelineBuilder Use(this IPipelineBuilder builder, Func, CancellationToken, Func, Task> middleware) + { + return builder.Use(next => + { + return (message, cancellationToken, messageContext) => + { + return middleware(message, cancellationToken, () => next(message, cancellationToken, messageContext)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IPipelineBuilder Use(this IPipelineBuilder builder, Func, Func, Task>, Task> middleware) + { + return builder.Use(next => + { + return (message, cancellationToken, messageContext) => + { + return middleware(message, m => next(m, cancellationToken, messageContext)); + }; + }); + } + + /// + /// Adds a middleware step into the pipeline + /// + public static IPipelineBuilder Use(this IPipelineBuilder builder, Func, Func, Task> middleware) + { + return builder.Use(next => + { + return (message, cancellationToken, messageContext) => + { + return middleware(message, () => next(message, cancellationToken, messageContext)); + }; + }); + } + + #endregion + + #region Run + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + public static void Run(this IPipelineBuilder builder, Func, CancellationToken, MessageContext, Task> action) + { + builder.Run(() => + { + return (message, cancellationToken, messageContext) => action(message, cancellationToken, messageContext); + }); + } + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + public static void Run(this IPipelineBuilder builder, Func, CancellationToken, Task> action) + { + builder.Run(() => + { + return (message, cancellationToken, messageContext) => action(message, cancellationToken); + }); + } + + /// + /// Ends the pipeline by executing the provided endpoint. Defaults to + /// + public static void Run(this IPipelineBuilder builder, Func, Task> action) + { + builder.Run(() => + { + return (message, cancellationToken, messageContext) => action(message); + }); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Endpoints/BatchHandlerPipelineEndpoint.cs b/src/OpenMessage/Pipelines/Endpoints/BatchHandlerPipelineEndpoint.cs new file mode 100644 index 0000000..acfbe39 --- /dev/null +++ b/src/OpenMessage/Pipelines/Endpoints/BatchHandlerPipelineEndpoint.cs @@ -0,0 +1,40 @@ +using OpenMessage.Handlers; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Endpoints +{ + /// + /// Calls all and + /// + public class BatchHandlerPipelineEndpoint : IBatchPipelineEndpoint + { + private readonly IEnumerable> _batchHandlers; + private readonly IEnumerable> _handlers; + + /// + /// ctor + /// + public BatchHandlerPipelineEndpoint(IEnumerable> handlers, IEnumerable> batchHandlers) + { + _handlers = handlers; + _batchHandlers = batchHandlers; + } + + /// + /// Calls all handlers with the batch of messages + /// + public Task Invoke(IReadOnlyCollection> messages, CancellationToken cancellationToken, MessageContext messageContext) => Task.WhenAll(Invoke(messages, cancellationToken)); + + private IEnumerable Invoke(IReadOnlyCollection> messages, CancellationToken cancellationToken) + { + foreach (var handler in _batchHandlers) + yield return handler.HandleAsync(messages, cancellationToken); + + foreach (var message in messages) + foreach (var handler in _handlers) + yield return handler.HandleAsync(message, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Endpoints/BatchPipelineEndpoint.cs b/src/OpenMessage/Pipelines/Endpoints/BatchPipelineEndpoint.cs new file mode 100644 index 0000000..5fb6414 --- /dev/null +++ b/src/OpenMessage/Pipelines/Endpoints/BatchPipelineEndpoint.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenMessage.Pipelines.Builders; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Endpoints +{ + internal sealed class BatchPipelineEndpoint : BatcherBase>, IPipelineEndpoint + { + private readonly IBatchPipelineBuilder _batchPipelineBuilder; + private readonly IServiceScopeFactory _serviceScopeFactory; + + public BatchPipelineEndpoint(IServiceScopeFactory serviceScopeFactory, IBatchPipelineBuilder batchPipelineBuilder, IOptions> options) + : base(options.Value.BatchSize, options.Value.BatchTimeout) + { + _serviceScopeFactory = serviceScopeFactory; + _batchPipelineBuilder = batchPipelineBuilder; + } + + /// + /// > + public async Task Invoke(Message message, CancellationToken cancellationToken, MessageContext messageContext) + { + await BatchAsync(message); + } + + /// + /// When the batch is full, then build a batch pipeline and pass it through + /// + protected override async Task OnBatchAsync(IReadOnlyCollection> batch) + { + var batchPipeline = _batchPipelineBuilder.Build(); + + using var scope = _serviceScopeFactory.CreateScope(); + + //batches no longer support their cancellation token + await batchPipeline(batch, new CancellationToken(), new MessageContext(scope.ServiceProvider)); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Endpoints/HandlerPipelineEndpoint.cs b/src/OpenMessage/Pipelines/Endpoints/HandlerPipelineEndpoint.cs new file mode 100644 index 0000000..21d3412 --- /dev/null +++ b/src/OpenMessage/Pipelines/Endpoints/HandlerPipelineEndpoint.cs @@ -0,0 +1,35 @@ +using OpenMessage.Handlers; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Endpoints +{ + /// + /// Calls all + /// + public class HandlerPipelineEndpoint : IPipelineEndpoint + { + private readonly IHandler[] _handlers; + + /// + /// ctor + /// + public HandlerPipelineEndpoint(IEnumerable> handlers) => _handlers = handlers.ToArray(); + + /// + /// Calls all handlers with the given message + /// + public Task Invoke(Message message, CancellationToken cancellationToken, MessageContext messageContext) + { + if (_handlers.Length == 0) + Throw.Exception("No handlers found for type: " + TypeCache.FriendlyName); + + if (_handlers.Length == 1) + return _handlers[0].HandleAsync(message, cancellationToken); + + return Task.WhenAll(_handlers.Select(x => x.HandleAsync(message, cancellationToken))); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Endpoints/IBatchPipelineEndpoint.cs b/src/OpenMessage/Pipelines/Endpoints/IBatchPipelineEndpoint.cs new file mode 100644 index 0000000..f92e234 --- /dev/null +++ b/src/OpenMessage/Pipelines/Endpoints/IBatchPipelineEndpoint.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Endpoints +{ + /// + /// Ends the batch pipeline + /// + public interface IBatchPipelineEndpoint + { + /// + /// Process a collection of messages + /// + Task Invoke(IReadOnlyCollection> messages, CancellationToken cancellationToken, MessageContext messageContext); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Endpoints/IPipelineEndpoint.cs b/src/OpenMessage/Pipelines/Endpoints/IPipelineEndpoint.cs new file mode 100644 index 0000000..a0ad59d --- /dev/null +++ b/src/OpenMessage/Pipelines/Endpoints/IPipelineEndpoint.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Endpoints +{ + /// + /// Ends the batch pipeline + /// + public interface IPipelineEndpoint + { + /// + /// Process a message + /// + Task Invoke(Message message, CancellationToken cancellationToken, MessageContext messageContext); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/MessageContext.cs b/src/OpenMessage/Pipelines/MessageContext.cs new file mode 100644 index 0000000..58f6f5d --- /dev/null +++ b/src/OpenMessage/Pipelines/MessageContext.cs @@ -0,0 +1,20 @@ +using System; + +namespace OpenMessage.Pipelines +{ + /// + /// The context that the message is being executed with + /// + public class MessageContext + { + /// + /// The service provider for this context + /// + public IServiceProvider ServiceProvider { get; } + + /// + /// ctor + /// + public MessageContext(IServiceProvider serviceProvider) => ServiceProvider = serviceProvider; + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/AutoAcknowledgeMiddleware.cs b/src/OpenMessage/Pipelines/Middleware/AutoAcknowledgeMiddleware.cs new file mode 100644 index 0000000..daebba4 --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/AutoAcknowledgeMiddleware.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Automatically acknowledges any messages that implement at the end of the pipeline + /// + public class AutoAcknowledgeMiddleware : Middleware + { + /// + protected override async Task OnInvoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next) + { + try + { + await next(message, cancellationToken, messageContext); + + if (message is ISupportAcknowledgement acknowledgement) + await acknowledgement.AcknowledgeAsync(); + } + catch (Exception e) + { + if (message is ISupportAcknowledgement acknowledgement) + await acknowledgement.AcknowledgeAsync(false, e); + + throw; + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/BatchMiddleware.cs b/src/OpenMessage/Pipelines/Middleware/BatchMiddleware.cs new file mode 100644 index 0000000..906d3a9 --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/BatchMiddleware.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Base class for handling null messages and expired cancellation tokens + /// + public abstract class BatchMiddleware : IBatchMiddleware + { + /// + public Task Invoke(IReadOnlyCollection> messages, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.BatchMiddleware next) + { + if (messages is null) + Throw.ArgumentNullException(nameof(messages)); + + if (messageContext is null) + Throw.ArgumentNullException(nameof(messageContext)); + + cancellationToken.ThrowIfCancellationRequested(); + + return OnInvoke(messages, cancellationToken, messageContext, next); + } + + /// + /// Invokes the middleware + /// + /// The messages to handle + /// The current cancellation token + /// The context of the message + /// The next middleware to run + protected abstract Task OnInvoke(IReadOnlyCollection> messages, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.BatchMiddleware next); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/IBatchMiddleware.cs b/src/OpenMessage/Pipelines/Middleware/IBatchMiddleware.cs new file mode 100644 index 0000000..6960513 --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/IBatchMiddleware.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Middleware contract for a batched pipeline + /// + public interface IBatchMiddleware + { + /// + /// Invokes the middleware + /// + /// The messages to handle + /// The current cancellation token + /// The context of the message + /// The next middleware to run + Task Invoke(IReadOnlyCollection> messages, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.BatchMiddleware next); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/IMiddleware.cs b/src/OpenMessage/Pipelines/Middleware/IMiddleware.cs new file mode 100644 index 0000000..a4b75d2 --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/IMiddleware.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Middleware contract for a pipeline + /// + public interface IMiddleware + { + /// + /// Invokes the middleware + /// + /// The message to handle + /// The current cancellation token + /// The context of the message + /// The next middleware to run + Task Invoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/LoggerScopeMiddleware.cs b/src/OpenMessage/Pipelines/Middleware/LoggerScopeMiddleware.cs new file mode 100644 index 0000000..60409c7 --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/LoggerScopeMiddleware.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Starts a new logger scope using the if it exists + /// + public class LoggerScopeMiddleware : Middleware + { + private static readonly string ScopePrefix = "MessageId"; + private readonly ILogger> _logger; + + /// + public LoggerScopeMiddleware(ILogger> logger) => _logger = logger; + + /// + protected override async Task OnInvoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next) + { + IDisposable? scope = null; + + if (message is ISupportIdentification identifier && !string.IsNullOrEmpty(identifier?.Id)) + scope = _logger.BeginScope(new KeyValuePair(ScopePrefix, identifier.Id)); + + using (scope) + { + await next(message, cancellationToken, messageContext); + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/Middleware.cs b/src/OpenMessage/Pipelines/Middleware/Middleware.cs new file mode 100644 index 0000000..a2db6b1 --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/Middleware.cs @@ -0,0 +1,34 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Base class for handling null messages and expired cancellation tokens + /// + public abstract class Middleware : IMiddleware + { + /// + public Task Invoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next) + { + if (message is null) + Throw.ArgumentNullException(nameof(message)); + + if (messageContext is null) + Throw.ArgumentNullException(nameof(messageContext)); + + cancellationToken.ThrowIfCancellationRequested(); + + return OnInvoke(message, cancellationToken, messageContext, next); + } + + /// + /// Invokes the middleware + /// + /// The message to handle + /// The current cancellation token + /// The context of the message + /// The next middleware to run + protected abstract Task OnInvoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/PipelineDelegate.cs b/src/OpenMessage/Pipelines/Middleware/PipelineDelegate.cs new file mode 100644 index 0000000..51d993d --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/PipelineDelegate.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Delegates for representing pipeline middleware + /// + public static class PipelineDelegate + { + /// + /// Represents an invokable pipeline for batches of messages + /// + public delegate Task BatchMiddleware(IReadOnlyCollection> messages, CancellationToken cancellationToken, MessageContext context); + + /// + /// Represents an invokable pipeline for messages + /// + public delegate Task SingleMiddleware(Message message, CancellationToken cancellationToken, MessageContext context); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/ServiceScopeMiddleware.cs b/src/OpenMessage/Pipelines/Middleware/ServiceScopeMiddleware.cs new file mode 100644 index 0000000..50abea7 --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/ServiceScopeMiddleware.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Creates a new service scope + /// + public class ServiceScopeMiddleware : Middleware + { + /// + protected override async Task OnInvoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next) + { + using var scope = messageContext.ServiceProvider.CreateScope(); + await next(message, cancellationToken, new MessageContext(scope.ServiceProvider)); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/TimeoutMiddleware.cs b/src/OpenMessage/Pipelines/Middleware/TimeoutMiddleware.cs new file mode 100644 index 0000000..3a3f636 --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/TimeoutMiddleware.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Options; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Triggers the cancellation token after a given timeout + /// + public class TimeoutMiddleware : Middleware + { + private readonly IOptionsMonitor> _optionsMonitor; + + /// + public TimeoutMiddleware(IOptionsMonitor> optionsMonitor) => _optionsMonitor = optionsMonitor; + + /// + protected override async Task OnInvoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next) + { + using var timedCts = new CancellationTokenSource(_optionsMonitor.CurrentValue.PipelineTimeout); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timedCts.Token); + + await next(message, cts.Token, messageContext); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Middleware/TraceMiddleware.cs b/src/OpenMessage/Pipelines/Middleware/TraceMiddleware.cs new file mode 100644 index 0000000..c1f47e1 --- /dev/null +++ b/src/OpenMessage/Pipelines/Middleware/TraceMiddleware.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Middleware +{ + /// + /// Adds an activity trace and starts a logger scope + /// + public class TraceMiddleware : Middleware + { + private static readonly string ConsumeActivityName = "OpenMessage.Consumer.Process"; + + /// + protected override async Task OnInvoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next) + { + if (TryGetActivityId(message, out var activityId)) + using (Trace.WithActivity(ConsumeActivityName, activityId)) + await next(message, cancellationToken, messageContext); + else + await next(message, cancellationToken, messageContext); + } + + private static bool TryGetActivityId(Message message, [NotNullWhen(true)] out string? activityId) + { + activityId = null; + + switch (message) + { + case ISupportProperties p: + { + if (p.Properties is IDictionary dictionary) + if (dictionary.TryGetValue(KnownProperties.ActivityId, out activityId) && !string.IsNullOrWhiteSpace(activityId)) + return true; + else + { + activityId = null; // Reset here because the value maybe whitespace + return false; + } + + // Fallback for other versions that aren't a dictionary + foreach (var prop in p.Properties) + if (prop.Key == KnownProperties.ActivityId && !string.IsNullOrWhiteSpace(prop.Value)) + { + activityId = prop.Value; + return true; + } + + break; + } + case ISupportProperties p2: + { + foreach (var prop in p2.Properties) + if (prop.Key == KnownProperties.ActivityId && prop.Value?.Length > 0) + { + var id = Encoding.UTF8.GetString(prop.Value); + if (!string.IsNullOrWhiteSpace(id)) + { + activityId = id; + return true; + } + } + + break; + } + } + + return false; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/PipelineOptions.cs b/src/OpenMessage/Pipelines/PipelineOptions.cs new file mode 100644 index 0000000..98bf590 --- /dev/null +++ b/src/OpenMessage/Pipelines/PipelineOptions.cs @@ -0,0 +1,41 @@ +using System; + +namespace OpenMessage.Pipelines +{ + /// + /// The options for general configuration of pipelines + /// + /// The type of message in the pipeline + public class PipelineOptions + { + /// + /// The maximum size of each batch + /// + public int BatchSize { get; set; } = 100; + + /// + /// The timeout before an undersized (less than ) batch is created. + /// + public TimeSpan BatchTimeout { get; set; } = TimeSpan.FromMilliseconds(100); + + /// + /// The number of messages to allow in the bounded channel. + /// + public int? BoundedChannelLimit { get; set; } + + /// + /// The time it takes before the cancellation token is triggered. Default: 5 seconds + /// + public TimeSpan PipelineTimeout { get; set; } + + /// + /// Determines the pipeline type to use. Default: Parallel. + /// + public PipelineType PipelineType { get; set; } = PipelineType.Parallel; + + /// + /// Determines whether or not to use a bounded channel. + /// + public bool? UseBoundedChannel { get; set; } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/PipelineOptionsPostConfigurationProvider.cs b/src/OpenMessage/Pipelines/PipelineOptionsPostConfigurationProvider.cs new file mode 100644 index 0000000..71647fd --- /dev/null +++ b/src/OpenMessage/Pipelines/PipelineOptionsPostConfigurationProvider.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Options; +using System; + +namespace OpenMessage.Pipelines +{ + internal sealed class PipelineOptionsPostConfigurationProvider : IPostConfigureOptions> + { + public void PostConfigure(string name, PipelineOptions options) + { + if (options.PipelineTimeout == default) + options.PipelineTimeout = TimeSpan.FromSeconds(5); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/PipelineType.cs b/src/OpenMessage/Pipelines/PipelineType.cs new file mode 100644 index 0000000..018f947 --- /dev/null +++ b/src/OpenMessage/Pipelines/PipelineType.cs @@ -0,0 +1,18 @@ +namespace OpenMessage.Pipelines +{ + /// + /// The type of the pipeline + /// + public enum PipelineType + { + /// + /// Each message in the pipeline is handled sequentially. Batching will produces batches of 1. + /// + Serial, + + /// + /// Each message in the pipeline is handled in a new task + /// + Parallel + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Pumps/ConsumerPump.cs b/src/OpenMessage/Pipelines/Pumps/ConsumerPump.cs new file mode 100644 index 0000000..e251a17 --- /dev/null +++ b/src/OpenMessage/Pipelines/Pumps/ConsumerPump.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OpenMessage.Pipelines.Builders; +using OpenMessage.Pipelines.Middleware; +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Pumps +{ + /// + /// The base type for providing a consumer of the internal messaging channel + /// + /// The type that is contained in the message + public class ConsumerPump : BackgroundService + { + private readonly ChannelReader> _channelReader; + private readonly ILogger> _logger; + private readonly IOptionsMonitor> _options; + private readonly IServiceProvider _serviceProvider; + private PipelineDelegate.SingleMiddleware _pipeline; + + /// + /// ctor + /// + public ConsumerPump(ChannelReader> channelReader, IPipelineBuilder pipelineBuilder, IServiceProvider serviceProvider, ILogger> logger, IOptionsMonitor> options) + { + _channelReader = channelReader ?? throw new ArgumentNullException(nameof(channelReader)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _pipeline = (pipelineBuilder ?? throw new ArgumentNullException(nameof(pipelineBuilder))).Build(); + } + + /// + public override Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation($"Starting consumer pump: {GetType().GetFriendlyName()}"); + + return base.StartAsync(cancellationToken); + } + + /// + public override Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation($"Stopping consumer pump: {GetType().GetFriendlyName()}"); + + return base.StopAsync(cancellationToken); + } + + /// + protected sealed override async Task ExecuteAsync(CancellationToken cancellationToken) + { + // Without this line we can encounter a blocking issue such as: https://github.com/dotnet/extensions/issues/2816 + await Task.Yield(); + + try + { + while (!cancellationToken.IsCancellationRequested && !_channelReader.Completion.IsCompleted) + try + { + if (_channelReader.TryRead(out var message)) + { + // TODO :: We don't need to check this every time, we just change the implementation when the options changes and use a field to represent the option we want to use. + if (_options.CurrentValue.PipelineType == PipelineType.Serial) + await InvokePipeline(message, cancellationToken); + else + _ = InvokePipeline(message, cancellationToken); + } + else + { + // Console.WriteLine("Waiting for message"); + await _channelReader.WaitToReadAsync(cancellationToken); + } + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + _logger.LogError(ex, ex.Message); + } + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + } + } + + private async Task InvokePipeline(Message message, CancellationToken cancellationToken) + { + try + { + await _pipeline(message, cancellationToken, new MessageContext(_serviceProvider)); + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Pipelines/Pumps/MessagePump{T}.cs b/src/OpenMessage/Pipelines/Pumps/MessagePump{T}.cs new file mode 100644 index 0000000..e4950f9 --- /dev/null +++ b/src/OpenMessage/Pipelines/Pumps/MessagePump{T}.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +namespace OpenMessage.Pipelines.Pumps +{ + /// + /// Defines the basis of a message pump + /// + /// The type produced by the message pump + public abstract class MessagePump : BackgroundService + { + /// + /// The writable channel to use + /// + protected ChannelWriter> ChannelWriter { get; } + + /// + /// The logger to use + /// + protected ILogger Logger { get; } + + /// + /// ctor + /// + protected MessagePump(ChannelWriter> channelWriter, ILogger logger) + { + ChannelWriter = channelWriter ?? throw new ArgumentNullException(nameof(channelWriter)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public override Task StartAsync(CancellationToken cancellationToken) + { + Logger.LogInformation($"Starting message pump: {GetType().GetFriendlyName()}"); + + return base.StartAsync(cancellationToken); + } + + /// + public override Task StopAsync(CancellationToken cancellationToken) + { + Logger.LogInformation($"Stopping message pump: {GetType().GetFriendlyName()}"); + + return base.StopAsync(cancellationToken); + } + + /// + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + // Without this line we can encounter a blocking issue such as: https://github.com/dotnet/extensions/issues/2816 + await Task.Yield(); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await ConsumeAsync(cancellationToken); + } + catch (Exception e) + { + if (!cancellationToken.IsCancellationRequested) + { + Logger.LogError(e, e.Message); + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); // TODO : make this configurable + } + } + } + } + + protected abstract Task ConsumeAsync(CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Properties/AssemblyAttributes.cs b/src/OpenMessage/Properties/AssemblyAttributes.cs new file mode 100644 index 0000000..a7f711d --- /dev/null +++ b/src/OpenMessage/Properties/AssemblyAttributes.cs @@ -0,0 +1,15 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] +[assembly: InternalsVisibleTo("OpenMessage.Samples.Core")] +[assembly: InternalsVisibleTo("OpenMessage.Tests")] +[assembly: InternalsVisibleTo("OpenMessage.Apache.Kafka")] +[assembly: InternalsVisibleTo("OpenMessage.AWS.SQS")] +[assembly: InternalsVisibleTo("OpenMessage.AWS.SNS")] +[assembly: InternalsVisibleTo("OpenMessage.Azure.EventHubs")] +[assembly: InternalsVisibleTo("OpenMessage.Azure.ServiceBus")] +[assembly: InternalsVisibleTo("OpenMessage.EventStore")] +[assembly: InternalsVisibleTo("OpenMessage.NATS")] +[assembly: InternalsVisibleTo("OpenMessage.RabbitMq")] +[assembly: InternalsVisibleTo("OpenMessage.Redis")] +[assembly: InternalsVisibleTo("OpenMessage.Serializer.JsonDotNet")] diff --git a/src/OpenMessage/Properties/AssemblyInfo.cs b/src/OpenMessage/Properties/AssemblyInfo.cs deleted file mode 100644 index e1ff374..0000000 --- a/src/OpenMessage/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("OpenMessage")] -[assembly: AssemblyTrademark("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("7d68a283-6d90-4a50-b015-d8c2bb5c7184")] -[assembly: InternalsVisibleTo("OpenMessage.Tests")] -[assembly: InternalsVisibleTo("OpenMessage.Providers.Azure")] -[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/OpenMessage/Serialization/DefaultDeserializer.cs b/src/OpenMessage/Serialization/DefaultDeserializer.cs new file mode 100644 index 0000000..69c2c48 --- /dev/null +++ b/src/OpenMessage/Serialization/DefaultDeserializer.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace OpenMessage.Serialization +{ + internal sealed class DefaultDeserializer : IDeserializer + { + private JsonSerializerOptions _settings; + + public IEnumerable SupportedContentTypes { get; } = new[] {"application/json"}; + + public DefaultDeserializer(IOptionsMonitor settings) + { + _settings = settings.Get(SerializationConstants.DeserializerSettings); + } + + public T From(string data, Type messageType) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + return (T)JsonSerializer.Deserialize(data, messageType, _settings); + } + + public T From(byte[] data, Type messageType) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + return (T)JsonSerializer.Deserialize(data, messageType, _settings); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Serialization/DefaultSerializer.cs b/src/OpenMessage/Serialization/DefaultSerializer.cs new file mode 100644 index 0000000..870ddb8 --- /dev/null +++ b/src/OpenMessage/Serialization/DefaultSerializer.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace OpenMessage.Serialization +{ + internal sealed class DefaultSerializer : ISerializer + { + private JsonSerializerOptions _settings; + public string ContentType { get; } = "application/json"; + + public DefaultSerializer(IOptionsMonitor settings) + { + _settings = settings.Get(SerializationConstants.SerializerSettings); + } + + public byte[] AsBytes(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return JsonSerializer.SerializeToUtf8Bytes(entity, _settings); + } + + public string AsString(T entity) + { + if (entity is null) + Throw.ArgumentNullException(nameof(entity)); + + return JsonSerializer.Serialize(entity, _settings); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Serialization/DeserializationProvider.cs b/src/OpenMessage/Serialization/DeserializationProvider.cs new file mode 100644 index 0000000..8c56c5c --- /dev/null +++ b/src/OpenMessage/Serialization/DeserializationProvider.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace OpenMessage.Serialization +{ + internal sealed class DeserializationProvider : IDeserializationProvider + { + private readonly Dictionary _deserializers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public DeserializationProvider(IEnumerable deserializers) + { + if (deserializers is null) + Throw.ArgumentNullException(nameof(deserializers)); + + foreach (var deserializer in deserializers) + foreach (var contentType in deserializer.SupportedContentTypes) + _deserializers[contentType] = deserializer; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: MaybeNull] + public T From(string data, string contentType, string type) + { + if (string.IsNullOrWhiteSpace(data)) + Throw.ArgumentException(nameof(data), "Cannot be null, empty or whitespace"); + + if (string.IsNullOrWhiteSpace(contentType)) + Throw.ArgumentException(nameof(contentType), "Cannot be null, empty or whitespace"); + + return GetDeserializer(contentType).From(data, GetType(type)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: MaybeNull] + public T From(byte[] data, string contentType, string type) + { + if (data is null || data.Length == 0) + Throw.ArgumentException(nameof(data), "Cannot be null or empty"); + + if (string.IsNullOrWhiteSpace(contentType)) + Throw.ArgumentException(nameof(contentType), "Cannot be null, empty or whitespace"); + + return GetDeserializer(contentType).From(data, GetType(type)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private IDeserializer GetDeserializer(string contentType) + { + if (!_deserializers.TryGetValue(contentType, out var deserializer)) + Throw.Exception($"No deserializer registered for content type: {contentType}. Registered types: {string.Join(", ", _deserializers.Keys)}"); + + return deserializer; + } + + private static Type GetType(string type) + { + if (string.IsNullOrWhiteSpace(type)) + if (TypeCache.IsAbstractOrInterface) + Throw.Exception("Cannot deserialize because type is abstract and no data type supplied."); + else + { + var name = TypeCache.AssemblyQualifiedName; + if (string.IsNullOrWhiteSpace(name)) + Throw.Exception("Cannot deserialize because the assembly qualified name cannot be null, empty or whitespace"); + type = name; + } + + if (TypeCache.TryGetType(type, out var deserializedType) && deserializedType != null) + { + var tempType = typeof(T); + if ((deserializedType == tempType || tempType.IsAssignableFrom(deserializedType)) && !deserializedType.IsInterface && !deserializedType.IsAbstract) + return deserializedType; + + Throw.Exception( $"Cannot deserialize type '{type}' to '{TypeCache.AssemblyQualifiedName}'"); + return default; + } + + Throw.Exception($"Cannot find matching type: {type}"); + return default; + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Serialization/IDeserializationProvider.cs b/src/OpenMessage/Serialization/IDeserializationProvider.cs new file mode 100644 index 0000000..36cb72a --- /dev/null +++ b/src/OpenMessage/Serialization/IDeserializationProvider.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; + +namespace OpenMessage.Serialization +{ + /// + /// Negotiates the deserializer to use based on a content type + /// + public interface IDeserializationProvider + { + /// + /// Deserializes from the specified string to the desired T + /// + /// The data to deserialize + /// The content type contained in data + /// The type contained within data + /// The type to deserialize to + /// An instance of T + [return: MaybeNull] + T From(string data, string contentType, string type); + + /// + /// Deserializes from the specified byte array to the desired T + /// + /// The data to deserialize + /// The content type contained in the data + /// The type contained within data + /// The type to deserialize to + /// An instance of T + [return: MaybeNull] + T From(byte[] data, string contentType, string type); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Serialization/IDeserializer.cs b/src/OpenMessage/Serialization/IDeserializer.cs new file mode 100644 index 0000000..66bd463 --- /dev/null +++ b/src/OpenMessage/Serialization/IDeserializer.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace OpenMessage.Serialization +{ + /// + /// An instance of a deserializer + /// + public interface IDeserializer + { + /// + /// Determines which content types are supported by this deserializer + /// + IEnumerable SupportedContentTypes { get; } + + /// + /// Deserializes the data to a given T + /// + /// The type to convert to + /// The data to convert from + /// The original type of the message + /// An instance of T + [return: MaybeNull] + T From(string data, Type messageType); + + /// + /// Deserializes the data to a given T + /// + /// The type to convert to + /// The data to convert from + /// The original type of the message + /// An instance of T + [return: MaybeNull] + T From(byte[] data, Type messageType); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Serialization/ISerializer.cs b/src/OpenMessage/Serialization/ISerializer.cs new file mode 100644 index 0000000..888bf19 --- /dev/null +++ b/src/OpenMessage/Serialization/ISerializer.cs @@ -0,0 +1,29 @@ +namespace OpenMessage.Serialization +{ + /// + /// A serializer for a given entity + /// + public interface ISerializer + { + /// + /// Content type that's returned, eg: application/json + /// + string ContentType { get; } + + /// + /// Serializes to a byte[] + /// + /// The entity type + /// The entity + /// A byte array with the serialized entity + byte[] AsBytes(T entity); + + /// + /// Serializes to a string + /// + /// The entity type + /// The entity + /// A string with the serialized entity + string AsString(T entity); + } +} \ No newline at end of file diff --git a/src/OpenMessage/Serialization/SerializationConstants.cs b/src/OpenMessage/Serialization/SerializationConstants.cs new file mode 100644 index 0000000..690f6f4 --- /dev/null +++ b/src/OpenMessage/Serialization/SerializationConstants.cs @@ -0,0 +1,17 @@ +namespace OpenMessage.Serialization +{ + /// + /// Constants for the OpenMessage serialization framework + /// + public static class SerializationConstants + { + /// + /// Settings for the OpenMessage Deserializers + /// + public static readonly string DeserializerSettings = "OpenMessageDeserializer"; + /// + /// Settings for the OpenMessage Serializers + /// + public static readonly string SerializerSettings = "OpenMessageSerializer"; + } +} \ No newline at end of file diff --git a/src/OpenMessage/ServiceExtensions.cs b/src/OpenMessage/ServiceExtensions.cs deleted file mode 100644 index f898f3a..0000000 --- a/src/OpenMessage/ServiceExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using System; - -namespace OpenMessage -{ - public static class ServiceExtensions - { - /// - /// Creates an observer from the specified action. - /// - public static IServiceCollection AddObserver(this IServiceCollection services, Action action) - { - if (services == null) - throw new ArgumentNullException(nameof(services)); - - if (action == null) - throw new ArgumentNullException(nameof(action)); - - return services.AddScoped>(sp => new ActionObserver(action)); - } - } -} diff --git a/src/OpenMessage/Throw.cs b/src/OpenMessage/Throw.cs new file mode 100644 index 0000000..81c0e42 --- /dev/null +++ b/src/OpenMessage/Throw.cs @@ -0,0 +1,69 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace OpenMessage +{ + /// + /// Helpers for throwing an exception to aid in the ability for smaller methods to inline + /// + public static class Throw + { + /// + /// Throws a ArgumentException + /// + /// The exception + [DoesNotReturn] + public static void ArgumentException() + { + throw new ArgumentException(); + } + + /// + /// Throws a ArgumentException + /// + /// The parameter name to use + /// The message to to use in the exception + /// The exception with a specified parameter name & message + [DoesNotReturn] + public static void ArgumentException(string paramName, string message) + { + throw new ArgumentException(paramName, message); + } + + /// + /// Throws a ArgumentNullException + /// + /// The exception + [DoesNotReturn] + public static void ArgumentNullException() + { + throw new ArgumentNullException(); + } + + /// + /// Throws a ArgumentNullException + /// + /// The parameter name to use + /// The message to to use in the exception + /// The exception with a specified message, if applicable + [DoesNotReturn] + public static void ArgumentNullException(string paramName, string message = "Parameter cannot be null.") + { + throw new ArgumentNullException(paramName, message); + } + + /// + /// Throws a Exception + /// + /// The message to to use in the exception + /// The exception with a specified message, if applicable + [DoesNotReturn] + public static void Exception(string? message = null) + { + if (message is null) + throw new Exception(); + + throw new Exception(message); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/Trace.cs b/src/OpenMessage/Trace.cs new file mode 100644 index 0000000..533bfe9 --- /dev/null +++ b/src/OpenMessage/Trace.cs @@ -0,0 +1,56 @@ +using System; +using System.Diagnostics; + +namespace OpenMessage +{ + /// + /// Helper for writing out activities to the ecosystem + /// + public static class Trace + { + private static readonly DiagnosticListener _listener = new DiagnosticListener("OpenMessage"); + + /// + /// Records an activity with the specified operation name + /// + /// The name to give to the activity + /// The parent of the activity if not known + public static ActivityTracer WithActivity(string operationName, string? parentId = null) + { + if (string.IsNullOrWhiteSpace(operationName)) + Throw.ArgumentException(nameof(operationName), "Cannot be null, empty or whitespace"); + + var activity = new Activity(operationName); + + if (parentId is {}) + activity.SetParentId(parentId); + + _listener.StartActivity(activity, AnonymousObject.Empty); + + return new ActivityTracer(_listener, activity); + } + + /// + /// Automatically records the end of an activity on dispose + /// + public struct ActivityTracer : IDisposable + { + private readonly DiagnosticListener _listener; + private readonly Activity _activity; + + internal ActivityTracer(DiagnosticListener listener, Activity activity) + { + _listener = listener; + _activity = activity; + } + + /// + /// Dispose... + /// + public void Dispose() + { + _listener?.StopActivity(_activity, AnonymousObject.Empty); + } + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/TypeCache.cs b/src/OpenMessage/TypeCache.cs new file mode 100644 index 0000000..fdda92e --- /dev/null +++ b/src/OpenMessage/TypeCache.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace OpenMessage +{ + public static class TypeCache + { + private static ConcurrentDictionary _types = new ConcurrentDictionary(); + + public static bool TryGetType(string typeName, [NotNullWhen(true)] out Type? type) + { + if (_types.TryGetValue(typeName, out type)) + return true; + + var originalTypeName = typeName; + var splitIndex = typeName.IndexOf(",", StringComparison.Ordinal); + if (splitIndex > 0) + { + var nextSplitIndex = typeName.IndexOf(",", Math.Min(typeName.Length, splitIndex + 1), StringComparison.Ordinal); + typeName = nextSplitIndex == -1 + ? typeName.Substring(0, splitIndex) + : typeName.Substring(0, nextSplitIndex); + } + + type = Type.GetType(typeName); + + if (type != null) + _types.TryAdd(originalTypeName, type); + + return type != null; + } + } + + /// + /// Fast access to certain type properties + /// + public static class TypeCache + { + private static readonly TypeInfo _type = typeof(T).GetTypeInfo(); + + /// + /// The friendly name of the class, with expanded generics + /// + public static string? FriendlyName = _type.GetFriendlyName(); + + /// + /// True, if the type is a class + /// + public static bool IsReferenceType = _type.IsClass; + + /// + /// The assembly qualified name of the type + /// + public static string? AssemblyQualifiedName = _type.AssemblyQualifiedName; + + /// + /// True, if the type is abstract + /// + public static bool IsAbstract = _type.IsAbstract; + + /// + /// True, if the type is abstract or if the type is an interface + /// + public static bool IsAbstractOrInterface = _type.IsAbstract || _type.IsInterface; + + /// + /// True, if the type is an interface + /// + public static bool IsInterface = _type.IsInterface; + + /// + /// The name of the class + /// + public static string? Name = _type.Name; + } +} \ No newline at end of file diff --git a/src/OpenMessage/TypeNames.cs b/src/OpenMessage/TypeNames.cs new file mode 100644 index 0000000..8cfaa90 --- /dev/null +++ b/src/OpenMessage/TypeNames.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; + +namespace OpenMessage +{ + internal static class TypeNames + { + private static readonly ConcurrentDictionary _friendlyNames = new ConcurrentDictionary(); + + public static string GetFriendlyName(this Type type) + { + if (type is null) + Throw.ArgumentNullException(nameof(type)); + + return _friendlyNames.GetOrAdd(type!, key => + { + if (key.IsGenericType) + return $"{key.Namespace}.{key.Name.Remove(key.Name.IndexOf('`'))}<{string.Join(", ", key.GetGenericArguments().Select(GetFriendlyName))}>"; + + return $"{key.Namespace}.{key.Name}"; + }); + } + } +} \ No newline at end of file diff --git a/src/OpenMessage/project.json b/src/OpenMessage/project.json deleted file mode 100644 index 544afd0..0000000 --- a/src/OpenMessage/project.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "version": "0.0.2", - "title": "OpenMessage", - "authors": [ "Im5tu", "Stuart Blackler" ], - "description": "OpenMessage is an easy to use abstraction for sending and receiving messages between applications.", - "packOptions": { - "owners": [ "Im5tu" ], - "tags": [ "OpenMessage", "Messaging", "ServiceBus", "aspnetcore", "netstandard" ], - "projectUrl": "https://github.com/Im5tu/OpenMessage", - "requireLicenseAcceptance": false, - "repository": { - "type": "git", - "url": "https://github.com/Im5tu/OpenMessage.git" - } - }, - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "1.0.0", - "Microsoft.Extensions.Logging.Abstractions": "1.0.0" - }, - "frameworks": { - "netstandard1.5": { - "imports": "dnxcore50", - "dependencies": { - "NETStandard.Library": "1.6.0" - } - }, - "net451": { - "frameworkAssemblies": { - "System.Runtime": { "type": "build" } - } - } - } -} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..e033a85 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + disable + + \ No newline at end of file diff --git a/tests/Directory.Build.targets b/tests/Directory.Build.targets new file mode 100644 index 0000000..0752edf --- /dev/null +++ b/tests/Directory.Build.targets @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/tests/OpenMessage.MediatR.Tests/MediatRTests.cs b/tests/OpenMessage.MediatR.Tests/MediatRTests.cs new file mode 100644 index 0000000..6bea93c --- /dev/null +++ b/tests/OpenMessage.MediatR.Tests/MediatRTests.cs @@ -0,0 +1,105 @@ +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenMessage.Pipelines.Builders; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace OpenMessage.MediatR.Tests +{ + public class MediatRTests : IDisposable, IAsyncLifetime + { + private readonly IList _history = new List(); + private readonly IHostBuilder _hostBuilder; + private IHost _app; + + public MediatRTests(ITestOutputHelper testOutputHelper) + { + _hostBuilder = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddMediatR(typeof(MediatRTests).Assembly) + .AddSingleton(_ => _history); + }) + .ConfigureMessaging(builder => + { + builder.ConfigureMemory() + .Build(); + + builder.ConfigurePipeline() + .UseDefaultMiddleware() + .Use(async (message, next) => + { + _history.Add("Middleware"); + await next(); + _history.Add("Middleware"); + }) + .Batch() + .RunMediatR(); + }) + .ConfigureServices(services => + { + services.AddAwaitableMemoryDispatcher(); + }); + } + + public void Dispose() + { + _app?.Dispose(); + } + + public Task DisposeAsync() => _app?.StopAsync(); + + public Task InitializeAsync() => Task.CompletedTask; + + [Fact] + public async Task MediatRHandlersAreCalled() + { + _app = _hostBuilder.Build(); + + await _app.StartAsync(); + + await _app.Services.GetRequiredService>() + .DispatchAsync(""); + + var i = 0; + Assert.Equal("Middleware", _history.ElementAtOrDefault(i++)); + Assert.Equal(nameof(BatchHandler), _history.ElementAtOrDefault(i++)); + Assert.Equal(nameof(Handler), _history.ElementAtOrDefault(i++)); + Assert.Equal("Middleware", _history.ElementAtOrDefault(i++)); + } + + private class Handler : INotificationHandler> + { + private readonly IList _history; + + public Handler(IList history) => _history = history; + + public Task Handle(MediatRMessage notification, CancellationToken cancellationToken) + { + _history.Add(nameof(Handler)); + + return Task.CompletedTask; + } + } + + private class BatchHandler : INotificationHandler> + { + private readonly IList _history; + + public BatchHandler(IList history) => _history = history; + + public Task Handle(MediatRBatch notification, CancellationToken cancellationToken) + { + _history.Add(nameof(BatchHandler)); + + return Task.CompletedTask; + } + } + } +} \ No newline at end of file diff --git a/tests/OpenMessage.MediatR.Tests/OpenMessage.MediatR.Tests.csproj b/tests/OpenMessage.MediatR.Tests/OpenMessage.MediatR.Tests.csproj new file mode 100644 index 0000000..053162e --- /dev/null +++ b/tests/OpenMessage.MediatR.Tests/OpenMessage.MediatR.Tests.csproj @@ -0,0 +1,30 @@ + + + + netcoreapp3.0 + + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/tests/OpenMessage.Providers.Azure.Tests/Configuration/OpenMessageAzureProviderOptionsConfiguratorTests.cs b/tests/OpenMessage.Providers.Azure.Tests/Configuration/OpenMessageAzureProviderOptionsConfiguratorTests.cs deleted file mode 100644 index b2205bd..0000000 --- a/tests/OpenMessage.Providers.Azure.Tests/Configuration/OpenMessageAzureProviderOptionsConfiguratorTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Options; -using Moq; -using OpenMessage.Providers.Azure.Configuration; -using System; -using Xunit; - -namespace OpenMessage.Providers.Azure.Tests.Configuration -{ - public class OpenMessageAzureProviderOptionsConfiguratorTests - { - public class Constructor - { - [Fact] - public void GivenNullDefaultOptionsThrowArgumentNullException() - { - Action act = () => new OpenMessageAzureProviderOptionsConfigurator(null); - - act.ShouldThrow(); - } - } - - public class ConfigureOptions - { - [Fact] - public void GivenAOptionSetOverridesDefaultValues() - { - var defaultOptions = new OpenMessageAzureProviderOptions - { - ConnectionString = "default" - }; - var mockOptions = new Mock>>(); - mockOptions.Setup(x => x.Value).Returns(defaultOptions); - var target = new OpenMessageAzureProviderOptionsConfigurator(mockOptions.Object); - - var testOptions = new OpenMessageAzureProviderOptions(); - target.Configure(testOptions); - - testOptions.ConnectionString.Should().Be(defaultOptions.ConnectionString); - } - } - } -} diff --git a/tests/OpenMessage.Providers.Azure.Tests/OpenMessage.Providers.Azure.Tests.xproj b/tests/OpenMessage.Providers.Azure.Tests/OpenMessage.Providers.Azure.Tests.xproj deleted file mode 100644 index 457ef53..0000000 --- a/tests/OpenMessage.Providers.Azure.Tests/OpenMessage.Providers.Azure.Tests.xproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - 48b14e17-6fe3-4b0d-bacc-966d4273c07b - OpenMessage.Providers.Azure.Tests - .\obj - .\bin\ - v4.5.2 - - - 2.0 - - - - - - \ No newline at end of file diff --git a/tests/OpenMessage.Providers.Azure.Tests/Properties/AssemblyInfo.cs b/tests/OpenMessage.Providers.Azure.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 54f573e..0000000 --- a/tests/OpenMessage.Providers.Azure.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("OpenMessage.Providers.Azure.Tests")] -[assembly: AssemblyTrademark("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("48b14e17-6fe3-4b0d-bacc-966d4273c07b")] diff --git a/tests/OpenMessage.Providers.Azure.Tests/Serialization/SerializationProviderTests.cs b/tests/OpenMessage.Providers.Azure.Tests/Serialization/SerializationProviderTests.cs deleted file mode 100644 index 5ffab83..0000000 --- a/tests/OpenMessage.Providers.Azure.Tests/Serialization/SerializationProviderTests.cs +++ /dev/null @@ -1,110 +0,0 @@ -using FluentAssertions; -using Moq; -using OpenMessage.Providers.Azure.Serialization; -using System; -using System.IO; -using System.Linq; -using Xunit; - -namespace OpenMessage.Providers.Azure.Tests.Serialization -{ - public class SerializationProviderTests - { - public class Constructor - { - [Fact] - public void GivenANullProviderEnumerationThrowArgumentNullException() - { - Action act = () => new SerializationProvider(null, new Mock().Object); - - act.ShouldThrow(); - } - - [Fact] - public void GivenANullDefaultSerializerThrowArgumentNullException() - { - Action act = () => new SerializationProvider(Enumerable.Empty(), null); - - act.ShouldThrow(); - } - } - - public class Serialize - { - [Fact] - public void GivenANullEntityThrowArgumentNullException() - { - var target = new SerializationProvider(Enumerable.Empty(), new Mock().Object); - - Action act = () => target.Serialize(null); - - act.ShouldThrow(); - } - - [Fact] - public void GivenAnEntityThenSerializesWithTheDefaultSerializer() - { - var serializer = new Mock(); - var target = new SerializationProvider(Enumerable.Empty(), serializer.Object); - - target.Serialize("test")?.Dispose(); - - serializer.Verify(s => s.Serialize(It.IsAny()), Times.Once); - } - } - - public class Deserialize - { - [Fact] - public void GivenANullEntityThrowArgumentNullException() - { - var target = new SerializationProvider(Enumerable.Empty(), new Mock().Object); - - Action act = () => target.Deserialize(null); - - act.ShouldThrow(); - } - - [Fact] - public void GivenATypeThatCanBeDeserializedThenDeserializesToTheCorrectType() - { - var serializer = new Mock(); - var serializer2 = new Mock(); - serializer.Setup(s => s.TypeName).Returns("testing"); - serializer.Setup(s => s.Deserialize(It.IsAny())).Returns("test"); - serializer2.Setup(s => s.TypeName).Returns("testing2"); - var target = new SerializationProvider(new[] { serializer.Object, serializer2.Object }, serializer.Object); - - target.Deserialize(new Microsoft.ServiceBus.Messaging.BrokeredMessage("test") - { - ContentType = "testing" - }).Should().Be("test"); - } - - [Fact] - public void GivenATypeThatCantBeDeserializedThenThrowException() - { - var serializer = new Mock(); - serializer.Setup(s => s.TypeName).Returns("testing"); - var target = new SerializationProvider(new[] { serializer.Object }, serializer.Object); - - Action act = () => target.Deserialize(new Microsoft.ServiceBus.Messaging.BrokeredMessage("test") - { - ContentType = "testing2" - }); - - act.ShouldThrow(); - } - - [Fact] - public void GivenANullContentTypeOnTheBrokeredMessageThenThrowException() - { - var target = new SerializationProvider(Enumerable.Empty(), new Mock().Object); - - Action act = () => target.Deserialize(new Microsoft.ServiceBus.Messaging.BrokeredMessage()); - - act.ShouldThrow(); - } - } - } -} diff --git a/tests/OpenMessage.Providers.Azure.Tests/ServiceExtensionTests.cs b/tests/OpenMessage.Providers.Azure.Tests/ServiceExtensionTests.cs deleted file mode 100644 index d710cb1..0000000 --- a/tests/OpenMessage.Providers.Azure.Tests/ServiceExtensionTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using OpenMessage.Providers.Azure.Serialization; -using System.Linq; -using Xunit; - -namespace OpenMessage.Providers.Azure.Tests -{ - public class ServiceExtensionTests - { - public class AddOpenMessage - { - [Fact] - public void GivenAServiceCollectionThenAddsSerializationProvider() - { - var services = new ServiceCollection().AddOpenMessage(); - - services.Any(service => service.ServiceType == typeof(ISerializationProvider)).Should().BeTrue(); - } - } - } -} diff --git a/tests/OpenMessage.Providers.Azure.Tests/project.json b/tests/OpenMessage.Providers.Azure.Tests/project.json deleted file mode 100644 index e931f91..0000000 --- a/tests/OpenMessage.Providers.Azure.Tests/project.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "version": "1.0.0-*", - "dependencies": { - "dotnet-test-xunit": "2.2.0-preview2-build1029", - "FluentAssertions": "4.14.0", - "Microsoft.Extensions.DependencyInjection": "1.0.0", - "Microsoft.Extensions.Logging": "1.0.0", - "Moq": "4.6.25-alpha", - "OpenMessage.Providers.Azure": { "target": "project" }, - "xunit": "2.2.0-beta2-build3300" - }, - "testRunner": "xunit", - "frameworks": { - "net451": {} - } -} diff --git a/tests/OpenMessage.Testing.Tests/Memory/MemoryTests.cs b/tests/OpenMessage.Testing.Tests/Memory/MemoryTests.cs new file mode 100644 index 0000000..d586ace --- /dev/null +++ b/tests/OpenMessage.Testing.Tests/Memory/MemoryTests.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenMessage.Pipelines.Builders; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace OpenMessage.Testing.Tests.Memory +{ + public class MemoryTests : IDisposable, IAsyncLifetime + { + private readonly IHostBuilder _hostBuilder; + + private IHost _app; + private bool _finished; + + public MemoryTests() + { + _hostBuilder = Host.CreateDefaultBuilder() + .ConfigureMessaging(builder => + { + builder.ConfigureMemory() + .Build(); + + builder.ConfigurePipeline() + .UseDefaultMiddleware() + .Run(message => + { + _finished = true; + + return Task.CompletedTask; + }); + }); + } + + public void Dispose() + { + _app?.Dispose(); + } + + public Task DisposeAsync() => _app.StopAsync(); + + public Task InitializeAsync() => Task.CompletedTask; + + [Fact] + public async Task WhenAwaitableMemoryDispatcherIsAdded_ThenAwaitingTheDispatchOfTheEventWillWaitForTheConsumerToFinish() + { + _app = _hostBuilder.ConfigureServices(services => + { + services.AddAwaitableMemoryDispatcher(); + }) + .Build(); + + await _app.StartAsync(); + + await _app.Services.GetRequiredService>() + .DispatchAsync(""); + + Assert.True(_finished); + } + + [Fact] + public async Task WhenAwaitableMemoryDispatcherIsNotAdded_ThenAwaitingTheDispatchOfTheEventWillNotWaitForTheConsumerToFinish() + { + _app = _hostBuilder.Build(); + + await _app.StartAsync(); + + await _app.Services.GetRequiredService>() + .DispatchAsync(""); + + Assert.False(_finished); + } + } +} \ No newline at end of file diff --git a/tests/OpenMessage.Testing.Tests/OpenMessage.Testing.Tests.csproj b/tests/OpenMessage.Testing.Tests/OpenMessage.Testing.Tests.csproj new file mode 100644 index 0000000..b8267a7 --- /dev/null +++ b/tests/OpenMessage.Testing.Tests/OpenMessage.Testing.Tests.csproj @@ -0,0 +1,29 @@ + + + + netcoreapp3.0 + + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/OpenMessage.Tests/ActionObserverTests.cs b/tests/OpenMessage.Tests/ActionObserverTests.cs deleted file mode 100644 index 70fc681..0000000 --- a/tests/OpenMessage.Tests/ActionObserverTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using FluentAssertions; -using System; -using Xunit; - -namespace OpenMessage.Tests -{ - public class ActionObserverTests - { - public class Constructor - { - [Fact] - public void GivenANullActionThrowsArgumentNullException() - { - Action act = () => new ActionObserver(null); - - act.ShouldThrow(); - } - } - - public class OnNext - { - [Fact] - public void GivenANullEntityThrowArgumentNullException() - { - Action callback = str => { }; - var target = new ActionObserver(callback); - - Action act = () => target.OnNext(null); - } - - [Fact] - public void GivenOnCompleteHasBeenCalledThenCallbackIsNotInvoked() - { - var callbackInvoked = false; - Action callback = str => { callbackInvoked = true; }; - var target = new ActionObserver(callback); - - target.OnCompleted(); - target.OnNext(""); - - callbackInvoked.Should().BeFalse(); - } - - [Fact] - public void GivenAnEntityIsSuppliedThenCallbackIsInvoked() - { - var callbackInvoked = false; - Action callback = str => { callbackInvoked = true; }; - var target = new ActionObserver(callback); - - target.OnNext(""); - - callbackInvoked.Should().BeTrue(); - } - } - } -} diff --git a/tests/OpenMessage.Tests/BatchPipelineTests.cs b/tests/OpenMessage.Tests/BatchPipelineTests.cs new file mode 100644 index 0000000..a38235e --- /dev/null +++ b/tests/OpenMessage.Tests/BatchPipelineTests.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenMessage.Pipelines; +using OpenMessage.Pipelines.Builders; +using OpenMessage.Pipelines.Middleware; +using OpenMessage.Tests.Helpers; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace OpenMessage.Tests +{ + public class BatchPipelineTests : IDisposable, IAsyncLifetime + { + private readonly IList _history = new List(); + private readonly IHostBuilder _host; + + private IHost _app; + private Func>, Task> _run; + + public BatchPipelineTests(ITestOutputHelper testOutputHelper) + { + _host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(_history); + }) + .ConfigureLogging(builder => builder.AddTestOutputHelper(testOutputHelper)) + .ConfigureMessaging(builder => + { + builder.ConfigureMemory() + .Build(); + + builder.ConfigurePipeline() + .UseDefaultMiddleware() + .Batch() + .Use() + .Use(async (messages, next) => + { + _history.Add("BatchFunc"); + await next(); + _history.Add("BatchFunc"); + }) + .Run(async messages => + { + _history.Add("Run"); + + if (_run is {}) + await _run(messages); + }); + }) + .ConfigureServices(services => + { + services.AddAwaitableMemoryDispatcher(); + }); + } + + [Fact] + public async Task BatchMiddlewareAndRunAreExecutedInTheCorrectOrder() + { + _app = _host.Build(); + + await _app.StartAsync(); + + await _app.Services.GetRequiredService>() + .DispatchAsync(""); + + var i = 0; + + Assert.Equal(nameof(CustomBatchMiddleware), _history[i++]); + Assert.Equal("BatchFunc", _history[i++]); + Assert.Equal("Run", _history[i++]); + Assert.Equal("BatchFunc", _history[i++]); + Assert.Equal(nameof(CustomBatchMiddleware), _history[i++]); + } + + public void Dispose() + { + _app?.Dispose(); + } + + public Task DisposeAsync() => _app?.StopAsync(); + + public Task InitializeAsync() => Task.CompletedTask; + + [Fact] + public async Task WhenAnExceptionIsThrown_ThenTheMessageIsNotPositivelyAcknowledged() + { + _app = _host.Build(); + _run = messages => throw new Exception(); + + var message = new CustomMessage(); + + await _app.StartAsync(); + + try + { + await _app.Services.GetRequiredService>() + .DispatchAsync(message); + } + catch { } // Expected + finally + { + Assert.Equal(AcknowledgementState.NegativelyAcknowledged, message.AcknowledgementState); + } + } + + private class CustomMessage : Message, ISupportAcknowledgement + { + public AcknowledgementState AcknowledgementState { get; private set; } + + public Task AcknowledgeAsync(bool positivelyAcknowledge = true, Exception exception = null) + { + AcknowledgementState = positivelyAcknowledge ? AcknowledgementState.Acknowledged : AcknowledgementState.NegativelyAcknowledged; + + return Task.CompletedTask; + } + } + + private class CustomBatchMiddleware : IBatchMiddleware + { + private readonly IList _history; + private readonly ILogger _logger; + + public CustomBatchMiddleware(ILogger logger, IList history) + { + _logger = logger; + _history = history; + } + + public async Task Invoke(IReadOnlyCollection> messages, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.BatchMiddleware next) + { + _logger.LogInformation($"Before {nameof(CustomBatchMiddleware)}"); + _history.Add(nameof(CustomBatchMiddleware)); + + await next(messages, cancellationToken, messageContext); + + _history.Add(nameof(CustomBatchMiddleware)); + _logger.LogInformation($"After {nameof(CustomBatchMiddleware)}"); + } + } + } +} \ No newline at end of file diff --git a/tests/OpenMessage.Tests/DispatchExtensionsTests.cs b/tests/OpenMessage.Tests/DispatchExtensionsTests.cs deleted file mode 100644 index 94c44e3..0000000 --- a/tests/OpenMessage.Tests/DispatchExtensionsTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentAssertions; -using System; -using Xunit; - -namespace OpenMessage.Tests -{ - public class DispatchExtensionsTests - { - public class DispatchAsync - { - [Fact] - public void GivenANullDispatcherThrowArgumentNullException() - { - Action act = () => DispatcherExtensions.DispatchAsync(default(IDispatcher), "test"); - - act.ShouldThrow(); - } - } - } -} diff --git a/tests/OpenMessage.Tests/Helpers/LoggingBuilderExtensions.cs b/tests/OpenMessage.Tests/Helpers/LoggingBuilderExtensions.cs new file mode 100644 index 0000000..19e927b --- /dev/null +++ b/tests/OpenMessage.Tests/Helpers/LoggingBuilderExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace OpenMessage.Tests.Helpers +{ + internal static class LoggingBuilderExtensions + { + public static ILoggingBuilder AddTestOutputHelper(this ILoggingBuilder builder, ITestOutputHelper testOutputHelper) + { + builder.Services.AddSingleton(new TestOutputHelperLoggerProvider(testOutputHelper)); + + return builder; + } + } +} \ No newline at end of file diff --git a/tests/OpenMessage.Tests/Helpers/TestOutputHelperLoggerProvider.cs b/tests/OpenMessage.Tests/Helpers/TestOutputHelperLoggerProvider.cs new file mode 100644 index 0000000..b4a3a2d --- /dev/null +++ b/tests/OpenMessage.Tests/Helpers/TestOutputHelperLoggerProvider.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Logging; +using System; +using Xunit.Abstractions; + +namespace OpenMessage.Tests.Helpers +{ + internal sealed class TestOutputHelperLoggerProvider : ILoggerProvider + { + private readonly ITestOutputHelper _testOutputHelper; + + public TestOutputHelperLoggerProvider(ITestOutputHelper testOutputHelper) => _testOutputHelper = testOutputHelper; + + public ILogger CreateLogger(string categoryName) => new TestOutputHelperLogger(_testOutputHelper); + + public void Dispose() { } + + private class TestOutputHelperLogger : ILogger + { + private readonly ITestOutputHelper _testOutputHelper; + + public TestOutputHelperLogger(ITestOutputHelper testOutputHelper) => _testOutputHelper = testOutputHelper; + + public IDisposable BeginScope(TState state) + { + _testOutputHelper.WriteLine($"Begin Scope: {state}"); + + return new ActionDisposable(() => _testOutputHelper.WriteLine($"End Scope: {state}")); + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + _testOutputHelper.WriteLine($"Level: {logLevel}, EventId: {eventId}, Message: {formatter(state, exception)}"); + } + } + + private class ActionDisposable : IDisposable + { + private readonly Action _action; + + public ActionDisposable(Action action) => _action = action; + + public void Dispose() + { + _action(); + } + } + } +} \ No newline at end of file diff --git a/tests/OpenMessage.Tests/MessageBrokerTests.cs b/tests/OpenMessage.Tests/MessageBrokerTests.cs deleted file mode 100644 index 5f3a8b3..0000000 --- a/tests/OpenMessage.Tests/MessageBrokerTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.Logging; -using Moq; -using System; -using System.Collections.Generic; -using System.Linq; -using Xunit; - -namespace OpenMessage.Tests -{ - public class MessageBrokerTests - { - public class Constructor - { - [Fact] - public void GivenANullObserverCollectionThrowArgumentNullException() - { - Action act = () => new MessageBroker(null, Enumerable.Empty>(), new Mock>>().Object); - - act.ShouldThrow(); - } - - [Fact] - public void GivenANullObservableCollectionThrowArgumentNullException() - { - Action act = () => new MessageBroker(Enumerable.Empty>(), null, new Mock>>().Object); - - act.ShouldThrow(); - } - - [Fact] - public void GivenANullLoggerThrowArgumentNullException() - { - Action act = () => new MessageBroker(Enumerable.Empty>(), Enumerable.Empty>(), null); - - act.ShouldThrow(); - } - } - - public class Subscribe - { - private readonly MessageBroker _target = new MessageBroker(Enumerable.Empty>(), Enumerable.Empty>(), new Mock>>().Object); - - [Fact] - public void GivenANullObserverThrowArgumentNullException() - { - Action act = () => _target.Subscribe(null); - - act.ShouldThrow(); - } - - [Fact] - public void GivenASubscriptionIsAddedThenDoesNotReturnNull() - { - _target.Subscribe(new Mock>().Object).Should().NotBeNull(); - } - - [Fact] - public void GivenMultipleSubscriptionsThenCallsEachSubscriberOnNextMessage() - { - var subscriber1 = new Mock>(); - var subscriber2 = new Mock>(); - var producer = new FakeProducer(); - - using (var target = new MessageBroker(new[] { subscriber1.Object, subscriber2.Object }, new[] { producer }, new Mock>>().Object)) - producer.Trigger(); - - subscriber1.Verify(x => x.OnNext("test"), Times.Once); - subscriber2.Verify(x => x.OnNext("test"), Times.Once); - } - - [Fact] - public void GivenMultipleProducersThenCallsEachSubscriberOnNextMessage() - { - var subscriber1 = new Mock>(); - var subscriber2 = new Mock>(); - var producer1 = new FakeProducer(); - var producer2 = new FakeProducer(); - - using (var target = new MessageBroker(new[] { subscriber1.Object, subscriber2.Object }, new[] { producer1, producer2 }, new Mock>>().Object)) - { - producer1.Trigger("1"); - producer2.Trigger("2"); - } - - subscriber1.Verify(x => x.OnNext("1"), Times.Once); - subscriber2.Verify(x => x.OnNext("1"), Times.Once); - subscriber1.Verify(x => x.OnNext("2"), Times.Once); - subscriber2.Verify(x => x.OnNext("2"), Times.Once); - } - - private class FakeProducer : IObservable - { - private HashSet> _observers = new HashSet>(); - - public IDisposable Subscribe(IObserver observer) - { - _observers.Add(observer); - return new Disposable(() => _observers.Remove(observer)); - } - - internal void Trigger(string message = "test") - { - foreach (var observer in _observers) - observer.OnNext(message); - } - } - } - - public class Dispose - { - [Fact] - public void GivenMultiplePiecesOfTelemetryRecordedThenRecordsOnDispose() - { - var observer = new Mock>(); - var _target = new MessageBroker(new[] { observer.Object }, Enumerable.Empty>(), new Mock>>().Object); - - using (_target.Subscribe(observer.Object)) - _target.Dispose(); - - observer.Verify(x => x.OnCompleted(), Times.Once); - } - } - } -} diff --git a/tests/OpenMessage.Tests/NestedDispatchingTests.cs b/tests/OpenMessage.Tests/NestedDispatchingTests.cs new file mode 100644 index 0000000..9cca84b --- /dev/null +++ b/tests/OpenMessage.Tests/NestedDispatchingTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenMessage.Pipelines.Builders; +using OpenMessage.Tests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace OpenMessage.Tests +{ + public class NestedDispatchingTests : IDisposable, IAsyncLifetime + { + private readonly IList _history = new List(); + private readonly IHostBuilder _host; + + private IHost _app; + + public NestedDispatchingTests(ITestOutputHelper testOutputHelper) + { + _host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddLogging(); + services.AddSingleton(_history); + }) + .ConfigureLogging(builder => builder.AddTestOutputHelper(testOutputHelper)) + .ConfigureMessaging(builder => + { + builder.ConfigureMemory() + .Build(); + + builder.ConfigurePipeline() + .UseDefaultMiddleware() + .Batch() + .Run(async (messages, cancellationToken, context) => + { + var message = messages.Single().Value; + + _history.Add($"Start {message}"); + + if (message == "Hello") + { + var dispatcher = context.ServiceProvider.GetRequiredService>(); + + await dispatcher.DispatchAsync("World"); + } + + + _history.Add($"End {message}"); + }); + }) + .ConfigureServices(services => + { + services.AddAwaitableMemoryDispatcher(); + }); + } + + public void Dispose() + { + _app?.Dispose(); + } + + public Task DisposeAsync() => _app?.StopAsync(); + + public Task InitializeAsync() => Task.CompletedTask; + + [Fact] + public async Task WhenAHandlerDispatchesAnEvent_ThenThatEventCanBeHandled() + { + _app = _host.Build(); + + await _app.StartAsync(); + + await _app.Services.GetRequiredService>().DispatchAsync("Hello"); + + + var i = 0; + + Assert.Equal("Start Hello", _history[i++]); + Assert.Equal("Start World", _history[i++]); + Assert.Equal("End World", _history[i++]); + Assert.Equal("End Hello", _history[i++]); + } + } +} \ No newline at end of file diff --git a/tests/OpenMessage.Tests/OpenMessage.Tests.csproj b/tests/OpenMessage.Tests/OpenMessage.Tests.csproj new file mode 100644 index 0000000..4a164bf --- /dev/null +++ b/tests/OpenMessage.Tests/OpenMessage.Tests.csproj @@ -0,0 +1,29 @@ + + + + netcoreapp3.0 + + false + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/OpenMessage.Tests/OpenMessage.Tests.xproj b/tests/OpenMessage.Tests/OpenMessage.Tests.xproj deleted file mode 100644 index 715184b..0000000 --- a/tests/OpenMessage.Tests/OpenMessage.Tests.xproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - 14.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - d5c758b5-055c-41f0-a339-c8e4141076eb - OpenMessage.Tests - .\obj - .\bin\ - v4.5.2 - - - 2.0 - - - - - - \ No newline at end of file diff --git a/tests/OpenMessage.Tests/PipelineTests.cs b/tests/OpenMessage.Tests/PipelineTests.cs new file mode 100644 index 0000000..3d08034 --- /dev/null +++ b/tests/OpenMessage.Tests/PipelineTests.cs @@ -0,0 +1,148 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenMessage.Pipelines; +using OpenMessage.Pipelines.Builders; +using OpenMessage.Pipelines.Middleware; +using OpenMessage.Tests.Helpers; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace OpenMessage.Tests +{ + public class PipelineTests : IDisposable, IAsyncLifetime + { + private readonly IList _history = new List(); + private readonly IHostBuilder _host; + + private IHost _app; + private Func, Task> _run; + + public PipelineTests(ITestOutputHelper testOutputHelper) + { + _host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(_history); + }) + .ConfigureLogging(builder => builder.AddTestOutputHelper(testOutputHelper)) + .ConfigureMessaging(builder => + { + builder.ConfigureMemory() + .Build(); + + builder.ConfigurePipeline() + .UseDefaultMiddleware() + .Use() + .Use(async (message, next) => + { + _history.Add("Func"); + await next(); + _history.Add("Func"); + }) + .Run(async message => + { + _history.Add("Run"); + + if (_run is {}) + await _run(message); + }); + }) + .ConfigureServices(services => + { + services.AddAwaitableMemoryDispatcher(); + }); + } + + public void Dispose() + { + _app?.Dispose(); + } + + public Task DisposeAsync() => _app?.StopAsync(); + + public Task InitializeAsync() => Task.CompletedTask; + + [Fact] + public async Task MiddlewareAndRunAreExecutedInTheCorrectOrder() + { + _app = _host.Build(); + + await _app.StartAsync(); + + await _app.Services.GetRequiredService>() + .DispatchAsync(""); + + var i = 0; + + Assert.Equal(nameof(CustomMiddleware), _history[i++]); + Assert.Equal("Func", _history[i++]); + Assert.Equal("Run", _history[i++]); + Assert.Equal("Func", _history[i++]); + Assert.Equal(nameof(CustomMiddleware), _history[i++]); + } + + [Fact] + public async Task WhenAnExceptionIsThrown_ThenTheMessageIsNotPositivelyAcknowledged() + { + _app = _host.Build(); + _run = messages => throw new Exception(); + + var message = new CustomMessage(); + + await _app.StartAsync(); + + try + { + await _app.Services.GetRequiredService>() + .DispatchAsync(message); + } + catch { } // Expected + finally + { + Assert.Equal(AcknowledgementState.NegativelyAcknowledged, message.AcknowledgementState); + } + } + + private class CustomMessage : Message, ISupportAcknowledgement + { + public AcknowledgementState AcknowledgementState { get; private set; } + + public Task AcknowledgeAsync(bool positivelyAcknowledge = true, Exception exception = null) + { + AcknowledgementState = positivelyAcknowledge ? AcknowledgementState.Acknowledged : AcknowledgementState.NegativelyAcknowledged; + + return Task.CompletedTask; + } + } + + private class CustomMiddleware : IMiddleware + { + private readonly IList _history; + private readonly ILogger _logger; + + public CustomMiddleware(ILogger logger, IList history) + { + _logger = logger; + _history = history; + } + + public async Task Invoke(Message message, CancellationToken cancellationToken, MessageContext messageContext, PipelineDelegate.SingleMiddleware next) + { + _logger.LogInformation($"Before {nameof(CustomMiddleware)}"); + _history.Add(nameof(CustomMiddleware)); + + await next(message, cancellationToken, messageContext); + + _history.Add(nameof(CustomMiddleware)); + _logger.LogInformation($"After {nameof(CustomMiddleware)}"); + } + } + } +} \ No newline at end of file diff --git a/tests/OpenMessage.Tests/Pipelines/BatcherTests.cs b/tests/OpenMessage.Tests/Pipelines/BatcherTests.cs new file mode 100644 index 0000000..f233992 --- /dev/null +++ b/tests/OpenMessage.Tests/Pipelines/BatcherTests.cs @@ -0,0 +1,78 @@ +using OpenMessage.Pipelines.Builders; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace OpenMessage.Tests.Pipelines +{ + public class BatcherTests + { + private readonly BatcherBase _batcher; + private readonly int _batchSize = 3; + private readonly IList> _history = new List>(); + private readonly TimeSpan _timeout = TimeSpan.FromSeconds(3); + + public BatcherTests(ITestOutputHelper testOutputHelper) => _batcher = new TestBatcher(testOutputHelper, _history, _timeout, _batchSize); + + [Fact] + public async Task WhenBatchDoesNotFillUpBeforeTimeout_ThenASmallBatchIsProcessedAfterTheTimeout() + { + var stopwatch = new Stopwatch(); + + stopwatch.Start(); + + await Task.WhenAll(Enumerable.Range(0, _batchSize - 1) + .Select(i => _batcher.BatchAsync($"{i}"))); + stopwatch.Stop(); + + Assert.Equal(1, _history.Count); + + Assert.Equal(_batchSize - 1, _history.Single() + .Count); + Assert.True(stopwatch.Elapsed >= _timeout); + } + + [Fact] + public async Task WhenBatchFillsUpBeforeTimeout_ThenItIsProcessedEarly() + { + var stopwatch = new Stopwatch(); + + stopwatch.Start(); + + await Task.WhenAll(Enumerable.Range(0, _batchSize) + .Select(i => _batcher.BatchAsync($"{i}"))); + stopwatch.Stop(); + + Assert.Equal(1, _history.Count); + + Assert.Equal(_batchSize, _history.Single() + .Count); + Assert.True(stopwatch.Elapsed < _timeout); + } + + private class TestBatcher : BatcherBase + { + private readonly IList> _batches; + private readonly ITestOutputHelper _testOutputHelper; + + public TestBatcher(ITestOutputHelper testOutputHelper, IList> batches, TimeSpan timeout, int batchSize) + : base(batchSize, timeout) + { + _testOutputHelper = testOutputHelper; + _batches = batches; + } + + protected override Task OnBatchAsync(IReadOnlyCollection batch) + { + _batches.Add(batch); + _testOutputHelper.WriteLine($"Received batch of {batch.Count} items: {string.Join(", ", batch)}"); + + return Task.CompletedTask; + } + } + } +} \ No newline at end of file diff --git a/tests/OpenMessage.Tests/Properties/AssemblyInfo.cs b/tests/OpenMessage.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 53fe112..0000000 --- a/tests/OpenMessage.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("OpenMessage.Tests")] -[assembly: AssemblyTrademark("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("d5c758b5-055c-41f0-a339-c8e4141076eb")] diff --git a/tests/OpenMessage.Tests/project.json b/tests/OpenMessage.Tests/project.json deleted file mode 100644 index 349a1fb..0000000 --- a/tests/OpenMessage.Tests/project.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "version": "1.0.0", - "dependencies": { - "dotnet-test-xunit": "2.2.0-preview2-build1029", - "FluentAssertions": "4.14.0", - "Microsoft.Extensions.DependencyInjection": "1.0.0", - "Microsoft.Extensions.Logging": "1.0.0", - "Moq": "4.6.25-alpha", - "OpenMessage": { "target": "project" }, - "OpenTelemetry": "1.0.0", - "xunit": "2.2.0-beta2-build3300" - }, - "testRunner": "xunit", - "frameworks": { - "net451": {} - } -}