feat: add inital in memory dispatcher
Some checks failed
default / dotnet-default-workflow (push) Failing after 1m52s

Add a simple in memory dispatcher for scalar requests and stream request.
This commit is contained in:
Louis Seubert 2026-05-08 20:26:26 +02:00
commit fff952a385
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
37 changed files with 3065 additions and 0 deletions

419
.editorconfig Normal file
View file

@ -0,0 +1,419 @@
root = true
[*]
indent_style = tab
indent_size = 4
tab_width = 4
end_of_line = lf
insert_final_newline = false
trim_trailing_whitespace = true
max_line_length = 120
[*.{md,json,yaml,yml}]
indent_size = 2
indent_style = space
trim_trailing_whitespace = false
[*.{csproj,props,targets,slnx}]
indent_size = 2
indent_style = space
[nuget.config]
indent_size = 2
indent_style = space
#### .NET Coding Conventions ####
[*.{cs,vb}]
# Organize usings
file_header_template = Copyright (c) The Geekeey Authors\nSPDX-License-Identifier: EUPL-1.2
dotnet_separate_import_directive_groups = true
dotnet_sort_system_directives_first = true
dotnet_diagnostic.IDE0005.severity = suggestion # https://github.com/dotnet/roslyn/issues/41640
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Expression-level preferences
dotnet_diagnostic.IDE0270.severity = none
dotnet_style_coalesce_expression = true # IDE0029,IDE0030,IDE0270
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_compound_assignment = true
dotnet_diagnostic.IDE0045.severity = suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true # IDE0045
dotnet_diagnostic.IDE0046.severity = suggestion
dotnet_style_prefer_conditional_expression_over_return = true # IDE0046
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
dotnet_style_namespace_match_folder = false # resharper: resharper_check_namespace_highlighting
# Field preferences
dotnet_style_readonly_field = true
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
# ReSharper preferences
resharper_wrap_object_and_collection_initializer_style = chop_always
resharper_check_namespace_highlighting = none
resharper_csharp_wrap_lines = false
#### C# Coding Conventions ####
[*.cs]
# var preferences
csharp_style_var_for_built_in_types = true
csharp_style_var_when_type_is_apparent = true
csharp_style_var_elsewhere = true
# Expression-bodied members
csharp_style_expression_bodied_accessors = true
csharp_style_expression_bodied_constructors = false
csharp_style_expression_bodied_indexers = true
csharp_style_expression_bodied_lambdas = true
csharp_style_expression_bodied_local_functions = false
csharp_style_expression_bodied_methods = false
csharp_style_expression_bodied_operators = false
csharp_style_expression_bodied_properties = true
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Modifier preferences
csharp_prefer_static_local_function = true
csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async
# Code-block preferences
csharp_prefer_braces = true
csharp_prefer_simple_using_statement = true
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_deconstructed_variable_declaration = true
csharp_style_inlined_variable_declaration = true
csharp_style_pattern_local_over_anonymous_function = true
csharp_style_prefer_index_operator = true
csharp_style_prefer_range_operator = true
csharp_style_throw_expression = true
dotnet_diagnostic.IDE0058.severity = suggestion
csharp_style_unused_value_assignment_preference = discard_variable # IDE0058
csharp_style_unused_value_expression_statement_preference = discard_variable # IDE0058
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace
# 'namespace' preferences
csharp_style_namespace_declarations = file_scoped
# 'constructor' preferences
csharp_style_prefer_primary_constructors = false
#### C# Formatting Rules ####
[*.cs]
# New line preferences
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_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_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### .NET Naming styles ####
[*.{cs,vb}]
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types_and_namespaces.required_modifiers =
dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interfaces.required_modifiers =
dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.enums.applicable_kinds = enum
dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.enums.required_modifiers =
dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
dotnet_naming_symbols.type_parameters.required_modifiers =
dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.methods.applicable_kinds = method
dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.methods.required_modifiers =
dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
dotnet_naming_symbols.parameters.applicable_kinds = parameter
dotnet_naming_symbols.parameters.applicable_accessibilities = *
dotnet_naming_symbols.parameters.required_modifiers =
dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.properties.applicable_kinds = property
dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.properties.required_modifiers =
dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.events_should_be_pascalcase.symbols = events
dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.events.applicable_kinds = event
dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.events.required_modifiers =
# local
dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
dotnet_naming_symbols.local_variables.applicable_kinds = local
dotnet_naming_symbols.local_variables.applicable_accessibilities = local
dotnet_naming_symbols.local_variables.required_modifiers =
dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_symbols.local_functions.applicable_accessibilities = *
dotnet_naming_symbols.local_functions.required_modifiers =
dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
dotnet_naming_symbols.local_constants.applicable_kinds = local
dotnet_naming_symbols.local_constants.applicable_accessibilities = local
dotnet_naming_symbols.local_constants.required_modifiers = const
# private
dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_fields.required_modifiers = static
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_constant_fields.required_modifiers = const
# public
dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.public_fields.applicable_kinds = field
dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_fields.required_modifiers =
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_constant_fields.required_modifiers = const
# others
dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascalcase.required_prefix =
dotnet_naming_style.pascalcase.required_suffix =
dotnet_naming_style.pascalcase.word_separator =
dotnet_naming_style.pascalcase.capitalization = pascal_case
dotnet_naming_style.ipascalcase.required_prefix = I
dotnet_naming_style.ipascalcase.required_suffix =
dotnet_naming_style.ipascalcase.word_separator =
dotnet_naming_style.ipascalcase.capitalization = pascal_case
dotnet_naming_style.tpascalcase.required_prefix = T
dotnet_naming_style.tpascalcase.required_suffix =
dotnet_naming_style.tpascalcase.word_separator =
dotnet_naming_style.tpascalcase.capitalization = pascal_case
dotnet_naming_style._camelcase.required_prefix = _
dotnet_naming_style._camelcase.required_suffix =
dotnet_naming_style._camelcase.word_separator =
dotnet_naming_style._camelcase.capitalization = camel_case
dotnet_naming_style.camelcase.required_prefix =
dotnet_naming_style.camelcase.required_suffix =
dotnet_naming_style.camelcase.word_separator =
dotnet_naming_style.camelcase.capitalization = camel_case
dotnet_naming_style.s_camelcase.required_prefix = s_
dotnet_naming_style.s_camelcase.required_suffix =
dotnet_naming_style.s_camelcase.word_separator =
dotnet_naming_style.s_camelcase.capitalization = camel_case
[*.{cs,vb}]
dotnet_analyzer_diagnostic.category-style.severity = warning
dotnet_analyzer_diagnostic.category-design.severity = warning
dotnet_analyzer_diagnostic.category-globalization.severity = notice
dotnet_analyzer_diagnostic.category-naming.severity = warning
dotnet_analyzer_diagnostic.category-performance.severity = warning
dotnet_analyzer_diagnostic.category-reliability.severity = warning
dotnet_analyzer_diagnostic.category-security.severity = warning
dotnet_analyzer_diagnostic.category-usage.severity = warning
dotnet_analyzer_diagnostic.category-maintainability.severity = warning
dotnet_diagnostic.CA1716.severity = none # Identifiers should not match keywords
dotnet_diagnostic.CA1816.severity = suggestion # Dispose methods should call SuppressFinalize
dotnet_diagnostic.CA1848.severity = none # Use the LoggerMessage delegates
dotnet_diagnostic.IDE0210.severity = none # Use top-level statements

View file

@ -0,0 +1,39 @@
name: default
on:
push:
branches: [ "main", "develop" ]
paths-ignore:
- "doc/**"
- "*.md"
pull_request:
branches: [ "main", "develop" ]
paths-ignore:
- "doc/**"
- "*.md"
jobs:
default:
name: dotnet-default-workflow
runs-on: debian-latest
strategy:
matrix:
dotnet-version: [ "10.0" ]
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
steps:
- name: checkout
uses: https://code.geekeey.de/actions/checkout@1
- name: nuget login
run: |
# This token is readonly and can only be used for restore
dotnet nuget update source geekeey --store-password-in-clear-text \
--username "${{ github.actor }}" --password "${{ github.token }}"
- name: dotnet pack
run: |
dotnet pack -p:ContinuousIntegrationBuild=true
- name: dotnet test
run: |
dotnet test -p:ContinuousIntegrationBuild=true

View file

@ -0,0 +1,36 @@
name: release
on:
push:
tags: [ "[0-9]+.[0-9]+.[0-9]+" ]
jobs:
release:
name: dotnet-release-workflow
runs-on: debian-latest
strategy:
matrix:
dotnet-version: [ "10.0" ]
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
steps:
- uses: https://code.geekeey.de/actions/checkout@1
- name: nuget login
run: |
# This token is readonly and can only be used for restore
dotnet nuget update source geekeey --store-password-in-clear-text \
--username "${{ github.actor }}" --password "${{ github.token }}"
- name: dotnet pack
run: |
dotnet pack -p:ContinuousIntegrationBuild=true
- name: dotnet test
run: |
dotnet test -p:ContinuousIntegrationBuild=true
- name: dotnet nuget push
run: |
# The token used here is only intended to publish packages
dotnet nuget push -k "${{ secrets.geekeey_package_registry }}" \
artifacts/package/release/Geekeey.*.nupkg

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
artifacts/
*.DotSettings.user

18
CHANGELOG.md Normal file
View file

@ -0,0 +1,18 @@
# Changelog
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- This is the initial release of the library.
### Changed
### Removed
[1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0
[Unreleased]: https://code.geekeey.de/geekeey/request/compare/1.0.0...HEAD

37
Directory.Build.props Normal file
View file

@ -0,0 +1,37 @@
<Project>
<PropertyGroup Condition="'$(ArtifactsPath)' == ''">
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
</PropertyGroup>
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<VersionPrefix>1.0.0</VersionPrefix>
</PropertyGroup>
<PropertyGroup>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<WarningsAsErrors>nullable</WarningsAsErrors>
<WarningsAsErrors Condition="'$(ContinuousIntegrationBuild)' == 'true'">true</WarningsAsErrors>
</PropertyGroup>
<PropertyGroup Label="NuGet Package Info">
<Authors>The Geekeey Team</Authors>
<Copyright>Copyright (c) The Geekeey Team 2026</Copyright>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.Gitea" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<NuGetAuditMode>all</NuGetAuditMode>
</PropertyGroup>
</Project>

2
Directory.Build.targets Normal file
View file

@ -0,0 +1,2 @@
<Project>
</Project>

12
Directory.Packages.props Normal file
View file

@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.6" />
<PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.102" />
<PackageVersion Include="TUnit" Version="1.11.51" />
</ItemGroup>
</Project>

287
LICENSE.md Normal file
View file

@ -0,0 +1,287 @@
EUROPEAN UNION PUBLIC LICENCE v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the EUPL) applies to the Work (as defined
below) which is provided under the terms of this Licence. Any use of the Work,
other than as authorised under this Licence is prohibited (to the extent such
use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as
defined below) has placed the following notice immediately following the
copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
1. Definitions
In this Licence, the following terms have the following meaning:
- The Licence: this Licence.
- The Original Work: the work or software distributed or communicated by the
Licensor under this Licence, available as Source Code and also as Executable
Code as the case may be.
- Derivative Works: the works or software that could be created by the
Licensee, based upon the Original Work or modifications thereof. This Licence
does not define the extent of modification or dependence on the Original Work
required in order to classify a work as a Derivative Work; this extent is
determined by copyright law applicable in the country mentioned in Article 15.
- The Work: the Original Work or its Derivative Works.
- The Source Code: the human-readable form of the Work which is the most
convenient for people to study and modify.
- The Executable Code: any code which has generally been compiled and which is
meant to be interpreted by a computer as a program.
- The Licensor: the natural or legal person that distributes or communicates
the Work under the Licence.
- Contributor(s): any natural or legal person who modifies the Work under the
Licence, or otherwise contributes to the creation of a Derivative Work.
- The Licensee or You: any natural or legal person who makes any usage of
the Work under the terms of the Licence.
- Distribution or Communication: any act of selling, giving, lending,
renting, distributing, communicating, transmitting, or otherwise making
available, online or offline, copies of the Work or providing access to its
essential functionalities at the disposal of any other natural or legal
person.
2. Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
sublicensable licence to do the following, for the duration of copyright vested
in the Original Work:
- use the Work in any circumstance and for all usage,
- reproduce the Work,
- modify the Work, and make Derivative Works based upon the Work,
- communicate to the public, including the right to make available or display
the Work or copies thereof to the public and perform publicly, as the case may
be, the Work,
- distribute the Work or copies thereof,
- lend and rent the Work or copies thereof,
- sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now
known or later invented, as far as the applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to
exercise his moral right to the extent allowed by law in order to make effective
the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
any patents held by the Licensor, to the extent necessary to make use of the
rights granted on the Work under this Licence.
3. Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as
Executable Code. If the Work is provided as Executable Code, the Licensor
provides in addition a machine-readable copy of the Source Code of the Work
along with each copy of the Work that the Licensor distributes or indicates, in
a notice following the copyright notice attached to the Work, a repository where
the Source Code is easily and freely accessible for as long as the Licensor
continues to distribute or communicate the Work.
4. Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits from
any exception or limitation to the exclusive rights of the rights owners in the
Work, of the exhaustion of those rights or of other applicable limitations
thereto.
5. Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and
obligations imposed on the Licensee. Those obligations are the following:
Attribution right: The Licensee shall keep intact all copyright, patent or
trademarks notices and all notices that refer to the Licence and to the
disclaimer of warranties. The Licensee must include a copy of such notices and a
copy of the Licence with every copy of the Work he/she distributes or
communicates. The Licensee must cause any Derivative Work to carry prominent
notices stating that the Work has been modified and the date of modification.
Copyleft clause: If the Licensee distributes or communicates copies of the
Original Works or Derivative Works, this Distribution or Communication will be
done under the terms of this Licence or of a later version of this Licence
unless the Original Work is expressly distributed only under this version of the
Licence — for example by communicating EUPL v. 1.2 only. The Licensee
(becoming Licensor) cannot offer or impose any additional terms or conditions on
the Work or Derivative Work that alter or restrict the terms of the Licence.
Compatibility clause: If the Licensee Distributes or Communicates Derivative
Works or copies thereof based upon both the Work and another work licensed under
a Compatible Licence, this Distribution or Communication can be done under the
terms of this Compatible Licence. For the sake of this clause, Compatible
Licence refers to the licences listed in the appendix attached to this Licence.
Should the Licensee's obligations under the Compatible Licence conflict with
his/her obligations under this Licence, the obligations of the Compatible
Licence shall prevail.
Provision of Source Code: When distributing or communicating copies of the Work,
the Licensee will provide a machine-readable copy of the Source Code or indicate
a repository where this Source will be easily and freely available for as long
as the Licensee continues to distribute or communicate the Work.
Legal Protection: This Licence does not grant permission to use the trade names,
trademarks, service marks, or names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the copyright notice.
6. Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted
hereunder is owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings
to the Work are owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent
Contributors grant You a licence to their contributions to the Work, under the
terms of this Licence.
7. Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous
Contributors. It is not a finished work and may therefore contain defects or
bugs inherent to this type of development.
For the above reason, the Work is provided under the Licence on an as is basis
and without warranties of any kind concerning the Work, including without
limitation merchantability, fitness for a particular purpose, absence of defects
or errors, accuracy, non-infringement of intellectual property rights other than
copyright as stated in Article 6 of this Licence.
This disclaimer of warranty is an essential part of the Licence and a condition
for the grant of any rights to the Work.
8. Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural
persons, the Licensor will in no event be liable for any direct or indirect,
material or moral, damages of any kind, arising out of the Licence or of the use
of the Work, including without limitation, damages for loss of goodwill, work
stoppage, computer failure or malfunction, loss of data or any commercial
damage, even if the Licensor has been advised of the possibility of such damage.
However, the Licensor will be liable under statutory product liability laws as
far such laws apply to the Work.
9. Additional agreements
While distributing the Work, You may choose to conclude an additional agreement,
defining obligations or services consistent with this Licence. However, if
accepting obligations, You may act only on your own behalf and on your sole
responsibility, not on behalf of the original Licensor or any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor harmless
for any liability incurred by, or claims asserted against such Contributor by
the fact You have accepted any warranty or additional liability.
10. Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon I agree
placed under the bottom of a window displaying the text of this Licence or by
affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable
acceptance of this Licence and all of its terms and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and
conditions by exercising any rights granted to You by Article 2 of this Licence,
such as the use of the Work, the creation by You of a Derivative Work or the
Distribution or Communication by You of the Work or copies thereof.
11. Information to the public
In case of any Distribution or Communication of the Work by means of electronic
communication by You (for example, by offering to download the Work from a
remote location) the distribution channel or media (for example, a website) must
at least provide to the public the information requested by the applicable law
regarding the Licensor, the Licence and the way it may be accessible, concluded,
stored and reproduced by the Licensee.
12. Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon
any breach by the Licensee of the terms of the Licence.
Such a termination will not terminate the licences of any person who has
received the Work from the Licensee under the Licence, provided such persons
remain in full compliance with the Licence.
13. Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete
agreement between the Parties as to the Work.
If any provision of the Licence is invalid or unenforceable under applicable
law, this will not affect the validity or enforceability of the Licence as a
whole. Such provision will be construed or reformed so as necessary to make it
valid and enforceable.
The European Commission may publish other linguistic versions or new versions of
this Licence or updated versions of the Appendix, so far this is required and
reasonable, without reducing the scope of the rights granted by the Licence. New
versions of the Licence will be published with a unique version number.
All linguistic versions of this Licence, approved by the European Commission,
have identical value. Parties can take advantage of the linguistic version of
their choice.
14. Jurisdiction
Without prejudice to specific agreement between parties,
- any litigation resulting from the interpretation of this License, arising
between the European Union institutions, bodies, offices or agencies, as a
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
of Justice of the European Union, as laid down in article 272 of the Treaty on
the Functioning of the European Union,
- any litigation arising between other parties and resulting from the
interpretation of this License, will be subject to the exclusive jurisdiction
of the competent court where the Licensor resides or conducts its primary
business.
15. Applicable Law
Without prejudice to specific agreement between parties,
- this Licence shall be governed by the law of the European Union Member State
where the Licensor has his seat, resides or has his registered office,
- this licence shall be governed by Belgian law if the Licensor has no seat,
residence or registered office inside a European Union Member State.
Appendix
Compatible Licences according to Article 5 EUPL are:
- GNU General Public License (GPL) v. 2, v. 3
- GNU Affero General Public License (AGPL) v. 3
- Open Software License (OSL) v. 2.1, v. 3.0
- Eclipse Public License (EPL) v. 1.0
- CeCILL v. 2.0, v. 2.1
- Mozilla Public Licence (MPL) v. 2
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
works other than software
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the above
licences without producing a new version of the EUPL, as long as they provide
the rights granted in Article 2 of this Licence and protect the covered Source
Code from exclusive appropriation.
All other changes or additions to this Appendix require the production of a new
EUPL version.

0
README.md Normal file
View file

11
global.json Normal file
View file

@ -0,0 +1,11 @@
{
"$schema": "https://www.schemastore.org/global.json",
"sdk": {
"version": "10.0.0",
"rollForward": "latestMinor"
},
"msbuild-sdks": {},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

19
nuget.config Normal file
View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="defaultPushSource" value="geekeey" />
</config>
<packageSources>
<clear />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
<add key="geekeey" value="https://code.geekeey.de/api/packages/geekeey/nuget/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget">
<package pattern="*" />
</packageSource>
<packageSource key="geekeey">
<package pattern="Geekeey.*" />
</packageSource>
</packageSourceMapping>
</configuration>

4
request.slnx Normal file
View file

@ -0,0 +1,4 @@
<Solution>
<Project Path="src/Request/Geekeey.Request.csproj" />
<Project Path="src/Request.Tests/Geekeey.Request.Tests.csproj" />
</Solution>

View file

@ -0,0 +1,9 @@
[*.{cs,vb}]
# disable CA1822: Mark members as static
# -> TUnit requiring instance methods for test cases
dotnet_diagnostic.CA1822.severity = none
# disable CA1707: Identifiers should not contain underscores
dotnet_diagnostic.CA1707.severity = none
# disable IDE0060: Remove unused parameter
dotnet_diagnostic.IDE0060.severity = none

View file

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<PropertyGroup>
<RootNamespace>Geekeey.Request</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" />
<!-- additional packages -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Request\Geekeey.Request.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,140 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Geekeey.Request.Tests;
internal sealed class RequestDispatcherBuilderExtensionsTests
{
[Test]
public async Task I_can_add_a_type_and_register_the_options()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
var type = typeof(TestHandler);
// Act
builder.Add(type);
// Assert
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
var handlers = options.GetRequestHandlers<IScalarRequestHandler<TestRequest, string>>(provider);
await Assert.That(handlers).Count().IsEqualTo(1);
await Assert.That(handlers.First()).IsTypeOf<TestHandler>();
}
[Test]
public async Task I_can_add_a_type_with_a_lifetime_and_register_the_options_and_service()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
var type = typeof(TestHandler);
var lifetime = ServiceLifetime.Scoped;
// Act
builder.Add(type, lifetime);
// Assert
var serviceDescriptor = services.FirstOrDefault(sd => sd.ServiceType == type);
await Assert.That(serviceDescriptor).IsNotNull();
await Assert.That(serviceDescriptor.Lifetime).IsEqualTo(lifetime);
await Assert.That(serviceDescriptor.ImplementationType).IsEqualTo(type);
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
var handlers = options.GetRequestHandlers<IScalarRequestHandler<TestRequest, string>>(provider);
await Assert.That(handlers).Count().IsEqualTo(1);
}
[Test]
public async Task I_can_add_an_enumerable_of_types_and_register_the_options()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
var types = new[] { typeof(TestHandler), typeof(AnotherTestHandler) };
// Act
builder.Add(types);
// Assert
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
var handlers1 = options.GetRequestHandlers<IScalarRequestHandler<TestRequest, string>>(provider);
await Assert.That(handlers1).Count().IsEqualTo(1);
await Assert.That(handlers1.First()).IsTypeOf<TestHandler>();
var handlers2 = options.GetRequestHandlers<IScalarRequestHandler<AnotherTestRequest, string>>(provider);
await Assert.That(handlers2).Count().IsEqualTo(1);
await Assert.That(handlers2.First()).IsTypeOf<AnotherTestHandler>();
}
[Test]
public async Task I_can_add_an_enumerable_of_types_with_a_lifetime_and_register_the_options_and_services()
{
// Arrange
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
var types = new[] { typeof(TestHandler), typeof(AnotherTestHandler) };
var lifetime = ServiceLifetime.Singleton;
// Act
builder.Add(types, lifetime);
// Assert
foreach (var type in types)
{
var serviceDescriptor = services.FirstOrDefault(sd => sd.ServiceType == type);
await Assert.That(serviceDescriptor).IsNotNull();
await Assert.That(serviceDescriptor.Lifetime).IsEqualTo(lifetime);
}
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
await Assert.That(options.GetRequestHandlers<IScalarRequestHandler<TestRequest, string>>(provider)).Count().IsEqualTo(1);
await Assert.That(options.GetRequestHandlers<IScalarRequestHandler<AnotherTestRequest, string>>(provider)).Count().IsEqualTo(1);
}
[Test]
public async Task I_can_see_it_throw_when_the_builder_is_null()
{
IRequestDispatcherBuilder builder = null!;
using (Assert.Multiple())
{
await Assert.That(() => builder.Add(typeof(TestHandler))).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient)).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add([typeof(TestHandler)])).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient)).Throws<ArgumentNullException>();
}
}
private sealed class TestRequest : IScalarRequest<string>;
private sealed class TestHandler : IScalarRequestHandler<TestRequest, string>
{
public Task<string> HandleAsync(TestRequest request, CancellationToken ct)
{
return Task.FromResult("ok");
}
}
private sealed class AnotherTestRequest : IScalarRequest<string>;
private sealed class AnotherTestHandler : IScalarRequestHandler<AnotherTestRequest, string>
{
public Task<string> HandleAsync(AnotherTestRequest request, CancellationToken ct)
{
return Task.FromResult("ok");
}
}
}

View file

@ -0,0 +1,206 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Tests;
internal sealed class ScalarBehaviourTests
{
[Test]
public async Task I_can_execute_the_closed_behaviour()
{
var sc = new ServiceCollection();
sc.AddSingleton<ScalarTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ScalarTestHandler))
.Add(typeof(ScalarTestBehavior)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<ScalarTestTracker>();
var request = new ScalarTestRequest { Value = "Hello" };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("Hello-Handled");
await Assert.That(tracker.Executed).IsTrue();
}
[Test]
public async Task I_can_execute_the_open_behaviour()
{
var sc = new ServiceCollection();
sc.AddSingleton<ScalarTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ScalarTestHandler))
.Add(typeof(ScalarOpenBehavior<,>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<ScalarTestTracker>();
var request = new ScalarTestRequest { Value = "Hello" };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("Hello-Handled");
await Assert.That(tracker.Executed).IsTrue();
}
[Test]
public async Task I_can_chain_the_behaviours_in_order()
{
var sc = new ServiceCollection();
sc.AddSingleton<ScalarTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ScalarTestHandler))
.Add(typeof(ScalarChainedBehaviour1))
.Add(typeof(ScalarChainedBehaviour2)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<ScalarTestTracker>();
var request = new ScalarTestRequest { Value = "Hello" };
await dispatcher.DispatchAsync(request);
// They are discovered in the order they appear in the assembly.
// In this file: ChainedBehaviour1, ChainedBehaviour2
await Assert.That(tracker.Log).Count().IsEqualTo(2);
await Assert.That(tracker.Log[0]).IsEquivalentTo("Behaviour1");
await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2");
}
[Test]
public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour()
{
var sc = new ServiceCollection();
sc.AddSingleton<ScalarTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ScalarTestWrapperHandler<>))
.Add(typeof(ScalarWrapperBehavior<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<ScalarTestTracker>();
var request = new ScalarTestWrapperRequest<int> { Item = 42 };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("Handled-42");
await Assert.That(tracker.Executed).IsTrue();
}
[Test]
public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours()
{
var sc = new ServiceCollection();
sc.AddSingleton<ScalarTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ScalarTestHandler))
.Add(typeof(ScalarOrderingOpenBehavior<,>))
.Add(typeof(ScalarOrderingClosedBehavior)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<ScalarTestTracker>();
var request = new ScalarTestRequest { Value = "Order" };
await dispatcher.DispatchAsync(request);
await Assert.That(tracker.Log).Contains("OrderingOpen");
await Assert.That(tracker.Log).Contains("OrderingClosed");
}
}
public class ScalarTestTracker
{
public List<string> Log { get; } = [];
public bool Executed { get; set; }
}
public class ScalarOrderingOpenBehavior<TRequest, TOutput>(ScalarTestTracker tracker) : IScalarRequestBehavior<TRequest, TOutput>
where TRequest : IScalarRequest<TOutput>
{
public Task<TOutput> HandleAsync(TRequest request, ScalarHandlerDelegate<TOutput> next, CancellationToken cancellationToken)
{
tracker.Log.Add("OrderingOpen");
return next(request, cancellationToken);
}
}
public class ScalarOrderingClosedBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
{
public Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("OrderingClosed");
return next(request, cancellationToken);
}
}
public class ScalarTestRequest : IScalarRequest<string>
{
public string Value { get; set; } = string.Empty;
}
public class ScalarTestHandler : IScalarRequestHandler<ScalarTestRequest, string>
{
public Task<string> HandleAsync(ScalarTestRequest request, CancellationToken cancellationToken)
{
return Task.FromResult($"{request.Value}-Handled");
}
}
public class ScalarTestBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
{
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Executed = true;
return await next(request, cancellationToken);
}
}
public class ScalarOpenBehavior<TRequest, TResponse>(ScalarTestTracker tracker) : IScalarRequestBehavior<TRequest, TResponse>
where TRequest : IScalarRequest<TResponse>
{
public async Task<TResponse> HandleAsync(TRequest request, ScalarHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
tracker.Executed = true;
return await next(request, cancellationToken);
}
}
public class ScalarChainedBehaviour1(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
{
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("Behaviour1");
return await next(request, cancellationToken);
}
}
public class ScalarChainedBehaviour2(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
{
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("Behaviour2");
return await next(request, cancellationToken);
}
}
public class ScalarTestWrapperRequest<T> : IScalarRequest<string>
{
public T Item { get; set; } = default!;
}
public class ScalarTestWrapperHandler<T> : IScalarRequestHandler<ScalarTestWrapperRequest<T>, string>
{
public Task<string> HandleAsync(ScalarTestWrapperRequest<T> request, CancellationToken cancellationToken)
{
return Task.FromResult($"Handled-{request.Item}");
}
}
public class ScalarWrapperBehavior<T>(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestWrapperRequest<T>, string>
{
public async Task<string> HandleAsync(ScalarTestWrapperRequest<T> request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Executed = true;
return await next(request, cancellationToken);
}
}

View file

@ -0,0 +1,415 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Geekeey.Request.Tests;
internal sealed class ScalarDispatcherTests
{
[Test]
public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(OpenScalarHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new OpenScalarRequest { Data = "Hello" };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("Hello-Handled");
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler_that_has_constraints()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ConstrainedScalarHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new ConstrainedScalarRequest { Value = 123 };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("123-Constrained");
}
[Test]
public async Task I_can_see_it_fail_if_no_handler_is_found_even_with_an_open_generic_available()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(OpenScalarHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new UnhandledScalarRequest();
await Assert.ThrowsAsync<InvalidOperationException>(async () => await dispatcher.DispatchAsync(request));
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_inherited_request()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(OpenScalarHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
// InheritedScalarRequest : OpenScalarRequest.
// There is no Handler<InheritedScalarRequest> but there is OpenScalarHandler<TRequest> where TRequest : OpenScalarRequest.
// It should be able to handle InheritedScalarRequest.
var request = new InheritedScalarRequest { Data = "Sub" };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("Sub-Handled");
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_inherited_handler()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(DerivedScalarHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new DerivedScalarRequest { Value = 42 };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("Derived: 42");
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_interface_inherited_request()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(InterfaceInheritedScalarHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedScalarRequest { Name = "InterfaceTest" };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("InterfaceTest-InterfaceHandled");
}
[Test]
public async Task I_can_dispatch_a_request_async_with_deep_inheritance_in_the_request()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(OpenScalarHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new DeepDerivedScalarRequest { Data = "Deep", DeepValue = 99 };
var result = await dispatcher.DispatchAsync(request);
// OpenScalarHandler<TRequest> where TRequest : OpenScalarRequest should handle this
await Assert.That(result).IsEquivalentTo("Deep-Handled");
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_interface_constrained_handler()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(InterfaceInheritedScalarHandler))
.Add(typeof(InterfaceConstrainedScalarHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
var result = await dispatcher.DispatchAsync(request);
// Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match.
// InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest.
// InterfaceConstrainedScalarHandler is an open generic match.
// Currently Dispatcher.SendAsync checks concrete handlers first.
await Assert.That(result).IsEquivalentTo("Constrained-InterfaceHandled");
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_interface_only_match()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(InterfaceConstrainedScalarHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new AnotherNamedScalarRequest { Name = "InterfaceOnly" };
var result = await dispatcher.DispatchAsync(request);
// No concrete handler for AnotherNamedScalarRequest, but InterfaceConstrainedScalarHandler<T> where T : INamedScalarRequest matches.
await Assert.That(result).IsEquivalentTo("InterfaceOnly-ConstrainedByInterface");
}
[Test]
public async Task I_can_dispatch_a_request_async_with_a_nested_generic_request()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(WrapperScalarHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new WrapperScalarRequest<int> { Item = 42 };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("Handled-42");
}
[Test]
public async Task I_can_handle_multiple_interface_implementations()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(MultiInterfaceScalarHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new MultiInterfaceScalarRequest();
var result1 = await dispatcher.DispatchAsync<int>(request);
var result2 = await dispatcher.DispatchAsync<string>(request);
await Assert.That(result1).IsEqualTo(1);
await Assert.That(result2).IsEquivalentTo("One");
}
[Test]
public async Task I_can_see_it_fail_if_there_are_ambiguous_handle_methods()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(AmbiguousScalarHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new AmbiguousScalarRequest();
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("Interface-Handled");
}
[Test]
public async Task I_can_dispatch_a_request_async_with_a_generic_interface_explicit_implementation()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ExplicitGenericScalarHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new ExplicitGenericScalarRequest { Value = "Explicit" };
var result = await dispatcher.DispatchAsync(request);
await Assert.That(result).IsEquivalentTo("Explicit-ExplicitHandled");
}
[Test]
public async Task I_can_see_it_throw_the_original_exception()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(FailingScalarHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new FailingScalarRequest();
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
using (Assert.Multiple())
{
await Assert.That(ex?.Message).IsEquivalentTo("Handler failed");
// Assert that the stack trace contains the handler's method name,
// which proves the exception was rethrown while preserving its origin.
await Assert.That(ex?.StackTrace).Contains(nameof(FailingScalarHandler.HandleAsync));
}
}
[Test]
public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(FailingScalarHandler)));
var provider = sc.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
options.GetRequestBehaviors<IScalarRequestHandler<FailingScalarRequest, string>>(default!);
await Assert.That(() => options.Inspect([])).Throws<InvalidOperationException>();
}
}
public class FailingScalarRequest : IScalarRequest<string>
{
}
public class FailingScalarHandler : IScalarRequestHandler<FailingScalarRequest, string>
{
public Task<string> HandleAsync(FailingScalarRequest request, CancellationToken cancellationToken)
{
throw new InvalidOperationException("Handler failed");
}
}
public class ExplicitGenericScalarRequest : IScalarRequest<string>
{
public string Value { get; set; } = string.Empty;
}
public class ExplicitGenericScalarHandler<T> : IScalarRequestHandler<ExplicitGenericScalarRequest, string>
{
Task<string> IScalarRequestHandler<ExplicitGenericScalarRequest, string>.HandleAsync(ExplicitGenericScalarRequest request, CancellationToken ct)
{
return Task.FromResult($"{request.Value}-ExplicitHandled");
}
}
public class AmbiguousScalarRequest : IScalarRequest<string>
{
}
public class AmbiguousScalarHandler : IScalarRequestHandler<AmbiguousScalarRequest, string>
{
// Public method with the same name and signature
public Task<string> HandleAsync(AmbiguousScalarRequest request, CancellationToken ct)
{
return Task.FromResult("Public-Handled");
}
// Explicit interface implementation
Task<string> IScalarRequestHandler<AmbiguousScalarRequest, string>.HandleAsync(AmbiguousScalarRequest request, CancellationToken ct)
{
return Task.FromResult("Interface-Handled");
}
}
public class OpenScalarRequest : IScalarRequest<string>
{
public string Data { get; set; } = string.Empty;
}
public class InheritedScalarRequest : OpenScalarRequest
{
}
public class DeepDerivedScalarRequest : InheritedScalarRequest
{
public int DeepValue { get; set; }
}
public class OpenScalarHandler<TRequest> : IScalarRequestHandler<TRequest, string>
where TRequest : OpenScalarRequest
{
public Task<string> HandleAsync(TRequest request, CancellationToken cancellationToken)
{
return Task.FromResult($"{request.Data}-Handled");
}
}
public class ConstrainedScalarRequest : IScalarRequest<string>
{
public int Value { get; set; }
}
public class ConstrainedScalarHandler<TRequest> : IScalarRequestHandler<TRequest, string>
where TRequest : ConstrainedScalarRequest
{
public Task<string> HandleAsync(TRequest request, CancellationToken cancellationToken)
{
return Task.FromResult($"{request.Value}-Constrained");
}
}
public class UnhandledScalarRequest : IScalarRequest<string>
{
}
public class DerivedScalarRequest : IScalarRequest<string>
{
public int Value { get; set; }
}
public abstract class BaseScalarHandler<TRequest> : IScalarRequestHandler<TRequest, string>
where TRequest : IScalarRequest<string>
{
public abstract Task<string> HandleAsync(TRequest request, CancellationToken cancellationToken);
}
public class DerivedScalarHandler : BaseScalarHandler<DerivedScalarRequest>
{
public override Task<string> HandleAsync(DerivedScalarRequest request, CancellationToken cancellationToken)
{
return Task.FromResult($"Derived: {request.Value}");
}
}
public interface INamedScalarRequest : IScalarRequest<string>
{
string Name { get; }
}
public class InterfaceInheritedScalarRequest : INamedScalarRequest
{
public string Name { get; set; } = string.Empty;
}
public class AnotherNamedScalarRequest : INamedScalarRequest
{
public string Name { get; set; } = string.Empty;
}
public class InterfaceInheritedScalarHandler : IScalarRequestHandler<InterfaceInheritedScalarRequest, string>
{
public Task<string> HandleAsync(InterfaceInheritedScalarRequest request, CancellationToken cancellationToken)
{
return Task.FromResult($"{request.Name}-InterfaceHandled");
}
}
public class InterfaceConstrainedScalarHandler<TRequest> : IScalarRequestHandler<TRequest, string>
where TRequest : INamedScalarRequest
{
public Task<string> HandleAsync(TRequest request, CancellationToken cancellationToken)
{
return Task.FromResult($"{request.Name}-ConstrainedByInterface");
}
}
public class WrapperScalarRequest<T> : IScalarRequest<string>
{
public T Item { get; set; } = default!;
}
public class WrapperScalarHandler<T> : IScalarRequestHandler<WrapperScalarRequest<T>, string>
{
public Task<string> HandleAsync(WrapperScalarRequest<T> request, CancellationToken cancellationToken)
{
return Task.FromResult($"Handled-{request.Item}");
}
}
public class MultiInterfaceScalarRequest : IScalarRequest<int>, IScalarRequest<string>
{
}
public class MultiInterfaceScalarHandler : IScalarRequestHandler<MultiInterfaceScalarRequest, int>, IScalarRequestHandler<MultiInterfaceScalarRequest, string>
{
public Task<int> HandleAsync(MultiInterfaceScalarRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(1);
}
Task<string> IScalarRequestHandler<MultiInterfaceScalarRequest, string>.HandleAsync(MultiInterfaceScalarRequest request, CancellationToken ct)
{
return Task.FromResult("One");
}
}

View file

@ -0,0 +1,207 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Tests;
internal sealed class StreamBehaviourTests
{
[Test]
public async Task I_can_execute_the_closed_behaviour()
{
var sc = new ServiceCollection();
sc.AddSingleton<StreamTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(StreamTestHandler))
.Add(typeof(StreamTestBehavior)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<StreamTestTracker>();
var request = new StreamTestRequest { Value = "Hello" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Hello-Handled-0", "Hello-Handled-1"]);
await Assert.That(tracker.Executed).IsTrue();
}
[Test]
public async Task I_can_execute_the_open_behaviour()
{
var sc = new ServiceCollection();
sc.AddSingleton<StreamTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(StreamTestHandler))
.Add(typeof(StreamOpenBehavior<,>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<StreamTestTracker>();
var request = new StreamTestRequest { Value = "Hello" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Hello-Handled-0", "Hello-Handled-1"]);
await Assert.That(tracker.Executed).IsTrue();
}
[Test]
public async Task I_can_chain_the_behaviours_in_order()
{
var sc = new ServiceCollection();
sc.AddSingleton<StreamTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(StreamTestHandler))
.Add(typeof(StreamChainedBehaviour1))
.Add(typeof(StreamChainedBehaviour2)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<StreamTestTracker>();
var request = new StreamTestRequest { Value = "Hello" };
await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(tracker.Log).Count().IsEqualTo(2);
await Assert.That(tracker.Log[0]).IsEquivalentTo("Behaviour1");
await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2");
}
[Test]
public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour()
{
var sc = new ServiceCollection();
sc.AddSingleton<StreamTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(StreamTestWrapperHandler<>))
.Add(typeof(StreamWrapperBehavior<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<StreamTestTracker>();
var request = new StreamTestWrapperRequest<int> { Item = 42 };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Handled-42-0", "Handled-42-1"]);
await Assert.That(tracker.Executed).IsTrue();
}
[Test]
public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours()
{
var sc = new ServiceCollection();
sc.AddSingleton<StreamTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(StreamTestHandler))
.Add(typeof(StreamOrderingOpenBehavior<,>))
.Add(typeof(StreamOrderingClosedBehavior)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<StreamTestTracker>();
var request = new StreamTestRequest { Value = "Order" };
await dispatcher.DispatchAsync(request).ToListAsync();
var log = tracker.Log.ToList();
await Assert.That(log).Contains("Open");
await Assert.That(log).Contains("Closed");
}
}
public class StreamTestTracker
{
public List<string> Log { get; } = [];
public bool Executed { get; set; }
}
public class StreamOrderingOpenBehavior<TRequest, TOutput>(StreamTestTracker tracker) : IStreamRequestBehavior<TRequest, TOutput>
where TRequest : IStreamRequest<TOutput>
{
public IAsyncEnumerable<TOutput> HandleAsync(TRequest request, StreamHandlerDelegate<TOutput> next, CancellationToken cancellationToken)
{
tracker.Log.Add("Open");
return next(request, cancellationToken);
}
}
public class StreamOrderingClosedBehavior(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
{
public IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("Closed");
return next(request, cancellationToken);
}
}
public class StreamTestRequest : IStreamRequest<string>
{
public string Value { get; set; } = string.Empty;
}
public class StreamTestHandler : IStreamRequestHandler<StreamTestRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return $"{request.Value}-Handled-0";
yield return $"{request.Value}-Handled-1";
}
}
public class StreamTestBehavior(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
{
public IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Executed = true;
return next(request, cancellationToken);
}
}
public class StreamOpenBehavior<TRequest, TResponse>(StreamTestTracker tracker) : IStreamRequestBehavior<TRequest, TResponse>
where TRequest : IStreamRequest<TResponse>
{
public IAsyncEnumerable<TResponse> HandleAsync(TRequest request, StreamHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
tracker.Executed = true;
return next(request, cancellationToken);
}
}
public class StreamChainedBehaviour1(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
{
public IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("Behaviour1");
return next(request, cancellationToken);
}
}
public class StreamChainedBehaviour2(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestRequest, string>
{
public IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("Behaviour2");
return next(request, cancellationToken);
}
}
public class StreamTestWrapperRequest<T> : IStreamRequest<string>
{
public T? Item { get; set; }
}
public class StreamTestWrapperHandler<T> : IStreamRequestHandler<StreamTestWrapperRequest<T>, string>
{
public async IAsyncEnumerable<string> HandleAsync(StreamTestWrapperRequest<T> request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return $"Handled-{request.Item}-0";
yield return $"Handled-{request.Item}-1";
}
}
public class StreamWrapperBehavior<T>(StreamTestTracker tracker) : IStreamRequestBehavior<StreamTestWrapperRequest<T>, string>
{
public IAsyncEnumerable<string> HandleAsync(StreamTestWrapperRequest<T> request, StreamHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Executed = true;
return next(request, cancellationToken);
}
}

View file

@ -0,0 +1,418 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Tests;
public class StreamDispatcherTests
{
[Test]
public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(OpenStreamHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new OpenStreamRequest { Data = "Hello" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Hello-Stream-0", "Hello-Stream-1"]);
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler_that_has_constraints()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ConstrainedStreamHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new ConstrainedStreamRequest { Value = 123 };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["123-Constrained-0", "123-Constrained-1"]);
}
[Test]
public async Task I_can_see_it_fail_if_no_handler_is_found_even_with_an_open_generic_available()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(OpenStreamHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new UnhandledStreamRequest();
await Assert.ThrowsAsync<InvalidOperationException>(async () => await dispatcher.DispatchAsync(request).FirstOrDefaultAsync().AsTask());
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_inherited_request()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(OpenStreamHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InheritedStreamRequest { Data = "Sub" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Sub-Stream-0", "Sub-Stream-1"]);
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_inherited_handler()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(DerivedStreamHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new DerivedStreamRequest { Value = 42 };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Derived: 42-0", "Derived: 42-1"]);
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_interface_inherited_request()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(InterfaceInheritedStreamHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedStreamRequest { Name = "InterfaceTest" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["InterfaceTest-InterfaceHandled-0", "InterfaceTest-InterfaceHandled-1"]);
}
[Test]
public async Task I_can_dispatch_a_request_async_with_deep_inheritance_in_the_request()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(OpenStreamHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new DeepDerivedStreamRequest { Data = "Deep", DeepValue = 99 };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Deep-Stream-0", "Deep-Stream-1"]);
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_interface_constrained_handler()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(InterfaceInheritedStreamHandler))
.Add(typeof(InterfaceConstrainedStreamHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedStreamRequest { Name = "Constrained" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
// Both InterfaceInheritedStreamHandler and InterfaceConstrainedStreamHandler could match.
// InterfaceInheritedStreamHandler is a concrete match for InterfaceInheritedStreamRequest.
// InterfaceConstrainedStreamHandler is an open generic match.
// Currently Dispatcher checks concrete handlers first.
await Assert.That(results).IsEquivalentTo(["Constrained-InterfaceHandled-0", "Constrained-InterfaceHandled-1"]);
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_interface_only_match()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(InterfaceConstrainedStreamHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new AnotherNamedStreamRequest { Name = "InterfaceOnly" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["InterfaceOnly-ConstrainedByInterface-0", "InterfaceOnly-ConstrainedByInterface-1"]);
}
[Test]
public async Task I_can_dispatch_a_request_async_with_a_nested_generic_request()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(WrapperStreamHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new WrapperStreamRequest<int> { Item = 42 };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Handled-42-0", "Handled-42-1"]);
}
[Test]
public async Task I_can_handle_multiple_interface_implementations()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(MultiInterfaceStreamHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new MultiInterfaceStreamRequest();
var results1 = await dispatcher.DispatchAsync<int>(request).ToListAsync();
var results2 = await dispatcher.DispatchAsync<string>(request).ToListAsync();
await Assert.That(results1).IsEquivalentTo([1, 2]);
await Assert.That(results2).IsEquivalentTo(["One", "Two"]);
}
[Test]
public async Task I_can_see_it_fail_if_there_are_ambiguous_handle_methods()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(AmbiguousStreamHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new AmbiguousStreamRequest();
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Interface-Handled"]);
}
[Test]
public async Task I_can_dispatch_a_request_async_with_a_generic_interface_explicit_implementation()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ExplicitGenericStreamHandler<>)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new ExplicitGenericStreamRequest { Value = "Explicit" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
await Assert.That(results).IsEquivalentTo(["Explicit-ExplicitHandled"]);
}
[Test]
public async Task I_can_see_it_throw_the_original_exception()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(FailingStreamHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new FailingStreamRequest();
var enumerable = dispatcher.DispatchAsync(request);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await enumerable.ToListAsync().AsTask());
using (Assert.Multiple())
{
await Assert.That(ex?.Message).IsEquivalentTo("Handler failed");
await Assert.That(ex?.StackTrace).Contains(nameof(FailingStreamHandler.HandleAsync));
}
}
[Test]
public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(FailingStreamHandler)));
var provider = sc.BuildServiceProvider();
var options = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<RequestDispatcherOptions>>().Value;
options.GetRequestHandlers<IStreamRequestHandler<FailingStreamRequest, string>>(default!);
await Assert.That(() => options.Inspect([])).Throws<InvalidOperationException>();
}
}
public class FailingStreamRequest : IStreamRequest<string>
{
}
public class FailingStreamHandler : IStreamRequestHandler<FailingStreamRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(FailingStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return "Wait for it...";
throw new InvalidOperationException("Handler failed");
}
}
public class ExplicitGenericStreamRequest : IStreamRequest<string>
{
public string Value { get; set; } = string.Empty;
}
public class ExplicitGenericStreamHandler<T> : IStreamRequestHandler<ExplicitGenericStreamRequest, string>
{
async IAsyncEnumerable<string> IStreamRequestHandler<ExplicitGenericStreamRequest, string>.HandleAsync(ExplicitGenericStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
yield return $"{request.Value}-ExplicitHandled";
}
}
public class AmbiguousStreamRequest : IStreamRequest<string>
{
}
public class AmbiguousStreamHandler : IStreamRequestHandler<AmbiguousStreamRequest, string>
{
// Public method with the same name and signature
public async IAsyncEnumerable<string> HandleAsync(AmbiguousStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return "Public-Handled";
}
// Explicit interface implementation
async IAsyncEnumerable<string> IStreamRequestHandler<AmbiguousStreamRequest, string>.HandleAsync(AmbiguousStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return "Interface-Handled";
}
}
public class OpenStreamRequest : IStreamRequest<string>
{
public string Data { get; set; } = string.Empty;
}
public class InheritedStreamRequest : OpenStreamRequest
{
}
public class DeepDerivedStreamRequest : InheritedStreamRequest
{
public int DeepValue { get; set; }
}
public class OpenStreamHandler<TRequest> : IStreamRequestHandler<TRequest, string>
where TRequest : OpenStreamRequest
{
public async IAsyncEnumerable<string> HandleAsync(TRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return $"{request.Data}-Stream-0";
yield return $"{request.Data}-Stream-1";
}
}
public class ConstrainedStreamRequest : IStreamRequest<string>
{
public int Value { get; set; }
}
public class ConstrainedStreamHandler<TRequest> : IStreamRequestHandler<TRequest, string>
where TRequest : ConstrainedStreamRequest
{
public async IAsyncEnumerable<string> HandleAsync(TRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return $"{request.Value}-Constrained-0";
yield return $"{request.Value}-Constrained-1";
}
}
public class UnhandledStreamRequest : IStreamRequest<string>
{
}
public class DerivedStreamRequest : IStreamRequest<string>
{
public int Value { get; set; }
}
public abstract class BaseStreamHandler<TRequest> : IStreamRequestHandler<TRequest, string>
where TRequest : IStreamRequest<string>
{
public abstract IAsyncEnumerable<string> HandleAsync(TRequest request, CancellationToken cancellationToken);
}
public class DerivedStreamHandler : BaseStreamHandler<DerivedStreamRequest>
{
public override async IAsyncEnumerable<string> HandleAsync(DerivedStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return $"Derived: {request.Value}-0";
yield return $"Derived: {request.Value}-1";
}
}
public interface INamedStreamRequest : IStreamRequest<string>
{
string Name { get; }
}
public class InterfaceInheritedStreamRequest : INamedStreamRequest
{
public string Name { get; set; } = string.Empty;
}
public class AnotherNamedStreamRequest : INamedStreamRequest
{
public string Name { get; set; } = string.Empty;
}
public class InterfaceInheritedStreamHandler : IStreamRequestHandler<InterfaceInheritedStreamRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(InterfaceInheritedStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return $"{request.Name}-InterfaceHandled-0";
yield return $"{request.Name}-InterfaceHandled-1";
}
}
public class InterfaceConstrainedStreamHandler<TRequest> : IStreamRequestHandler<TRequest, string>
where TRequest : INamedStreamRequest
{
public async IAsyncEnumerable<string> HandleAsync(TRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return $"{request.Name}-ConstrainedByInterface-0";
yield return $"{request.Name}-ConstrainedByInterface-1";
}
}
public class WrapperStreamRequest<T> : IStreamRequest<string>
{
public T Item { get; set; } = default!;
}
public class WrapperStreamHandler<T> : IStreamRequestHandler<WrapperStreamRequest<T>, string>
{
public async IAsyncEnumerable<string> HandleAsync(WrapperStreamRequest<T> request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return $"Handled-{request.Item}-0";
yield return $"Handled-{request.Item}-1";
}
}
public class MultiInterfaceStreamRequest : IStreamRequest<int>, IStreamRequest<string>
{
}
public class MultiInterfaceStreamHandler : IStreamRequestHandler<MultiInterfaceStreamRequest, int>, IStreamRequestHandler<MultiInterfaceStreamRequest, string>
{
public async IAsyncEnumerable<int> HandleAsync(MultiInterfaceStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return 1;
yield return 2;
}
async IAsyncEnumerable<string> IStreamRequestHandler<MultiInterfaceStreamRequest, string>.HandleAsync(MultiInterfaceStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
yield return "One";
yield return "Two";
}
}

View file

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
</PropertyGroup>
<PropertyGroup>
<RootNamespace>Geekeey.Request</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<InternalsVisibleTo Include="Geekeey.Request.Tests" />
</ItemGroup>
<PropertyGroup>
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
<PackageIcon>package-icon.png</PackageIcon>
<PackageProjectUrl>https://code.geekeey.de/geekeey/request/src/branch/main/src/request</PackageProjectUrl>
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,28 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request;
/// <summary>
/// Defines functionality to dispatch requests to their corresponding handlers.
/// </summary>
public interface IRequestDispatcher
{
/// <summary>
/// Asynchronously send a request to a handler producing a scalar value.
/// </summary>
/// <param name="request">Request object</param>
/// <param name="cancellationToken">Optional cancellation token</param>
/// <typeparam name="TResponse">Response type</typeparam>
/// <returns>A task that represents the send operation. The task result contains the handler response</returns>
Task<TResponse> DispatchAsync<TResponse>(IScalarRequest<TResponse> request, CancellationToken cancellationToken = default);
/// <summary>
/// Asynchronously send a request to a handler producing a stream value.
/// </summary>
/// <param name="request">Request object</param>
/// <param name="cancellationToken">Optional cancellation token</param>
/// <typeparam name="TResponse">Response type</typeparam>
/// <returns>The created async enumerable, representing the stream of responses.</returns>
IAsyncEnumerable<TResponse> DispatchAsync<TResponse>(IStreamRequest<TResponse> request, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,19 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request;
/// <summary>
/// Represents a builder for configuring and registering request dispatchers and their related components.
/// </summary>
public interface IRequestDispatcherBuilder
{
/// <summary>
/// Gets the <see cref="IServiceCollection"/> associated with the request dispatcher builder.
/// Provides access to the underlying service collection for configuring dependencies
/// and registering services required by the request dispatcher.
/// </summary>
IServiceCollection Services { get; }
}

View file

@ -0,0 +1,34 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
#pragma warning disable CA1711
namespace Geekeey.Request;
/// <summary>
/// Represents a behavior in the request pipeline, allowing interception, modification,
/// or chaining of asynchronous stream requests and responses.
/// </summary>
/// <typeparam name="TRequest">The type of the request being handled. Must implement <see cref="IScalarRequest{TResponse}"/>.</typeparam>
/// <typeparam name="TResponse">The type of the response produced by the implementing request handler.</typeparam>
public interface IScalarRequestBehavior<in TRequest, TResponse> where TRequest : IScalarRequest<TResponse>
{
/// <summary>
/// Handles the asynchronous processing of a request, allowing behavior customization
/// such as interception, modification, or chaining of the request and its response.
/// </summary>
/// <param name="request">The request instance being processed.</param>
/// <param name="next">The next delegate in the pipeline to execute after the custom behavior.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the response from the request.</returns>
Task<TResponse> HandleAsync(TRequest request, ScalarHandlerDelegate<TResponse> next, CancellationToken cancellationToken);
}
/// <summary>
/// Represents the delegate responsible for handling asynchronous requests in a pipeline.
/// </summary>
/// <typeparam name="TResponse">The type of the response produced by the implementing request handler.</typeparam>
/// <param name="request">The asynchronous request being processed.</param>
/// <param name="cancellationToken">A token for monitoring cancellation requests.</param>
/// <returns>A task, that represents the asynchronous operation, containing the response of type <typeparamref name="TResponse"/>.</returns>
public delegate Task<TResponse> ScalarHandlerDelegate<TResponse>(IScalarRequest<TResponse> request, CancellationToken cancellationToken);

View file

@ -0,0 +1,14 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request;
/// <summary>
/// Represents a request that produces a response of a specified type.
/// This interface serves as a marker for distinguishing request types,
/// enabling their integration into request-based pipelines or dispatch mechanisms.
/// </summary>
/// <typeparam name="TResponse">The type of the response produced by the implementing request handler.</typeparam>
public interface IScalarRequest<out TResponse>
{
}

View file

@ -0,0 +1,20 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request;
/// <summary>
/// Defines a handler for processing requests of a specific type and returning a response.
/// </summary>
/// <typeparam name="TRequest">The type of the request to be handled. Must implement <see cref="IScalarRequest{TResponse}"/>.</typeparam>
/// <typeparam name="TResponse">The type of the response produced by the implementing request handler.</typeparam>
public interface IScalarRequestHandler<in TRequest, TResponse> where TRequest : IScalarRequest<TResponse>
{
/// <summary>
/// Processes a scalar request and returns a response asynchronously.
/// </summary>
/// <param name="request">The request object to be processed.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation, containing the response of type <typeparamref name="TResponse"/>.</returns>
Task<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,34 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
#pragma warning disable CA1711
namespace Geekeey.Request;
/// <summary>
/// Represents a behavior in the request pipeline, allowing interception, modification,
/// or chaining of asynchronous stream requests and responses.
/// </summary>
/// <typeparam name="TRequest">The type of the request being processed. Must implement <see cref="IStreamRequest{TResponse}"/>.</typeparam>
/// <typeparam name="TResponse">The type of the response produced by the implementing request handler.</typeparam>
public interface IStreamRequestBehavior<in TRequest, TResponse> where TRequest : IStreamRequest<TResponse>
{
/// <summary>
/// Handles the asynchronous processing of a request, allowing behavior customization
/// such as interception, modification, or chaining of the request and its response.
/// </summary>
/// <param name="request">The request instance being processed.</param>
/// <param name="next">The next delegate in the pipeline to execute after the custom behavior.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>An asynchronous stream of <typeparamref name="TResponse"/> representing the processed response.</returns>
IAsyncEnumerable<TResponse> HandleAsync(TRequest request, StreamHandlerDelegate<TResponse> next, CancellationToken cancellationToken);
}
/// <summary>
/// Represents the delegate responsible for handling asynchronous requests in a pipeline.
/// </summary>
/// <typeparam name="TResponse">The type of the response produced by the implementing request handler.</typeparam>
/// <param name="request">The asynchronous request being processed.</param>
/// <param name="cancellationToken">A token for monitoring cancellation requests.</param>
/// <returns>An asynchronous stream of responses of type <typeparamref name="TResponse"/>.</returns>
public delegate IAsyncEnumerable<TResponse> StreamHandlerDelegate<TResponse>(IStreamRequest<TResponse> request, CancellationToken cancellationToken);

View file

@ -0,0 +1,14 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request;
/// <summary>
/// Represents a request that produces a response of a specified type.
/// This interface serves as a marker for distinguishing request types,
/// enabling their integration into request-based pipelines or dispatch mechanisms.
/// </summary>
/// <typeparam name="TResponse">The type of the response produced by the implementing request handler.</typeparam>
public interface IStreamRequest<out TResponse>
{
}

View file

@ -0,0 +1,21 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request;
/// <summary>
/// Defines a handler for processing streaming requests of a specific type and producing
/// a stream of responses.
/// </summary>
/// <typeparam name="TRequest">The type of the request to handle. This must implement <see cref="IStreamRequest{TResponse}"/>.</typeparam>
/// <typeparam name="TResponse">The type of the response produced by the implementing request handler.</typeparam>
public interface IStreamRequestHandler<in TRequest, out TResponse> where TRequest : IStreamRequest<TResponse>
{
/// <summary>
/// Handles a streaming request and returns a stream of responses asynchronously.
/// </summary>
/// <param name="request">The request object to be processed.</param>
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>An asynchronous stream of responses of type <typeparamref name="TResponse"/>.</returns>
IAsyncEnumerable<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken);
}

View file

@ -0,0 +1,48 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections.Concurrent;
using System.Reflection.Metadata;
[assembly: MetadataUpdateHandler(typeof(Geekeey.Request.RequestDispatcher))]
namespace Geekeey.Request;
internal sealed class RequestDispatcher : IRequestDispatcher
{
private static readonly ConcurrentDictionary<(Type, Type), ScalarRequestInvoker> ScalarRequestHandlers = new();
private static readonly ConcurrentDictionary<(Type, Type), StreamRequestInvoker> StreamRequestHandlers = new();
private readonly IServiceProvider _serviceProvider;
public RequestDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public Task<TResponse> DispatchAsync<TResponse>(IScalarRequest<TResponse> request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var handler = ScalarRequestHandlers.GetOrAdd((request.GetType(), typeof(TResponse)), static key =>
{
var type = typeof(ScalarRequestInvoker<,>).MakeGenericType(key.Item1, key.Item2);
return (ScalarRequestInvoker)Activator.CreateInstance(type)!;
});
return ((ScalarRequestInvoker<TResponse>)handler).HandleAsync(request, _serviceProvider, cancellationToken);
}
public IAsyncEnumerable<TResponse> DispatchAsync<TResponse>(IStreamRequest<TResponse> request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var handler = StreamRequestHandlers.GetOrAdd((request.GetType(), typeof(TResponse)), static key =>
{
var type = typeof(StreamRequestInvoker<,>).MakeGenericType(key.Item1, key.Item2);
return (StreamRequestInvoker)Activator.CreateInstance(type)!;
});
return ((StreamRequestInvoker<TResponse>)handler).HandleAsync(request, _serviceProvider, cancellationToken);
}
}

View file

@ -0,0 +1,133 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using static Geekeey.Request.RequestDispatcherOptions;
namespace Geekeey.Request;
/// <summary>
/// Provides extension methods for configuring <see cref="IRequestDispatcherBuilder"/>
/// with additional capabilities such as searching and registering request handlers in assemblies or adding types directly.
/// </summary>
public static class RequestDispatcherBuilderExtensions
{
/// <summary>
/// Searches for request handler types within the specified assembly and adds them to the request dispatcher
/// configuration.
/// </summary>
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
/// <param name="assembly">The assembly to search for request handler types.</param>
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
public static IRequestDispatcherBuilder SearchHandlerInAssembly(this IRequestDispatcherBuilder builder, Assembly assembly)
{
ArgumentNullException.ThrowIfNull(builder);
var exports = assembly.GetTypes()
.Where(type => type is { IsClass: true, IsAbstract: false })
.Where(IsRequestHandlerType);
builder.Add(exports);
return builder;
}
/// <summary>
/// Searches for request handler types within the specified assembly and adds them to the request dispatcher
/// configuration with the given service lifetime.
/// </summary>
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
/// <param name="assembly">The assembly to search for request handler types.</param>
/// <param name="lifetime">The lifetime with which the request handlers are registered in the dependency injection container.</param>
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
public static IRequestDispatcherBuilder SearchHandlerInAssembly(this IRequestDispatcherBuilder builder, Assembly assembly, ServiceLifetime lifetime)
{
ArgumentNullException.ThrowIfNull(builder);
var exports = assembly.GetTypes()
.Where(type => type is { IsClass: true, IsAbstract: false })
.Where(IsRequestHandlerType);
builder.Add(exports, lifetime);
return builder;
}
/// <summary>
/// Adds the specified type to the request dispatcher configuration for inspection.
/// </summary>
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
/// <param name="type">The type to be added to the request dispatcher configuration.</param>
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, Type type)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOptions<RequestDispatcherOptions>()
.Configure(options => options.Inspect([type]));
return builder;
}
/// <summary>
/// Adds the specified type to the request dispatcher configuration.
/// This also adds the type to the service collection with the specified lifetime,
/// allowing it to be resolved as a dependency in request handlers and behaviors.
/// </summary>
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> used to configure the request dispatcher.</param>
/// <param name="type">The type to be added to the request dispatcher configuration.</param>
/// <param name="lifetime">The lifetime scope of the type in the service container.</param>
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, Type type, ServiceLifetime lifetime)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOptions<RequestDispatcherOptions>()
.Configure(options => options.Inspect([type]));
builder.Services.Add(new ServiceDescriptor(type, type, lifetime));
return builder;
}
/// <summary>
/// Adds the specified collection of types to the request dispatcher configuration for inspection.
/// </summary>
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
/// <param name="type">The collection of types to be added to the request dispatcher configuration.</param>
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, IEnumerable<Type> type)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOptions<RequestDispatcherOptions>()
.Configure(options => options.Inspect(type));
return builder;
}
/// <summary>
/// Adds the specified collection of types to the request dispatcher configuration for inspection.
/// This also adds the specified collection of types to the service collection with the specified lifetime,
/// allowing it to be resolved as a dependency in request handlers and behaviors.
/// </summary>
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
/// <param name="type">The collection of types to be added to the request dispatcher configuration.</param>
/// <param name="lifetime">The lifetime scope of the types in the service container.</param>
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, IEnumerable<Type> type, ServiceLifetime lifetime)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddOptions<RequestDispatcherOptions>()
.Configure(options => options.Inspect(type));
builder.Services.Add(type.Select(export => new ServiceDescriptor(export, export, lifetime)));
return builder;
}
}

View file

@ -0,0 +1,207 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections;
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request;
internal sealed class RequestDispatcherOptions
{
private readonly List<Type> _search = [];
private readonly Lazy<TypeIndex> _behaviorsTypeIndex;
private readonly Lazy<TypeIndex> _handlersTypeIndex;
public RequestDispatcherOptions()
{
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.Distinct()));
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.Distinct()));
}
public void Inspect(IEnumerable<Type> assembly)
{
if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated)
{
throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies.");
}
_search.AddRange(assembly);
}
public IEnumerable<T> GetRequestBehaviors<T>(IServiceProvider services)
{
return _behaviorsTypeIndex.Value.Resolve<T>(services);
}
public IEnumerable<T> GetRequestHandlers<T>(IServiceProvider services)
{
return _handlersTypeIndex.Value.Resolve<T>(services);
}
private abstract class TypeIndex
{
private readonly ConcurrentDictionary<Type, Func<IServiceProvider, IEnumerable>> _cache = new();
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
protected readonly List<Type> _openTypeInfo = [];
protected TypeIndex(IEnumerable<Type> collection, Func<Type, bool> predicate)
{
foreach (var type in collection)
{
if (type.IsGenericTypeDefinition)
{
if (type.GetInterfaces().Any(predicate))
{
_openTypeInfo.Add(type);
}
}
else
{
foreach (var @interface in type.GetInterfaces().Where(predicate))
{
(_closedTypeInfo.TryGetValue(@interface, out var list) ? list : _closedTypeInfo[@interface] = []).Add(type);
}
}
}
}
public IEnumerable<T> Resolve<T>(IServiceProvider services)
{
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
}
protected abstract IReadOnlyList<Type> IsAssignableTo(Type type);
private Func<IServiceProvider, IEnumerable<T>> CreateResolverFactory<T>(Type @interface)
{
var list = IsAssignableTo(@interface);
return ResolverFactory;
IEnumerable<T> ResolverFactory(IServiceProvider services)
{
foreach (var type in list)
{
yield return (T)ActivatorUtilities.GetServiceOrCreateInstance(services, type);
}
}
}
}
internal static bool IsRequestHandlerType(Type type)
{
return type.IsGenericType &&
(type.GetGenericTypeDefinition() == typeof(IScalarRequestHandler<,>) ||
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
}
private sealed class HandlerTypeIndex(IEnumerable<Type> collection)
: TypeIndex(collection, IsRequestHandlerType)
{
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
{
var result = new List<Type>();
if (_closedTypeInfo.TryGetValue(@interface, out var list))
{
result.AddRange(list);
}
var requestType = @interface.GetGenericArguments()[0];
_ = @interface.GetGenericArguments()[1];
foreach (var type in _openTypeInfo)
{
try
{
// open type case one: Handler<TRequest> : IRequestHandler<TRequest, TOutput>
// We try to close it with the request type.
var impl = type.MakeGenericType(requestType);
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
}
}
catch (ArgumentException)
{
}
try
{
// open type case two: Handler<T> : IRequestHandler<WrapperRequest<T>, TOutput>
// If the request is generic, we try to close the handler with the request's generic arguments.
if (requestType.IsGenericType)
{
var impl = type.MakeGenericType(requestType.GetGenericArguments());
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
}
}
}
catch (ArgumentException)
{
}
}
return result;
}
}
internal static bool IsRequestBehaviorType(Type type)
{
return type.IsGenericType &&
(type.GetGenericTypeDefinition() == typeof(IScalarRequestBehavior<,>) ||
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>));
}
private sealed class BehaviorTypeIndex(IEnumerable<Type> collection)
: TypeIndex(collection, IsRequestBehaviorType)
{
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
{
var result = new List<Type>();
if (_closedTypeInfo.TryGetValue(@interface, out var list))
{
result.AddRange(list);
}
var requestType = @interface.GetGenericArguments()[0];
var responseType = @interface.GetGenericArguments()[1];
foreach (var behaviour in _openTypeInfo)
{
try
{
// open type case one: Behaviour<TRequest, TResponse> : IRequestBehaviour<TRequest, TResponse>
var impl = behaviour.MakeGenericType(requestType, responseType);
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
}
}
catch (ArgumentException)
{
}
try
{
// open type case two: Behaviour<T> : IRequestBehaviour<WrapperRequest<T>, TResponse>
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
}
}
catch (ArgumentException)
{
}
}
return result;
}
}
}

View file

@ -0,0 +1,47 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Geekeey.Request;
internal abstract class ScalarRequestInvoker
{
public abstract Task<object?> HandleAsync(object request, IServiceProvider serviceProvider, CancellationToken cancellationToken);
}
internal abstract class ScalarRequestInvoker<TResponse> : ScalarRequestInvoker
{
public abstract Task<TResponse> HandleAsync(IScalarRequest<TResponse> request, IServiceProvider serviceProvider, CancellationToken cancellationToken);
}
internal sealed class ScalarRequestInvoker<TRequest, TResponse> : ScalarRequestInvoker<TResponse>
where TRequest : IScalarRequest<TResponse>
{
public override async Task<object?> HandleAsync(object request, IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
return await HandleAsync((IScalarRequest<TResponse>)request, serviceProvider, cancellationToken).ConfigureAwait(false);
}
public override Task<TResponse> HandleAsync(IScalarRequest<TResponse> request, IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
var options = serviceProvider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
var pipeline = options.GetRequestBehaviors<IScalarRequestBehavior<TRequest, TResponse>>(serviceProvider)
.Reverse()
.Aggregate((ScalarHandlerDelegate<TResponse>)Head, Chain);
return pipeline(request, cancellationToken);
static ScalarHandlerDelegate<TResponse> Chain(ScalarHandlerDelegate<TResponse> next, IScalarRequestBehavior<TRequest, TResponse> filter)
{
return (req, ct) => filter.HandleAsync((TRequest)req, next, ct);
}
Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct)
{
return options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
}
}
}

View file

@ -0,0 +1,50 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Geekeey.Request;
/// <summary>
/// Provides extension methods for configuring and registering request dispatchers and their dependencies.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds the request dispatcher services to the specified <see cref="IServiceCollection"/>.
/// </summary>
/// <param name="services">The service collection to which the request dispatcher services will be added.</param>
/// <returns>An instance of <see cref="IRequestDispatcherBuilder"/> to configure the request dispatcher.</returns>
public static IRequestDispatcherBuilder AddRequestDispatcher(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<RequestDispatcherOptions>();
services.TryAddTransient<IRequestDispatcher, RequestDispatcher>();
return new RequestDispatcherBuilder(services);
}
/// <summary>
/// Adds the request dispatcher services to the specified <see cref="IServiceCollection"/>
/// and configures it using the provided <see cref="Action{T}"/>.
/// </summary>
/// <param name="services">The service collection to which the request dispatcher services will be added.</param>
/// <param name="configure">A delegate to configure the request dispatcher builder.</param>
/// <returns>The service collection with the request dispatcher services added.</returns>
public static IServiceCollection AddRequestDispatcher(this IServiceCollection services, Action<IRequestDispatcherBuilder> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
configure(services.AddRequestDispatcher());
return services;
}
private sealed class RequestDispatcherBuilder(IServiceCollection services) : IRequestDispatcherBuilder
{
public IServiceCollection Services { get; } = services;
}
}

View file

@ -0,0 +1,52 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.CompilerServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Geekeey.Request;
internal abstract class StreamRequestInvoker
{
public abstract IAsyncEnumerable<object?> HandleAsync(object request, IServiceProvider serviceProvider, CancellationToken cancellationToken);
}
internal abstract class StreamRequestInvoker<TResponse> : StreamRequestInvoker
{
public abstract IAsyncEnumerable<TResponse> HandleAsync(IStreamRequest<TResponse> request, IServiceProvider serviceProvider, CancellationToken cancellationToken);
}
internal sealed class StreamRequestInvoker<TRequest, TResponse> : StreamRequestInvoker<TResponse>
where TRequest : IStreamRequest<TResponse>
{
public override async IAsyncEnumerable<object?> HandleAsync(object request, IServiceProvider serviceProvider, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var item in HandleAsync((IStreamRequest<TResponse>)request, serviceProvider, cancellationToken))
{
yield return item;
}
}
public override IAsyncEnumerable<TResponse> HandleAsync(IStreamRequest<TResponse> request, IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
var options = serviceProvider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
var pipeline = options.GetRequestBehaviors<IStreamRequestBehavior<TRequest, TResponse>>(serviceProvider)
.Reverse()
.Aggregate((StreamHandlerDelegate<TResponse>)Head, Chain);
return pipeline(request, cancellationToken);
static StreamHandlerDelegate<TResponse> Chain(StreamHandlerDelegate<TResponse> next, IStreamRequestBehavior<TRequest, TResponse> filter)
{
return (req, ct) => filter.HandleAsync((TRequest)req, next, ct);
}
IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct)
{
return options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file