commit fff952a385460b5e8f28aaf71dcd3633c253caf8 Author: Louis Seubert Date: Fri May 8 20:26:26 2026 +0200 feat: add inital in memory dispatcher Add a simple in memory dispatcher for scalar requests and stream request. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3848020 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.forgejo/workflows/default.yml b/.forgejo/workflows/default.yml new file mode 100644 index 0000000..a007574 --- /dev/null +++ b/.forgejo/workflows/default.yml @@ -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 \ No newline at end of file diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..603a9d6 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3509a45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +artifacts/ +*.DotSettings.user \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8eec13f --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..7996aa7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,37 @@ + + + $(MSBuildThisFileDirectory)artifacts + + + + enable + enable + + + + 1.0.0 + + + + true + nullable + true + + + + The Geekeey Team + Copyright (c) The Geekeey Team 2026 + true + true + snupkg + + + + + + + + moderate + all + + \ No newline at end of file diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..c1df222 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..c22a3bf --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,12 @@ + + + true + + + + + + + + + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c29ce2f --- /dev/null +++ b/LICENSE.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/global.json b/global.json new file mode 100644 index 0000000..76286dc --- /dev/null +++ b/global.json @@ -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" + } +} diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..131917b --- /dev/null +++ b/nuget.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/request.slnx b/request.slnx new file mode 100644 index 0000000..af7d750 --- /dev/null +++ b/request.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/Request.Tests/.editorconfig b/src/Request.Tests/.editorconfig new file mode 100644 index 0000000..78f1f31 --- /dev/null +++ b/src/Request.Tests/.editorconfig @@ -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 diff --git a/src/Request.Tests/Geekeey.Request.Tests.csproj b/src/Request.Tests/Geekeey.Request.Tests.csproj new file mode 100644 index 0000000..1f39d5f --- /dev/null +++ b/src/Request.Tests/Geekeey.Request.Tests.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + false + + + + Geekeey.Request + + + + + + + + + + + + + + + + + diff --git a/src/Request.Tests/RequestDispatcherBuilderExtensionsTests.cs b/src/Request.Tests/RequestDispatcherBuilderExtensionsTests.cs new file mode 100644 index 0000000..9362ced --- /dev/null +++ b/src/Request.Tests/RequestDispatcherBuilderExtensionsTests.cs @@ -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>().Value; + + var handlers = options.GetRequestHandlers>(provider); + await Assert.That(handlers).Count().IsEqualTo(1); + await Assert.That(handlers.First()).IsTypeOf(); + } + + [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>().Value; + var handlers = options.GetRequestHandlers>(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>().Value; + + var handlers1 = options.GetRequestHandlers>(provider); + await Assert.That(handlers1).Count().IsEqualTo(1); + await Assert.That(handlers1.First()).IsTypeOf(); + + var handlers2 = options.GetRequestHandlers>(provider); + await Assert.That(handlers2).Count().IsEqualTo(1); + await Assert.That(handlers2.First()).IsTypeOf(); + } + + [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>().Value; + + await Assert.That(options.GetRequestHandlers>(provider)).Count().IsEqualTo(1); + await Assert.That(options.GetRequestHandlers>(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(); + await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient)).Throws(); + await Assert.That(() => builder.Add([typeof(TestHandler)])).Throws(); + await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient)).Throws(); + } + } + + private sealed class TestRequest : IScalarRequest; + + private sealed class TestHandler : IScalarRequestHandler + { + public Task HandleAsync(TestRequest request, CancellationToken ct) + { + return Task.FromResult("ok"); + } + } + + private sealed class AnotherTestRequest : IScalarRequest; + + private sealed class AnotherTestHandler : IScalarRequestHandler + { + public Task HandleAsync(AnotherTestRequest request, CancellationToken ct) + { + return Task.FromResult("ok"); + } + } +} \ No newline at end of file diff --git a/src/Request.Tests/ScalarBehaviourTests.cs b/src/Request.Tests/ScalarBehaviourTests.cs new file mode 100644 index 0000000..0707e85 --- /dev/null +++ b/src/Request.Tests/ScalarBehaviourTests.cs @@ -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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(ScalarTestHandler)) + .Add(typeof(ScalarTestBehavior))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + 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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(ScalarTestHandler)) + .Add(typeof(ScalarOpenBehavior<,>))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + 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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(ScalarTestHandler)) + .Add(typeof(ScalarChainedBehaviour1)) + .Add(typeof(ScalarChainedBehaviour2))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + 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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(ScalarTestWrapperHandler<>)) + .Add(typeof(ScalarWrapperBehavior<>))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + var request = new ScalarTestWrapperRequest { 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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(ScalarTestHandler)) + .Add(typeof(ScalarOrderingOpenBehavior<,>)) + .Add(typeof(ScalarOrderingClosedBehavior))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + 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 Log { get; } = []; + public bool Executed { get; set; } +} + +public class ScalarOrderingOpenBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior + where TRequest : IScalarRequest +{ + public Task HandleAsync(TRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("OrderingOpen"); + return next(request, cancellationToken); + } +} + +public class ScalarOrderingClosedBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("OrderingClosed"); + return next(request, cancellationToken); + } +} + +public class ScalarTestRequest : IScalarRequest +{ + public string Value { get; set; } = string.Empty; +} + +public class ScalarTestHandler : IScalarRequestHandler +{ + public Task HandleAsync(ScalarTestRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Value}-Handled"); + } +} + +public class ScalarTestBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Executed = true; + return await next(request, cancellationToken); + } +} + +public class ScalarOpenBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior + where TRequest : IScalarRequest +{ + public async Task HandleAsync(TRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Executed = true; + return await next(request, cancellationToken); + } +} + +public class ScalarChainedBehaviour1(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Behaviour1"); + return await next(request, cancellationToken); + } +} + +public class ScalarChainedBehaviour2(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Behaviour2"); + return await next(request, cancellationToken); + } +} + +public class ScalarTestWrapperRequest : IScalarRequest +{ + public T Item { get; set; } = default!; +} + +public class ScalarTestWrapperHandler : IScalarRequestHandler, string> +{ + public Task HandleAsync(ScalarTestWrapperRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"Handled-{request.Item}"); + } +} + +public class ScalarWrapperBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior, string> +{ + public async Task HandleAsync(ScalarTestWrapperRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Executed = true; + return await next(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Request.Tests/ScalarDispatcherTests.cs b/src/Request.Tests/ScalarDispatcherTests.cs new file mode 100644 index 0000000..d37a148 --- /dev/null +++ b/src/Request.Tests/ScalarDispatcherTests.cs @@ -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(); + + 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(); + + 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(); + + var request = new UnhandledScalarRequest(); + await Assert.ThrowsAsync(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(); + + // InheritedScalarRequest : OpenScalarRequest. + // There is no Handler but there is OpenScalarHandler 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(); + + 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(); + + 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(); + + var request = new DeepDerivedScalarRequest { Data = "Deep", DeepValue = 99 }; + var result = await dispatcher.DispatchAsync(request); + + // OpenScalarHandler 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(); + + 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(); + + var request = new AnotherNamedScalarRequest { Name = "InterfaceOnly" }; + var result = await dispatcher.DispatchAsync(request); + + // No concrete handler for AnotherNamedScalarRequest, but InterfaceConstrainedScalarHandler 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(); + + var request = new WrapperScalarRequest { 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(); + + var request = new MultiInterfaceScalarRequest(); + var result1 = await dispatcher.DispatchAsync(request); + var result2 = await dispatcher.DispatchAsync(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(); + + 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(); + + 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(); + + var request = new FailingScalarRequest(); + var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws(); + + 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>().Value; + options.GetRequestBehaviors>(default!); + + await Assert.That(() => options.Inspect([])).Throws(); + } +} + +public class FailingScalarRequest : IScalarRequest +{ +} + +public class FailingScalarHandler : IScalarRequestHandler +{ + public Task HandleAsync(FailingScalarRequest request, CancellationToken cancellationToken) + { + throw new InvalidOperationException("Handler failed"); + } +} + +public class ExplicitGenericScalarRequest : IScalarRequest +{ + public string Value { get; set; } = string.Empty; +} + +public class ExplicitGenericScalarHandler : IScalarRequestHandler +{ + Task IScalarRequestHandler.HandleAsync(ExplicitGenericScalarRequest request, CancellationToken ct) + { + return Task.FromResult($"{request.Value}-ExplicitHandled"); + } +} + +public class AmbiguousScalarRequest : IScalarRequest +{ +} + +public class AmbiguousScalarHandler : IScalarRequestHandler +{ + // Public method with the same name and signature + public Task HandleAsync(AmbiguousScalarRequest request, CancellationToken ct) + { + return Task.FromResult("Public-Handled"); + } + + // Explicit interface implementation + Task IScalarRequestHandler.HandleAsync(AmbiguousScalarRequest request, CancellationToken ct) + { + return Task.FromResult("Interface-Handled"); + } +} + +public class OpenScalarRequest : IScalarRequest +{ + public string Data { get; set; } = string.Empty; +} + +public class InheritedScalarRequest : OpenScalarRequest +{ +} + +public class DeepDerivedScalarRequest : InheritedScalarRequest +{ + public int DeepValue { get; set; } +} + +public class OpenScalarHandler : IScalarRequestHandler + where TRequest : OpenScalarRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Data}-Handled"); + } +} + +public class ConstrainedScalarRequest : IScalarRequest +{ + public int Value { get; set; } +} + +public class ConstrainedScalarHandler : IScalarRequestHandler + where TRequest : ConstrainedScalarRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Value}-Constrained"); + } +} + +public class UnhandledScalarRequest : IScalarRequest +{ +} + +public class DerivedScalarRequest : IScalarRequest +{ + public int Value { get; set; } +} + +public abstract class BaseScalarHandler : IScalarRequestHandler + where TRequest : IScalarRequest +{ + public abstract Task HandleAsync(TRequest request, CancellationToken cancellationToken); +} + +public class DerivedScalarHandler : BaseScalarHandler +{ + public override Task HandleAsync(DerivedScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"Derived: {request.Value}"); + } +} + +public interface INamedScalarRequest : IScalarRequest +{ + 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 +{ + public Task HandleAsync(InterfaceInheritedScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Name}-InterfaceHandled"); + } +} + +public class InterfaceConstrainedScalarHandler : IScalarRequestHandler + where TRequest : INamedScalarRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Name}-ConstrainedByInterface"); + } +} + +public class WrapperScalarRequest : IScalarRequest +{ + public T Item { get; set; } = default!; +} + +public class WrapperScalarHandler : IScalarRequestHandler, string> +{ + public Task HandleAsync(WrapperScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"Handled-{request.Item}"); + } +} + +public class MultiInterfaceScalarRequest : IScalarRequest, IScalarRequest +{ +} + +public class MultiInterfaceScalarHandler : IScalarRequestHandler, IScalarRequestHandler +{ + public Task HandleAsync(MultiInterfaceScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult(1); + } + + Task IScalarRequestHandler.HandleAsync(MultiInterfaceScalarRequest request, CancellationToken ct) + { + return Task.FromResult("One"); + } +} \ No newline at end of file diff --git a/src/Request.Tests/StreamBehaviourTests.cs b/src/Request.Tests/StreamBehaviourTests.cs new file mode 100644 index 0000000..e12d78c --- /dev/null +++ b/src/Request.Tests/StreamBehaviourTests.cs @@ -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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(StreamTestHandler)) + .Add(typeof(StreamTestBehavior))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + 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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(StreamTestHandler)) + .Add(typeof(StreamOpenBehavior<,>))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + 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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(StreamTestHandler)) + .Add(typeof(StreamChainedBehaviour1)) + .Add(typeof(StreamChainedBehaviour2))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + 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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(StreamTestWrapperHandler<>)) + .Add(typeof(StreamWrapperBehavior<>))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + var request = new StreamTestWrapperRequest { 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(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(StreamTestHandler)) + .Add(typeof(StreamOrderingOpenBehavior<,>)) + .Add(typeof(StreamOrderingClosedBehavior))); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + 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 Log { get; } = []; + public bool Executed { get; set; } +} + +public class StreamOrderingOpenBehavior(StreamTestTracker tracker) : IStreamRequestBehavior + where TRequest : IStreamRequest +{ + public IAsyncEnumerable HandleAsync(TRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Open"); + return next(request, cancellationToken); + } +} + +public class StreamOrderingClosedBehavior(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Closed"); + return next(request, cancellationToken); + } +} + +public class StreamTestRequest : IStreamRequest +{ + public string Value { get; set; } = string.Empty; +} + +public class StreamTestHandler : IStreamRequestHandler +{ + public async IAsyncEnumerable 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 +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Executed = true; + return next(request, cancellationToken); + } +} + +public class StreamOpenBehavior(StreamTestTracker tracker) : IStreamRequestBehavior + where TRequest : IStreamRequest +{ + public IAsyncEnumerable HandleAsync(TRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Executed = true; + return next(request, cancellationToken); + } +} + +public class StreamChainedBehaviour1(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Behaviour1"); + return next(request, cancellationToken); + } +} + +public class StreamChainedBehaviour2(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Behaviour2"); + return next(request, cancellationToken); + } +} + +public class StreamTestWrapperRequest : IStreamRequest +{ + public T? Item { get; set; } +} + +public class StreamTestWrapperHandler : IStreamRequestHandler, string> +{ + public async IAsyncEnumerable HandleAsync(StreamTestWrapperRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return $"Handled-{request.Item}-0"; + yield return $"Handled-{request.Item}-1"; + } +} + +public class StreamWrapperBehavior(StreamTestTracker tracker) : IStreamRequestBehavior, string> +{ + public IAsyncEnumerable HandleAsync(StreamTestWrapperRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Executed = true; + return next(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Request.Tests/StreamDispatcherTests.cs b/src/Request.Tests/StreamDispatcherTests.cs new file mode 100644 index 0000000..38c1528 --- /dev/null +++ b/src/Request.Tests/StreamDispatcherTests.cs @@ -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(); + + 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(); + + 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(); + + var request = new UnhandledStreamRequest(); + await Assert.ThrowsAsync(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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + var request = new WrapperStreamRequest { 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(); + + var request = new MultiInterfaceStreamRequest(); + + var results1 = await dispatcher.DispatchAsync(request).ToListAsync(); + var results2 = await dispatcher.DispatchAsync(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(); + + 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(); + + 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(); + + var request = new FailingStreamRequest(); + var enumerable = dispatcher.DispatchAsync(request); + var ex = await Assert.ThrowsAsync(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>().Value; + options.GetRequestHandlers>(default!); + + await Assert.That(() => options.Inspect([])).Throws(); + } +} + +public class FailingStreamRequest : IStreamRequest +{ +} + +public class FailingStreamHandler : IStreamRequestHandler +{ + public async IAsyncEnumerable HandleAsync(FailingStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return "Wait for it..."; + throw new InvalidOperationException("Handler failed"); + } +} + +public class ExplicitGenericStreamRequest : IStreamRequest +{ + public string Value { get; set; } = string.Empty; +} + +public class ExplicitGenericStreamHandler : IStreamRequestHandler +{ + async IAsyncEnumerable IStreamRequestHandler.HandleAsync(ExplicitGenericStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + yield return $"{request.Value}-ExplicitHandled"; + } +} + +public class AmbiguousStreamRequest : IStreamRequest +{ +} + +public class AmbiguousStreamHandler : IStreamRequestHandler +{ + // Public method with the same name and signature + public async IAsyncEnumerable HandleAsync(AmbiguousStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return "Public-Handled"; + } + + // Explicit interface implementation + async IAsyncEnumerable IStreamRequestHandler.HandleAsync(AmbiguousStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return "Interface-Handled"; + } +} + +public class OpenStreamRequest : IStreamRequest +{ + public string Data { get; set; } = string.Empty; +} + +public class InheritedStreamRequest : OpenStreamRequest +{ +} + +public class DeepDerivedStreamRequest : InheritedStreamRequest +{ + public int DeepValue { get; set; } +} + +public class OpenStreamHandler : IStreamRequestHandler + where TRequest : OpenStreamRequest +{ + public async IAsyncEnumerable 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 +{ + public int Value { get; set; } +} + +public class ConstrainedStreamHandler : IStreamRequestHandler + where TRequest : ConstrainedStreamRequest +{ + public async IAsyncEnumerable 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 +{ +} + +public class DerivedStreamRequest : IStreamRequest +{ + public int Value { get; set; } +} + +public abstract class BaseStreamHandler : IStreamRequestHandler + where TRequest : IStreamRequest +{ + public abstract IAsyncEnumerable HandleAsync(TRequest request, CancellationToken cancellationToken); +} + +public class DerivedStreamHandler : BaseStreamHandler +{ + public override async IAsyncEnumerable 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 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 +{ + public async IAsyncEnumerable HandleAsync(InterfaceInheritedStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return $"{request.Name}-InterfaceHandled-0"; + yield return $"{request.Name}-InterfaceHandled-1"; + } +} + +public class InterfaceConstrainedStreamHandler : IStreamRequestHandler + where TRequest : INamedStreamRequest +{ + public async IAsyncEnumerable HandleAsync(TRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return $"{request.Name}-ConstrainedByInterface-0"; + yield return $"{request.Name}-ConstrainedByInterface-1"; + } +} + +public class WrapperStreamRequest : IStreamRequest +{ + public T Item { get; set; } = default!; +} + +public class WrapperStreamHandler : IStreamRequestHandler, string> +{ + public async IAsyncEnumerable HandleAsync(WrapperStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return $"Handled-{request.Item}-0"; + yield return $"Handled-{request.Item}-1"; + } +} + +public class MultiInterfaceStreamRequest : IStreamRequest, IStreamRequest +{ +} + +public class MultiInterfaceStreamHandler : IStreamRequestHandler, IStreamRequestHandler +{ + public async IAsyncEnumerable HandleAsync(MultiInterfaceStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return 1; + yield return 2; + } + + async IAsyncEnumerable IStreamRequestHandler.HandleAsync(MultiInterfaceStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + yield return "One"; + yield return "Two"; + } +} \ No newline at end of file diff --git a/src/Request/Geekeey.Request.csproj b/src/Request/Geekeey.Request.csproj new file mode 100644 index 0000000..1656844 --- /dev/null +++ b/src/Request/Geekeey.Request.csproj @@ -0,0 +1,36 @@ + + + + Library + net10.0 + true + + + + Geekeey.Request + true + + + + + + + + package-readme.md + package-icon.png + https://code.geekeey.de/geekeey/request/src/branch/main/src/request + EUPL-1.2 + + + + + + + + + + + + + + diff --git a/src/Request/IRequestDispatcher.cs b/src/Request/IRequestDispatcher.cs new file mode 100644 index 0000000..47c0d28 --- /dev/null +++ b/src/Request/IRequestDispatcher.cs @@ -0,0 +1,28 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request; + +/// +/// Defines functionality to dispatch requests to their corresponding handlers. +/// +public interface IRequestDispatcher +{ + /// + /// Asynchronously send a request to a handler producing a scalar value. + /// + /// Request object + /// Optional cancellation token + /// Response type + /// A task that represents the send operation. The task result contains the handler response + Task DispatchAsync(IScalarRequest request, CancellationToken cancellationToken = default); + + /// + /// Asynchronously send a request to a handler producing a stream value. + /// + /// Request object + /// Optional cancellation token + /// Response type + /// The created async enumerable, representing the stream of responses. + IAsyncEnumerable DispatchAsync(IStreamRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Request/IRequestDispatcherBuilder.cs b/src/Request/IRequestDispatcherBuilder.cs new file mode 100644 index 0000000..f330d7d --- /dev/null +++ b/src/Request/IRequestDispatcherBuilder.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request; + +/// +/// Represents a builder for configuring and registering request dispatchers and their related components. +/// +public interface IRequestDispatcherBuilder +{ + /// + /// Gets the associated with the request dispatcher builder. + /// Provides access to the underlying service collection for configuring dependencies + /// and registering services required by the request dispatcher. + /// + IServiceCollection Services { get; } +} \ No newline at end of file diff --git a/src/Request/IScalarPipelineBehavior.cs b/src/Request/IScalarPipelineBehavior.cs new file mode 100644 index 0000000..cae0f8b --- /dev/null +++ b/src/Request/IScalarPipelineBehavior.cs @@ -0,0 +1,34 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +#pragma warning disable CA1711 + +namespace Geekeey.Request; + +/// +/// Represents a behavior in the request pipeline, allowing interception, modification, +/// or chaining of asynchronous stream requests and responses. +/// +/// The type of the request being handled. Must implement . +/// The type of the response produced by the implementing request handler. +public interface IScalarRequestBehavior where TRequest : IScalarRequest +{ + /// + /// Handles the asynchronous processing of a request, allowing behavior customization + /// such as interception, modification, or chaining of the request and its response. + /// + /// The request instance being processed. + /// The next delegate in the pipeline to execute after the custom behavior. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. The task result contains the response from the request. + Task HandleAsync(TRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken); +} + +/// +/// Represents the delegate responsible for handling asynchronous requests in a pipeline. +/// +/// The type of the response produced by the implementing request handler. +/// The asynchronous request being processed. +/// A token for monitoring cancellation requests. +/// A task, that represents the asynchronous operation, containing the response of type . +public delegate Task ScalarHandlerDelegate(IScalarRequest request, CancellationToken cancellationToken); \ No newline at end of file diff --git a/src/Request/IScalarRequest.cs b/src/Request/IScalarRequest.cs new file mode 100644 index 0000000..caa3ea8 --- /dev/null +++ b/src/Request/IScalarRequest.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request; + +/// +/// 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. +/// +/// The type of the response produced by the implementing request handler. +public interface IScalarRequest +{ +} \ No newline at end of file diff --git a/src/Request/IScalarRequestHandler.cs b/src/Request/IScalarRequestHandler.cs new file mode 100644 index 0000000..2c4a571 --- /dev/null +++ b/src/Request/IScalarRequestHandler.cs @@ -0,0 +1,20 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request; + +/// +/// Defines a handler for processing requests of a specific type and returning a response. +/// +/// The type of the request to be handled. Must implement . +/// The type of the response produced by the implementing request handler. +public interface IScalarRequestHandler where TRequest : IScalarRequest +{ + /// + /// Processes a scalar request and returns a response asynchronously. + /// + /// The request object to be processed. + /// A token to observe for cancellation requests. + /// A task representing the asynchronous operation, containing the response of type . + Task HandleAsync(TRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Request/IStreamPipelineBehavior.cs b/src/Request/IStreamPipelineBehavior.cs new file mode 100644 index 0000000..dbe6397 --- /dev/null +++ b/src/Request/IStreamPipelineBehavior.cs @@ -0,0 +1,34 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +#pragma warning disable CA1711 + +namespace Geekeey.Request; + +/// +/// Represents a behavior in the request pipeline, allowing interception, modification, +/// or chaining of asynchronous stream requests and responses. +/// +/// The type of the request being processed. Must implement . +/// The type of the response produced by the implementing request handler. +public interface IStreamRequestBehavior where TRequest : IStreamRequest +{ + /// + /// Handles the asynchronous processing of a request, allowing behavior customization + /// such as interception, modification, or chaining of the request and its response. + /// + /// The request instance being processed. + /// The next delegate in the pipeline to execute after the custom behavior. + /// A token to monitor for cancellation requests. + /// An asynchronous stream of representing the processed response. + IAsyncEnumerable HandleAsync(TRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken); +} + +/// +/// Represents the delegate responsible for handling asynchronous requests in a pipeline. +/// +/// The type of the response produced by the implementing request handler. +/// The asynchronous request being processed. +/// A token for monitoring cancellation requests. +/// An asynchronous stream of responses of type . +public delegate IAsyncEnumerable StreamHandlerDelegate(IStreamRequest request, CancellationToken cancellationToken); \ No newline at end of file diff --git a/src/Request/IStreamRequest.cs b/src/Request/IStreamRequest.cs new file mode 100644 index 0000000..f9dab59 --- /dev/null +++ b/src/Request/IStreamRequest.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request; + +/// +/// 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. +/// +/// The type of the response produced by the implementing request handler. +public interface IStreamRequest +{ +} \ No newline at end of file diff --git a/src/Request/IStreamRequestHandler.cs b/src/Request/IStreamRequestHandler.cs new file mode 100644 index 0000000..5d72ca3 --- /dev/null +++ b/src/Request/IStreamRequestHandler.cs @@ -0,0 +1,21 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request; + +/// +/// Defines a handler for processing streaming requests of a specific type and producing +/// a stream of responses. +/// +/// The type of the request to handle. This must implement . +/// The type of the response produced by the implementing request handler. +public interface IStreamRequestHandler where TRequest : IStreamRequest +{ + /// + /// Handles a streaming request and returns a stream of responses asynchronously. + /// + /// The request object to be processed. + /// The token to monitor for cancellation requests. + /// An asynchronous stream of responses of type . + IAsyncEnumerable HandleAsync(TRequest request, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Request/RequestDispatcher.cs b/src/Request/RequestDispatcher.cs new file mode 100644 index 0000000..415af3b --- /dev/null +++ b/src/Request/RequestDispatcher.cs @@ -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 DispatchAsync(IScalarRequest 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)handler).HandleAsync(request, _serviceProvider, cancellationToken); + } + + public IAsyncEnumerable DispatchAsync(IStreamRequest 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)handler).HandleAsync(request, _serviceProvider, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Request/RequestDispatcherBuilderExtensions.cs b/src/Request/RequestDispatcherBuilderExtensions.cs new file mode 100644 index 0000000..006feef --- /dev/null +++ b/src/Request/RequestDispatcherBuilderExtensions.cs @@ -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; + +/// +/// Provides extension methods for configuring +/// with additional capabilities such as searching and registering request handlers in assemblies or adding types directly. +/// +public static class RequestDispatcherBuilderExtensions +{ + /// + /// Searches for request handler types within the specified assembly and adds them to the request dispatcher + /// configuration. + /// + /// The to configure. + /// The assembly to search for request handler types. + /// The instance for further configuration. + 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; + } + + /// + /// Searches for request handler types within the specified assembly and adds them to the request dispatcher + /// configuration with the given service lifetime. + /// + /// The to configure. + /// The assembly to search for request handler types. + /// The lifetime with which the request handlers are registered in the dependency injection container. + /// The instance for further configuration. + 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; + } + + /// + /// Adds the specified type to the request dispatcher configuration for inspection. + /// + /// The to configure. + /// The type to be added to the request dispatcher configuration. + /// The instance for further configuration. + public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, Type type) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOptions() + .Configure(options => options.Inspect([type])); + + return builder; + } + + /// + /// 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. + /// + /// The used to configure the request dispatcher. + /// The type to be added to the request dispatcher configuration. + /// The lifetime scope of the type in the service container. + /// The instance for further configuration. + public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, Type type, ServiceLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOptions() + .Configure(options => options.Inspect([type])); + + builder.Services.Add(new ServiceDescriptor(type, type, lifetime)); + + return builder; + } + + /// + /// Adds the specified collection of types to the request dispatcher configuration for inspection. + /// + /// The to configure. + /// The collection of types to be added to the request dispatcher configuration. + /// The instance for further configuration. + public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, IEnumerable type) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOptions() + .Configure(options => options.Inspect(type)); + + return builder; + } + + /// + /// 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. + /// + /// The to configure. + /// The collection of types to be added to the request dispatcher configuration. + /// The lifetime scope of the types in the service container. + /// The instance for further configuration. + public static IRequestDispatcherBuilder Add(this IRequestDispatcherBuilder builder, IEnumerable type, ServiceLifetime lifetime) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddOptions() + .Configure(options => options.Inspect(type)); + + builder.Services.Add(type.Select(export => new ServiceDescriptor(export, export, lifetime))); + + return builder; + } +} \ No newline at end of file diff --git a/src/Request/RequestDispatcherOptions.cs b/src/Request/RequestDispatcherOptions.cs new file mode 100644 index 0000000..82df863 --- /dev/null +++ b/src/Request/RequestDispatcherOptions.cs @@ -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 _search = []; + private readonly Lazy _behaviorsTypeIndex; + private readonly Lazy _handlersTypeIndex; + + public RequestDispatcherOptions() + { + _behaviorsTypeIndex = new Lazy(() => new BehaviorTypeIndex(_search.Distinct())); + _handlersTypeIndex = new Lazy(() => new HandlerTypeIndex(_search.Distinct())); + } + + public void Inspect(IEnumerable 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 GetRequestBehaviors(IServiceProvider services) + { + return _behaviorsTypeIndex.Value.Resolve(services); + } + + public IEnumerable GetRequestHandlers(IServiceProvider services) + { + return _handlersTypeIndex.Value.Resolve(services); + } + + private abstract class TypeIndex + { + private readonly ConcurrentDictionary> _cache = new(); + + protected readonly Dictionary> _closedTypeInfo = []; + protected readonly List _openTypeInfo = []; + + protected TypeIndex(IEnumerable collection, Func 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 Resolve(IServiceProvider services) + { + return (IEnumerable)_cache.GetOrAdd(typeof(T), CreateResolverFactory)(services); + } + + protected abstract IReadOnlyList IsAssignableTo(Type type); + + private Func> CreateResolverFactory(Type @interface) + { + var list = IsAssignableTo(@interface); + return ResolverFactory; + + IEnumerable 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 collection) + : TypeIndex(collection, IsRequestHandlerType) + { + protected override IReadOnlyList IsAssignableTo(Type @interface) + { + var result = new List(); + + 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 : IRequestHandler + // 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 : IRequestHandler, 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 collection) + : TypeIndex(collection, IsRequestBehaviorType) + { + protected override IReadOnlyList IsAssignableTo(Type @interface) + { + var result = new List(); + + 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 : IRequestBehaviour + var impl = behaviour.MakeGenericType(requestType, responseType); + if (impl.IsAssignableTo(@interface)) + { + result.Add(impl); + } + } + catch (ArgumentException) + { + } + + try + { + // open type case two: Behaviour : IRequestBehaviour, TResponse> + var impl = behaviour.MakeGenericType(requestType.GetGenericArguments()); + if (impl.IsAssignableTo(@interface)) + { + result.Add(impl); + } + } + catch (ArgumentException) + { + } + } + + return result; + } + } +} \ No newline at end of file diff --git a/src/Request/ScalarRequestInvoker.cs b/src/Request/ScalarRequestInvoker.cs new file mode 100644 index 0000000..1f485ea --- /dev/null +++ b/src/Request/ScalarRequestInvoker.cs @@ -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 HandleAsync(object request, IServiceProvider serviceProvider, CancellationToken cancellationToken); +} + +internal abstract class ScalarRequestInvoker : ScalarRequestInvoker +{ + public abstract Task HandleAsync(IScalarRequest request, IServiceProvider serviceProvider, CancellationToken cancellationToken); +} + +internal sealed class ScalarRequestInvoker : ScalarRequestInvoker + where TRequest : IScalarRequest +{ + public override async Task HandleAsync(object request, IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + return await HandleAsync((IScalarRequest)request, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + public override Task HandleAsync(IScalarRequest request, IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var options = serviceProvider.GetRequiredService>().Value; + + var pipeline = options.GetRequestBehaviors>(serviceProvider) + .Reverse() + .Aggregate((ScalarHandlerDelegate)Head, Chain); + + return pipeline(request, cancellationToken); + + static ScalarHandlerDelegate Chain(ScalarHandlerDelegate next, IScalarRequestBehavior filter) + { + return (req, ct) => filter.HandleAsync((TRequest)req, next, ct); + } + + Task Head(IScalarRequest r, CancellationToken ct) + { + return options.GetRequestHandlers>(serviceProvider).First().HandleAsync((TRequest)r, ct); + } + } +} \ No newline at end of file diff --git a/src/Request/ServiceCollectionExtensions.cs b/src/Request/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..1754e3e --- /dev/null +++ b/src/Request/ServiceCollectionExtensions.cs @@ -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; + +/// +/// Provides extension methods for configuring and registering request dispatchers and their dependencies. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds the request dispatcher services to the specified . + /// + /// The service collection to which the request dispatcher services will be added. + /// An instance of to configure the request dispatcher. + public static IRequestDispatcherBuilder AddRequestDispatcher(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions(); + services.TryAddTransient(); + + return new RequestDispatcherBuilder(services); + } + + /// + /// Adds the request dispatcher services to the specified + /// and configures it using the provided . + /// + /// The service collection to which the request dispatcher services will be added. + /// A delegate to configure the request dispatcher builder. + /// The service collection with the request dispatcher services added. + public static IServiceCollection AddRequestDispatcher(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + configure(services.AddRequestDispatcher()); + + return services; + } + + private sealed class RequestDispatcherBuilder(IServiceCollection services) : IRequestDispatcherBuilder + { + public IServiceCollection Services { get; } = services; + } +} \ No newline at end of file diff --git a/src/Request/StreamRequestInvoker.cs b/src/Request/StreamRequestInvoker.cs new file mode 100644 index 0000000..811a670 --- /dev/null +++ b/src/Request/StreamRequestInvoker.cs @@ -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 HandleAsync(object request, IServiceProvider serviceProvider, CancellationToken cancellationToken); +} + +internal abstract class StreamRequestInvoker : StreamRequestInvoker +{ + public abstract IAsyncEnumerable HandleAsync(IStreamRequest request, IServiceProvider serviceProvider, CancellationToken cancellationToken); +} + +internal sealed class StreamRequestInvoker : StreamRequestInvoker + where TRequest : IStreamRequest +{ + public override async IAsyncEnumerable HandleAsync(object request, IServiceProvider serviceProvider, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var item in HandleAsync((IStreamRequest)request, serviceProvider, cancellationToken)) + { + yield return item; + } + } + + public override IAsyncEnumerable HandleAsync(IStreamRequest request, IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var options = serviceProvider.GetRequiredService>().Value; + + var pipeline = options.GetRequestBehaviors>(serviceProvider) + .Reverse() + .Aggregate((StreamHandlerDelegate)Head, Chain); + + return pipeline(request, cancellationToken); + + static StreamHandlerDelegate Chain(StreamHandlerDelegate next, IStreamRequestBehavior filter) + { + return (req, ct) => filter.HandleAsync((TRequest)req, next, ct); + } + + IAsyncEnumerable Head(IStreamRequest r, CancellationToken ct) + { + return options.GetRequestHandlers>(serviceProvider).First().HandleAsync((TRequest)r, ct); + } + } +} \ No newline at end of file diff --git a/src/Request/package-icon.png b/src/Request/package-icon.png new file mode 100644 index 0000000..35f4099 Binary files /dev/null and b/src/Request/package-icon.png differ diff --git a/src/Request/package-readme.md b/src/Request/package-readme.md new file mode 100644 index 0000000..e69de29