commit 28c07c5c3f31ea20235ec69c838add1c89d10a7c 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..d3517aa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,409 @@ +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,config}] +indent_size = 2 +indent_style = space + +[*.{cs,vb}] +#### code quality rule overrides #### + +#> Identifiers should not match keywords +dotnet_diagnostic.CA1716.severity = suggestion + +#### code style rule default severity #### +dotnet_analyzer_diagnostic.category-style.severity = warning + +#> Use top-level statements +dotnet_diagnostic.IDE0210.severity = none + +#### .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 + +# 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 + +dotnet_diagnostic.IDE0046.severity = suggestion +dotnet_style_prefer_conditional_expression_over_return = true + +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 + +# 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 +csharp_style_unused_value_expression_statement_preference = discard_variable + +# '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 diff --git a/.forgejo/workflows/default.yml b/.forgejo/workflows/default.yml new file mode 100644 index 0000000..66269b7 --- /dev/null +++ b/.forgejo/workflows/default.yml @@ -0,0 +1,43 @@ +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 format --verify-no-changes + run: | + dotnet format --no-restore --verify-no-changes --verbosity detailed + + - name: dotnet test + run: | + dotnet test -p:ContinuousIntegrationBuild=true 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..3f87bff --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,38 @@ + + + $(MSBuildThisFileDirectory)artifacts + + + + enable + enable + + + + 1.0.0 + + + + Recommended + true + nullable + true + + + + The Geekeey Team + Copyright (c) The Geekeey Team 2026 + true + true + snupkg + + + + + + + + moderate + all + + 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..30ebd7d --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# `Geekeey.Request` + +Simple mediator implementation in .NET with minimal dependencies. + +## Features + +- **Simple interfaces:** no complex constraints, just marker interfaces that work. +- **Minmal dependencies:** only depends on `Microsoft.Extensions.DependencyInjection.Abstractions` and the + `Microsoft.Extensions.Options` package. + +## Getting Started + +### Install the NuGet package: + +```shell +dotnet add package Geekeey.Request +``` + +You may need to add our NuGet feed to your `nuget.config` this can be done by running the following command: + +```shell +dotnet nuget add source -n geekeey https://code.geekeey.de/api/packages/geekeey/nuget/index.json +``` + +### Usage + +```csharp +public static Task Main() +{ + var collection = new ServiceCollection(); + collection.AddRequestDispatcher(builder => builder + .Add(typeof(ScalarHandler)) + .Add(typeof(ScalarBehavior))); + await using var provider = collection.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + + var request = new ScalarRequest { Value = "Hello" }; + var result = await dispatcher.DispatchAsync(request); + + Console.WriteLine(result); + return 0; +} + +public class ScalarRequest : IScalarRequest +{ + public string Value { get; set; } = string.Empty; +} + +public class ScalarHandler : IScalarRequestHandler +{ + public Task HandleAsync(ScalarTestRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Value} World"); + } +} + +public class ScalarBehavior : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + Console.WriteLine("Before"); + var result = await next(request, cancellationToken); + Console.WriteLine("After"); + return result; + } +} +``` + +## Behaviour of the Handlers + +Handlers are resolved from either the DI conatiner or are created on the fly but can receive arguments from the DI +container when being constructed. The same also applied for the request pipeline behaviours. 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..0cadb86 --- /dev/null +++ b/request.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/request.result.tests/.editorconfig b/src/request.result.tests/.editorconfig new file mode 100644 index 0000000..78f1f31 --- /dev/null +++ b/src/request.result.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.result.tests/ErrorTests.cs b/src/request.result.tests/ErrorTests.cs new file mode 100644 index 0000000..bd58b45 --- /dev/null +++ b/src/request.result.tests/ErrorTests.cs @@ -0,0 +1,27 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ErrorTests +{ + [Test] + public async Task I_can_implicitly_convert_from_string_and_get_string_error() + { + Error error = "error"; + + using var scope = Assert.Multiple(); + await Assert.That(error).IsTypeOf(); + await Assert.That(error.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_implicitly_convert_from_exception_and_get_exception_error() + { + Error error = new CustomTestException(); + + using var scope = Assert.Multiple(); + var instance = await Assert.That(error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } +} \ No newline at end of file diff --git a/src/request.result.tests/ExtensionsEnumerableTests.cs b/src/request.result.tests/ExtensionsEnumerableTests.cs new file mode 100644 index 0000000..e690ef9 --- /dev/null +++ b/src/request.result.tests/ExtensionsEnumerableTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ExtensionsEnumerableTests +{ + [Test] + public async Task I_can_join_sequence_and_get_all_success_when_all_elements_are_success() + { + IEnumerable> xs = [1, 2, 3, 4, 5]; + + var result = xs.Join(); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEquivalentTo([1, 2, 3, 4, 5]); + } + + [Test] + public async Task I_can_join_sequence_and_get_first_failure_when_sequence_contains_failure() + { + IEnumerable> xs = + [ + Prelude.Success(1), + Prelude.Success(2), + Prelude.Failure("error 1"), + Prelude.Success(4), + Prelude.Failure("error 2") + ]; + + var result = xs.Join(); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error 1"); + } + + [Test] + public async Task I_can_join_empty_sequence_and_get_success() + { + IEnumerable> xs = []; + + var result = xs.Join(); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEmpty(); + } +} \ No newline at end of file diff --git a/src/request.result.tests/Geekeey.Request.Result.Tests.csproj b/src/request.result.tests/Geekeey.Request.Result.Tests.csproj new file mode 100644 index 0000000..90f6e18 --- /dev/null +++ b/src/request.result.tests/Geekeey.Request.Result.Tests.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + false + + + + + + + + + + + + + + + diff --git a/src/request.result.tests/PreludeTests.cs b/src/request.result.tests/PreludeTests.cs new file mode 100644 index 0000000..788f4b3 --- /dev/null +++ b/src/request.result.tests/PreludeTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class PreludeTests +{ + [Test] + public async Task I_can_try_with_success_value_and_get_a_success_result() + { + var result = Prelude.Try(() => 2); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(2); + } + + [Test] + public async Task I_can_try_with_throwing_exception_and_get_a_failure_result() + { + var result = Prelude.Try(() => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_with_async_success_value_and_get_a_success_result() + { + var result = await Prelude.TryAsync(() => Task.FromResult(2)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(2); + } + + [Test] + public async Task I_can_try_with_async_throwing_exception_and_get_a_failure_result() + { + var result = await Prelude.TryAsync(Task () => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_with_async_await_throwing_exception_and_get_a_failure_result() + { + var result = await Prelude.TryAsync(async Task () => + { + await Task.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_with_async_success_value_and_get_a_success_result_of_type_ValueTask() + { + var result = await Prelude.TryAsync(() => ValueTask.FromResult(2)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(2); + } + + [Test] + public async Task I_can_try_with_async_throwing_exception_and_get_a_failure_result_of_type_ValueTask() + { + var result = await Prelude.TryAsync(ValueTask () => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_with_async_await_throwing_exception_and_get_a_failure_result_of_type_ValueTask() + { + var result = await Prelude.TryAsync(async ValueTask () => + { + await Task.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } +} \ No newline at end of file diff --git a/src/request.result.tests/ResultConversionTests.cs b/src/request.result.tests/ResultConversionTests.cs new file mode 100644 index 0000000..e957c4e --- /dev/null +++ b/src/request.result.tests/ResultConversionTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ResultConversionTests +{ + [Test] + public async Task I_can_implicitly_convert_from_value_and_get_success() + { + var result = Prelude.Success(2); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.IsFailure).IsFalse(); + await Assert.That(result.Value).IsEqualTo(2); + } + + [Test] + public async Task I_can_implicitly_convert_from_error_and_get_failure() + { + var error = new CustomTestError(); + var result = Prelude.Failure(error); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsTypeOf(); + } + + [Test] + public async Task I_can_unwrap_and_get_value_for_success() + { + var result = Prelude.Success(2); + var value = result.Unwrap(); + + await Assert.That(value).IsEqualTo(2); + } + + [Test] + public async Task I_can_unwrap_and_get_exception_for_failure() + { + var result = Prelude.Failure("error"); + + await Assert.That(result.Unwrap).Throws(); + } + + [Test] + public async Task I_can_explicitly_convert_and_get_value_for_success() + { + var result = Prelude.Success(2); + var value = (int)result; + + await Assert.That(value).IsEqualTo(2); + } + + [Test] + public async Task I_can_explicitly_convert_and_get_exception_for_failure() + { + var result = Prelude.Failure("error"); + + await Assert.That(() => (int)result).Throws(); + } +} \ No newline at end of file diff --git a/src/request.result.tests/ResultEqualityTests.cs b/src/request.result.tests/ResultEqualityTests.cs new file mode 100644 index 0000000..0d6b123 --- /dev/null +++ b/src/request.result.tests/ResultEqualityTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ResultEqualityTests +{ + [Test] + public async Task I_can_equal_t_and_get_true_for_success_with_equal_value() + { + var a = Prelude.Success(2); + var b = 2; + + await Assert.That(a.Equals(b)).IsTrue(); + } + + [Test] + public async Task I_can_equal_t_and_get_false_for_success_with_unequal_value() + { + var a = Prelude.Success(2); + var b = 3; + + await Assert.That(a.Equals(b)).IsFalse(); + } + + [Test] + public async Task I_can_equal_t_and_get_false_for_failure() + { + var a = Prelude.Failure("error"); + var b = 2; + + await Assert.That(a.Equals(b)).IsFalse(); + } + + [Test] + public async Task I_can_equal_result_and_get_true_for_success_and_success_with_equal_value() + { + var a = Prelude.Success(2); + var b = Prelude.Success(2); + + await Assert.That(a.Equals(b)).IsTrue(); + } + + [Test] + public async Task I_can_equal_result_and_get_false_for_success_and_success_with_unequal_value() + { + var a = Prelude.Success(2); + var b = Prelude.Success(3); + + await Assert.That(a.Equals(b)).IsFalse(); + } + + [Test] + public async Task I_can_equals_result_and_get_false_for_success_and_failure() + { + var a = Prelude.Success(2); + var b = Prelude.Failure("error 1"); + + await Assert.That(a.Equals(b)).IsFalse(); + } + + [Test] + public async Task I_can_equals_result_and_get_false_for_failure_and_success() + { + var a = Prelude.Failure("error"); + var b = Prelude.Success(2); + + await Assert.That(a.Equals(b)).IsFalse(); + } + + [Test] + public async Task I_can_equals_result_and_get_true_for_failure_and_failure() + { + var a = Prelude.Failure("error 1"); + var b = Prelude.Failure("error 2"); + + await Assert.That(a.Equals(b)).IsTrue(); + } + + [Test] + public async Task I_can_equal_t_and_get_true_for_success_with_equal_value_using_comparer() + { + var a = Prelude.Success(2); + var b = 2; + + await Assert.That(a.Equals(b, EqualityComparer.Default)).IsTrue(); + } + + [Test] + public async Task I_can_equal_t_and_get_false_for_success_with_unequal_value_using_comparer() + { + var a = Prelude.Success(2); + var b = 3; + + await Assert.That(a.Equals(b, EqualityComparer.Default)).IsFalse(); + } + + [Test] + public async Task I_can_equal_t_and_get_false_for_failure_using_comparer() + { + var a = Prelude.Failure("error"); + var b = 2; + + await Assert.That(a.Equals(b, EqualityComparer.Default)).IsFalse(); + } + + [Test] + public async Task I_can_equal_result_and_get_true_for_success_and_success_with_equal_value_using_comparer() + { + var a = Prelude.Success(2); + var b = Prelude.Success(2); + + await Assert.That(a.Equals(b, EqualityComparer.Default)).IsTrue(); + } + + [Test] + public async Task I_can_equal_result_and_get_false_for_success_and_success_with_unequal_value_using_comparer() + { + var a = Prelude.Success(2); + var b = Prelude.Success(3); + + await Assert.That(a.Equals(b, EqualityComparer.Default)).IsFalse(); + } + + [Test] + public async Task I_can_equals_result_and_get_false_for_success_and_failure_using_comparer() + { + var a = Prelude.Success(2); + var b = Prelude.Failure("error 1"); + + await Assert.That(a.Equals(b, EqualityComparer.Default)).IsFalse(); + } + + [Test] + public async Task I_can_equals_result_and_get_false_for_failure_and_success_using_comparer() + { + var a = Prelude.Failure("error"); + var b = Prelude.Success(2); + + await Assert.That(a.Equals(b, EqualityComparer.Default)).IsFalse(); + } + + [Test] + public async Task I_can_equals_result_and_get_true_for_failure_and_failure_using_comparer() + { + var a = Prelude.Failure("error 1"); + var b = Prelude.Failure("error 2"); + + await Assert.That(a.Equals(b, EqualityComparer.Default)).IsTrue(); + } + + [Test] + public async Task I_can_get_hashcode_and_get_hashcode_for_success() + { + var result = Prelude.Success(2); + + await Assert.That(result.GetHashCode()).IsEqualTo(2.GetHashCode()); + } + + [Test] + public async Task I_can_get_hashcode_and_get_zero_for_null() + { + var result = Prelude.Success(null); + + await Assert.That(result.GetHashCode()).IsZero(); + } + + [Test] + public async Task I_can_get_hashcode_and_get_zero_for_failure() + { + var result = Prelude.Failure("error"); + + await Assert.That(result.GetHashCode()).IsZero(); + } +} \ No newline at end of file diff --git a/src/request.result.tests/ResultMatchingTests.cs b/src/request.result.tests/ResultMatchingTests.cs new file mode 100644 index 0000000..c7ce08d --- /dev/null +++ b/src/request.result.tests/ResultMatchingTests.cs @@ -0,0 +1,232 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ResultMatchingTests +{ + [Test] + public async Task I_can_match_and_it_calls_success_func_for_success() + { + var result = Prelude.Success(2); + var match = result.Match( + v => v, + _ => throw new InvalidOperationException()); + + await Assert.That(match).IsEqualTo(2); + } + + [Test] + public async Task I_can_match_and_it_calls_failure_func_for_failure() + { + var result = Prelude.Failure("error"); + var match = result.Match( + _ => throw new InvalidOperationException(), + e => e); + + await Assert.That(match.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_switch_and_it_calls_success_action_for_success() + { + var called = false; + var value = default(int); + + var result = Prelude.Success(2); + result.Switch(OnSuccess, OnFailure); + + await Assert.That(called).IsTrue(); + await Assert.That(value).IsEqualTo(2); + + return; + + void OnSuccess(int i) + { + value = i; + called = true; + } + + void OnFailure(Error e) + { + throw new InvalidOperationException(); + } + } + + [Test] + public async Task I_can_switch_and_it_calls_failure_action_for_failure() + { + var called = false; + var value = default(Error); + + var result = Prelude.Failure("error"); + result.Switch(OnSuccess, OnFailure); + + await Assert.That(called).IsTrue(); + await Assert.That(value?.Message).IsEqualTo("error"); + + return; + + void OnSuccess(int i) + { + throw new InvalidOperationException(); + } + + void OnFailure(Error e) + { + value = e; + called = true; + } + } + + [Test] + public async Task I_can_match_async_and_it_calls_success_func_for_success() + { + var result = Prelude.Success(2); + var match = await result.MatchAsync( + Task.FromResult, + _ => throw new InvalidOperationException()); + + await Assert.That(match).IsEqualTo(2); + } + + [Test] + public async Task I_can_match_async_and_it_calls_failure_func_for_failure() + { + var result = Prelude.Failure("error"); + var match = await result.MatchAsync( + _ => throw new InvalidOperationException(), + Task.FromResult); + + await Assert.That(match.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_switch_async_and_it_calls_success_action_for_success() + { + var called = false; + var value = default(int); + + var result = Prelude.Success(2); + await result.SwitchAsync(OnSuccess, OnFailure); + + await Assert.That(called).IsTrue(); + await Assert.That(value).IsEqualTo(2); + return; + + Task OnSuccess(int i) + { + value = i; + called = true; + return Task.CompletedTask; + } + + Task OnFailure(Error e) + { + throw new InvalidOperationException(); + } + } + + [Test] + public async Task I_can_switch_async_and_it_calls_failure_action_for_failure() + { + var called = false; + var value = default(Error); + + var result = Prelude.Failure("error"); + await result.SwitchAsync(OnSuccess, OnFailure); + + await Assert.That(called).IsTrue(); + await Assert.That(value?.Message).IsEqualTo("error"); + + return; + + Task OnSuccess(int i) + { + throw new InvalidOperationException(); + } + + Task OnFailure(Error e) + { + value = e; + called = true; + return Task.CompletedTask; + } + } + + [Test] + public async Task I_can_match_and_it_calls_success_func_for_success_ValueTask() + { + var result = Prelude.Success(2); + var match = await result.MatchAsync( + ValueTask.FromResult, + _ => throw new InvalidOperationException()); + + await Assert.That(match).IsEqualTo(2); + } + + [Test] + public async Task I_can_match_async_and_it_calls_failure_func_for_failure_ValueTask() + { + var result = Prelude.Failure("error"); + var match = await result.MatchAsync( + _ => throw new InvalidOperationException(), + ValueTask.FromResult); + + await Assert.That(match.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_switch_async_and_it_calls_success_action_for_success_ValueTask() + { + var called = false; + var value = default(int); + + var result = Prelude.Success(2); + await result.SwitchAsync(OnSuccess, OnFailure); + + await Assert.That(called).IsTrue(); + await Assert.That(value).IsEqualTo(2); + + return; + + ValueTask OnSuccess(int i) + { + value = i; + called = true; + return ValueTask.CompletedTask; + } + + ValueTask OnFailure(Error e) + { + throw new InvalidOperationException(); + } + } + + [Test] + public async Task I_can_switch_async_and_it_calls_failure_action_for_failure_ValueTask() + { + var called = false; + var value = default(Error); + + var result = Prelude.Failure("error"); + await result.SwitchAsync(OnSuccess, OnFailure); + + await Assert.That(called).IsTrue(); + await Assert.That(value?.Message).IsEqualTo("error"); + + return; + + ValueTask OnSuccess(int i) + { + throw new InvalidOperationException(); + } + + ValueTask OnFailure(Error e) + { + value = e; + called = true; + return ValueTask.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/request.result.tests/ResultTests.cs b/src/request.result.tests/ResultTests.cs new file mode 100644 index 0000000..664567d --- /dev/null +++ b/src/request.result.tests/ResultTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ResultTests +{ + [Test] + public async Task I_can_create_new_success_result_from_t() + { + var result = new Result(1); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.IsFailure).IsFalse(); + await Assert.That(result.Value).IsNotEqualTo(default); + await Assert.That(result.Error).IsNull(); + } + + [Test] + public async Task I_can_create_new_failure_result_from_error() + { + var result = new Result(new CustomTestError()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Value).IsEqualTo(default(int)); + await Assert.That(result.Error).IsTypeOf(); + } + + [Test] + public async Task I_can_distinguish_default_result_from_created() + { + var result = default(Result); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Value).IsEqualTo(default(int)); + await Assert.That(result.Error).IsEqualTo(Error.DefaultValueError); + } + + [Test] + public async Task I_can_to_string_success_result_value() + { + Result result = 2; + + await Assert.That(result.ToString()).IsEqualTo("Success { 2 }"); + } + + [Test] + public async Task I_can_to_string_failure_result_value() + { + Result result = new StringError("error"); + + await Assert.That(result.ToString()).IsEqualTo("Failure { error }"); + } +} \ No newline at end of file diff --git a/src/request.result.tests/ResultTransformTests.cs b/src/request.result.tests/ResultTransformTests.cs new file mode 100644 index 0000000..3776db4 --- /dev/null +++ b/src/request.result.tests/ResultTransformTests.cs @@ -0,0 +1,644 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Globalization; + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ResultTransformTests +{ + [Test] + public async Task I_can_map_and_it_returns_success_for_success() + { + var start = Prelude.Success(2); + var result = start.Map(value => value.ToString(CultureInfo.InvariantCulture)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_map_and_it_returns_failure_for_failure() + { + var start = Prelude.Failure("error"); + var result = start.Map(value => value.ToString(CultureInfo.InvariantCulture)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_transform_result_with_then_and_it_returns_success_for_success_and_mapping_returning_success() + { + var start = Prelude.Success(2); + var result = start.Then(value => Prelude.Success(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_transform_result_with_then_and_it_returns_failure_for_success_and_mapping_returning_failure() + { + var start = Prelude.Success(2); + var result = start.Then(_ => Prelude.Failure("error")); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_transform_result_with_then_and_it_returns_failure_for_failure_and_mapping_returning_success() + { + var start = Prelude.Failure("error"); + var result = start.Then(value => Prelude.Success(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_transform_result_with_then_and_it_returns_failure_for_failure_and_mapping_returning_failure() + { + var start = Prelude.Failure("error"); + var result = start.Then(_ => Prelude.Failure("error 2")); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_map_and_it_returns_success_for_success_without_throwing() + { + var start = Prelude.Success(2); + var result = start.TryMap(value => value.ToString(CultureInfo.InvariantCulture)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_try_map_and_it_returns_failure_for_failure_without_throwing() + { + var start = Prelude.Failure("error"); + var result = start.TryMap(value => value.ToString(CultureInfo.InvariantCulture)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_map_and_it_returns_failure_for_success_with_throwing() + { + var start = Prelude.Success(2); + var result = start.TryMap(_ => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_map_and_it_returns_failure_for_failure_with_throwing() + { + var start = Prelude.Failure("error"); + var result = start.TryMap(_ => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_with_then_and_it_returns_success_for_success_and_mapping_returning_success() + { + var start = Prelude.Success(2); + var result = start.ThenTry(value => Prelude.Success(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_try_transform_result_with_then_and_it_returns_failure_for_success_and_mapping_returning_failure() + { + var start = Prelude.Success(2); + var result = start.ThenTry(_ => Prelude.Failure("error")); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_with_then_and_it_returns_failure_for_failure_and_mapping_returning_failure() + { + var start = Prelude.Failure("error"); + var result = start.ThenTry(x => Prelude.Success(x.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_with_then_and_it_returns_failure_for_success_and_mapping_throwing() + { + var start = Prelude.Success(2); + var result = start.ThenTry(_ => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_transform_result_with_then_and_it_returns_failure_for_failure_and_mapping_throwing() + { + var start = Prelude.Failure("error"); + var result = start.ThenTry(_ => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_map_async_and_it_returns_success_for_success() + { + var start = Prelude.Success(2); + var result = await start.MapAsync(value => Task.FromResult(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_map_async_and_it_returns_failure_for_failure() + { + var start = Prelude.Failure("error"); + var result = await start.MapAsync(value => Task.FromResult(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_transform_result_async_with_then_and_it_returns_success_for_success_and_mapping_returning_success() + { + var start = Prelude.Success(2); + var result = await start.ThenAsync(value => Task.FromResult(Prelude.Success(value.ToString(CultureInfo.InvariantCulture)))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_transform_result_async_with_then_and_it_returns_failure_for_success_and_mapping_returning_failure() + { + var start = Prelude.Success(2); + var result = await start.ThenAsync(_ => Task.FromResult(Prelude.Failure("error"))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_returning_success() + { + var start = Prelude.Failure("error"); + var result = await start.ThenAsync(value => Task.FromResult(Prelude.Success(value.ToString(CultureInfo.InvariantCulture)))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_returning_failure() + { + var start = Prelude.Failure("error"); + var result = await start.ThenAsync(_ => Task.FromResult(Prelude.Failure("error 2"))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_success_for_success_without_throwing() + { + var start = Prelude.Success(2); + var result = await start.TryMapAsync(value => Task.FromResult(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_failure_without_throwing() + { + var start = Prelude.Failure("error"); + var result = await start.TryMapAsync(value => Task.FromResult(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_success_with_throwing() + { + var start = Prelude.Success(2); + var result = await start.TryMapAsync(Task (_) => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_success_with_await_throwing() + { + var start = Prelude.Success(2); + var result = await start.TryMapAsync(async Task (_) => + { + await Task.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_failure_with_throwing() + { + var start = Prelude.Failure("error"); + var result = await start.TryMapAsync(Task (_) => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_failure_with_await_throwing() + { + var start = Prelude.Failure("error"); + var result = await start.TryMapAsync(async Task (_) => + { + await Task.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_success_for_success_and_mapping_returning_success() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(value => Task.FromResult(Prelude.Success(value.ToString(CultureInfo.InvariantCulture)))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_success_and_mapping_returning_failure() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(_ => Task.FromResult(Prelude.Failure("error"))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_returning_failure() + { + var start = Prelude.Failure("error"); + var result = await start.ThenTryAsync(x => Task.FromResult(Prelude.Success(x.ToString(CultureInfo.InvariantCulture)))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_success_and_mapping_throwing() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(Task> (_) => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_success_and_mapping_await_throwing() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(async Task> (_) => + { + await Task.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_throwing() + { + var start = Prelude.Failure("error"); + var result = await start.ThenTryAsync(Task> (_) => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_await_throwing() + { + var start = Prelude.Failure("error"); + var result = await start.ThenTryAsync(async Task> (_) => + { + await Task.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_map_async_and_it_returns_success_for_success_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.MapAsync(value => ValueTask.FromResult(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_map_async_and_it_returns_failure_for_failure_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.MapAsync(value => ValueTask.FromResult(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_transform_result_async_with_then_and_it_returns_success_for_success_and_mapping_returning_success_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.ThenAsync(value => ValueTask.FromResult(Prelude.Success(value.ToString(CultureInfo.InvariantCulture)))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_transform_result_async_with_then_and_it_returns_failure_for_success_and_mapping_returning_failure_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.ThenAsync(_ => ValueTask.FromResult(Prelude.Failure("error"))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_returning_success_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.ThenAsync(value => ValueTask.FromResult(Prelude.Success(value.ToString(CultureInfo.InvariantCulture)))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_returning_failure_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.ThenAsync(_ => ValueTask.FromResult(Prelude.Failure("error 2"))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_success_for_success_without_throwing_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.TryMapAsync(value => ValueTask.FromResult(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_failure_without_throwing_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.TryMapAsync(value => ValueTask.FromResult(value.ToString(CultureInfo.InvariantCulture))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_success_with_throwing_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.TryMapAsync(ValueTask (_) => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_success_with_await_throwing_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.TryMapAsync(async ValueTask (_) => + { + await ValueTask.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_failure_with_throwing_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.TryMapAsync(ValueTask (_) => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_map_async_and_it_returns_failure_for_failure_with_await_throwing_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.TryMapAsync(async ValueTask (_) => + { + await ValueTask.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_success_for_success_and_mapping_returning_success_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(value => ValueTask.FromResult(Prelude.Success(value.ToString(CultureInfo.InvariantCulture)))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_success_and_mapping_returning_failure_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(_ => ValueTask.FromResult(Prelude.Failure("error"))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_returning_failure_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.ThenTryAsync(x => ValueTask.FromResult(Prelude.Success(x.ToString(CultureInfo.InvariantCulture)))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_success_and_mapping_throwing_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(ValueTask> (_) => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_success_and_mapping_await_throwing_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(async ValueTask> (_) => + { + await ValueTask.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + var instance = await Assert.That(result.Error).IsTypeOf(); + await Assert.That(instance?.Exception).IsTypeOf(); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_throwing_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.ThenTryAsync(ValueTask> (_) => throw new CustomTestException()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_transform_result_async_with_then_and_it_returns_failure_for_failure_and_mapping_await_throwing_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.ThenTryAsync(async ValueTask> (_) => + { + await ValueTask.CompletedTask; + throw new CustomTestException(); + }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error).IsTypeOf(); + await Assert.That(result.Error?.Message).IsEqualTo("error"); + } +} \ No newline at end of file diff --git a/src/request.result.tests/ResultUnboxTests.cs b/src/request.result.tests/ResultUnboxTests.cs new file mode 100644 index 0000000..af1af1e --- /dev/null +++ b/src/request.result.tests/ResultUnboxTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ResultUnboxTests +{ + [Test] + public async Task I_can_try_get_value_and_it_returns_true_and_sets_value_for_success_with_1_param() + { + var result = Prelude.Success(2); + var ok = result.TryGetValue(out int value); + + using var scope = Assert.Multiple(); + await Assert.That(ok).IsTrue(); + await Assert.That(value).IsEqualTo(2); + } + + [Test] + public async Task I_can_try_get_value_and_it_returns_false_for_failure_with_1_param() + { + var result = Prelude.Failure("error"); + var ok = result.TryGetValue(out int value); + + using var scope = Assert.Multiple(); + await Assert.That(ok).IsFalse(); + await Assert.That(value).IsEqualTo(default(int)); + } + + [Test] + public async Task I_can_try_get_value_and_it_returns_true_and_sets_value_for_success_with_2_param() + { + var result = Prelude.Success(2); + var ok = result.TryGetValue(out int value, out var error); + + using var scope = Assert.Multiple(); + await Assert.That(ok).IsTrue(); + await Assert.That(value).IsEqualTo(2); + await Assert.That(error).IsEqualTo(default(Error)); + } + + [Test] + public async Task I_can_try_get_value_and_it_returns_false_and_sets_error_for_failure_with_2_param() + { + var result = Prelude.Failure("error"); + var ok = result.TryGetValue(out int value, out var error); + + using var scope = Assert.Multiple(); + await Assert.That(ok).IsFalse(); + await Assert.That(value).IsEqualTo(default(int)); + await Assert.That(error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_get_error_and_it_returns_true_and_sets_error_for_failure_with_1_param() + { + var result = Prelude.Failure("error"); + var ok = result.TryGetValue(out Error? error); + + using var scope = Assert.Multiple(); + await Assert.That(ok).IsTrue(); + await Assert.That(error?.Message).IsEqualTo("error"); + } + + [Test] + public async Task I_can_try_get_error_and_it_returns_false_for_success_with_1_param() + { + var result = Prelude.Success(2); + var ok = result.TryGetValue(out Error? error); + + using var scope = Assert.Multiple(); + await Assert.That(ok).IsFalse(); + await Assert.That(error).IsEqualTo(default(Error)); + } + + [Test] + public async Task I_can_try_get_error_and_it_returns_true_and_sets_error_for_failure_with_2_param() + { + var result = Prelude.Failure("error"); + var ok = result.TryGetValue(out Error? error, out var value); + + using var scope = Assert.Multiple(); + await Assert.That(ok).IsTrue(); + await Assert.That(error?.Message).IsEqualTo("error"); + await Assert.That(value).IsEqualTo(default(int)); + } + + [Test] + public async Task I_can_try_get_error_and_it_returns_false_and_sets_value_for_success_with_2_param() + { + var result = Prelude.Success(2); + var ok = result.TryGetValue(out Error? error, out var value); + + using var scope = Assert.Multiple(); + await Assert.That(ok).IsFalse(); + await Assert.That(error).IsEqualTo(default(Error)); + await Assert.That(value).IsEqualTo(2); + } +} \ No newline at end of file diff --git a/src/request.result.tests/_fixtures/CustomTestError.cs b/src/request.result.tests/_fixtures/CustomTestError.cs new file mode 100644 index 0000000..c513a1a --- /dev/null +++ b/src/request.result.tests/_fixtures/CustomTestError.cs @@ -0,0 +1,11 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class CustomTestError : Error +{ + internal const string DefaultMessage = "This is a custom error for test"; + + public override string Message => DefaultMessage; +} \ No newline at end of file diff --git a/src/request.result.tests/_fixtures/CustomTestException.cs b/src/request.result.tests/_fixtures/CustomTestException.cs new file mode 100644 index 0000000..28225e5 --- /dev/null +++ b/src/request.result.tests/_fixtures/CustomTestException.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +internal sealed class CustomTestException : Exception +{ +} \ No newline at end of file diff --git a/src/request.result/Geekeey.Request.Result.csproj b/src/request.result/Geekeey.Request.Result.csproj new file mode 100644 index 0000000..b39f727 --- /dev/null +++ b/src/request.result/Geekeey.Request.Result.csproj @@ -0,0 +1,30 @@ + + + + Library + net10.0 + true + + + + true + + + + + + + + package-readme.md + package-icon.png + https://code.geekeey.de/geekeey/request/src/branch/main/src/request.result + EUPL-1.2 + + + + + + + + + diff --git a/src/request.result/Prelude.cs b/src/request.result/Prelude.cs new file mode 100644 index 0000000..cd23136 --- /dev/null +++ b/src/request.result/Prelude.cs @@ -0,0 +1,101 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.Contracts; + +namespace Geekeey.Request.Result; + +/// +/// A class containing various utility methods, a 'prelude' to the rest of the library. +/// +/// +/// This class is meant to be imported statically, e.g. using static Geekeey.Extensions.Result.Prelude;. +/// Recommended to be imported globally via a global using statement. +/// +public static class Prelude +{ + /// + /// Creates a result containing a success value. + /// + /// The type of the success value. + /// The success value to create the result from. + [Pure] + public static Result Success(T value) + { + return new Result(value); + } + + /// + /// Creates a result containing a failure value. + /// + /// The type of success value in the result. + /// The failure value to create the result from. + [Pure] + public static Result Failure(Error error) + { + return new Result(error); + } + + /// + /// Tries to execute a function and return the result. If the function throws an exception, the exception will be + /// returned wrapped in an . + /// + /// The type the function returns. + /// The function to try to execute. + /// A result containing the return value of the function or an containing the + /// exception thrown by the function. + [Pure] + public static Result Try(Func function) + { + try + { + return new Result(function()); + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } + + /// + /// Tries to execute an asynchronous function and return the result. If the function throws an exception, the + /// exception will be returned wrapped in an . + /// + /// The type the function returns. + /// The function to try to execute. + /// A result containing the return value of the function or an containing the + /// exception thrown by the function. + [Pure] + public static async ValueTask> TryAsync(Func> function) + { + try + { + return new Result(await function()); + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } + + /// + /// Tries to execute an asynchronous function and return the result. If the function throws an exception, the + /// exception will be returned wrapped in an . + /// + /// The type the function returns. + /// The function to try to execute. + /// A result containing the return value of the function or an containing the + /// exception thrown by the function. + [Pure] + public static async Task> TryAsync(Func> function) + { + try + { + return new Result(await function()); + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } +} \ No newline at end of file diff --git a/src/request.result/Result.Conversion.cs b/src/request.result/Result.Conversion.cs new file mode 100644 index 0000000..6c87e3d --- /dev/null +++ b/src/request.result/Result.Conversion.cs @@ -0,0 +1,51 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.Contracts; + +namespace Geekeey.Request.Result; + +public readonly partial struct Result +{ + /// + /// Implicitly constructs a result from a success value. + /// + /// The value to construct the result from. + [Pure] + public static implicit operator Result(T value) + { + return new Result(value); + } + + /// + /// Implicitly constructs a result from a failure value. + /// + /// The error to construct the result from. + [Pure] + public static implicit operator Result(Error error) + { + return new Result(error); + } + + /// + /// Unwraps the success value of the result. Throws an if the result is a failure. + /// + /// + /// This call is unsafe in the sense that it might intentionally throw an exception. Please only use this + /// call if the caller knows that this operation is safe, or that an exception is acceptable to be thrown. + /// + /// The success value of the result. + /// The result is not a success. + [Pure] + public T Unwrap() + { + return IsSuccess ? Value : throw new UnwrapException(); + } + + /// + [Pure] + public static explicit operator T(Result result) + { + return result.Unwrap(); + } +} \ No newline at end of file diff --git a/src/request.result/Result.Equality.cs b/src/request.result/Result.Equality.cs new file mode 100644 index 0000000..babaf81 --- /dev/null +++ b/src/request.result/Result.Equality.cs @@ -0,0 +1,163 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; +using System.Numerics; + +namespace Geekeey.Request.Result; + +public readonly partial struct Result : IEquatable>, IEquatable +{ + /// + /// Checks whether the result is equal to another result. Results are equal if both results are success values and + /// the success values are equal, or if both results are failures. + /// + /// The result to check for equality with the current result. + [Pure] + public bool Equals(Result other) + { + return Equals(this, other, EqualityComparer.Default); + } + + /// + /// Checks whether the result is equal to another result. Results are equal if both results are success values and + /// the success values are equal, or if both results are failures. + /// + /// The result to check for equality with the current result. + /// The equality comparer to use for comparing values. + [Pure] + public bool Equals(Result other, IEqualityComparer comparer) + { + return Equals(this, other, comparer); + } + + /// + /// Checks whether the result is a success value and the success value is equal to another value. + /// + /// The value to check for equality with the success value of the result. + [Pure] + public bool Equals(T? other) + { + return Equals(this, other, EqualityComparer.Default); + } + + /// + /// Checks whether the result is a success value and the success value is equal to another value using a specified + /// equality comparer. + /// + /// The value to check for equality with the success value of the result. + /// The equality comparer to use for comparing values. + [Pure] + public bool Equals(T? other, IEqualityComparer comparer) + { + return Equals(this, other, comparer); + } + + /// + [Pure] + public override bool Equals(object? obj) + { + return (obj is T x && Equals(x)) || (obj is Result r && Equals(r)); + } + + /// + [Pure] + public override int GetHashCode() + { + return GetHashCode(this, EqualityComparer.Default); + } + + internal static bool Equals(Result a, Result b, IEqualityComparer comparer) + { + if (!a.IsSuccess || !b.IsSuccess) + { + return !a.IsSuccess && !b.IsSuccess; + } + + if (a.Value is null || b.Value is null) + { + return a.Value is null && b.Value is null; + } + + return comparer.Equals(a.Value, b.Value); + } + + internal static bool Equals(Result a, T? b, IEqualityComparer comparer) + { + if (!a.IsSuccess) + { + return false; + } + + if (a.Value is null || b is null) + { + return a.Value is null && b is null; + } + + return comparer.Equals(a.Value, b); + } + + internal static int GetHashCode(Result result, IEqualityComparer comparer) + { + if (result is { IsSuccess: true, Value: not null }) + { + return comparer.GetHashCode(result.Value); + } + + return 0; + } +} + +public readonly partial struct Result : IEqualityOperators, Result, bool>, IEqualityOperators, T, bool> +{ + /// + /// Checks whether two results are equal. Results are equal if both results are success values and the success + /// values are equal, or if both results are failures. + /// + /// The first result to compare. + /// The second result to compare. + [Pure] + [ExcludeFromCodeCoverage] + public static bool operator ==(Result a, Result b) + { + return Equals(a, b, EqualityComparer.Default); + } + + /// + /// Checks whether two results are not equal. Results are equal if both results are success values and the success + /// values are equal, or if both results are failures. + /// + /// The first result to compare. + /// The second result to compare. + [Pure] + [ExcludeFromCodeCoverage] + public static bool operator !=(Result a, Result b) + { + return !Equals(a, b, EqualityComparer.Default); + } + + /// + /// Checks whether a result is a success value and the success value is equal to another value. + /// + /// The result to compare. + /// The value to check for equality with the success value in the result. + [Pure] + [ExcludeFromCodeCoverage] + public static bool operator ==(Result a, T? b) + { + return Equals(a, b, EqualityComparer.Default); + } + + /// + /// Checks whether a result either does not have a value, or the value is not equal to another value. + /// + /// The result to compare. + /// The value to check for inequality with the success value in the result. + [Pure] + [ExcludeFromCodeCoverage] + public static bool operator !=(Result a, T? b) + { + return !Equals(a, b, EqualityComparer.Default); + } +} \ No newline at end of file diff --git a/src/request.result/Result.Matching.cs b/src/request.result/Result.Matching.cs new file mode 100644 index 0000000..9520111 --- /dev/null +++ b/src/request.result/Result.Matching.cs @@ -0,0 +1,104 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.Contracts; + +namespace Geekeey.Request.Result; + +public readonly partial struct Result +{ + /// + /// Matches over the success value or failure value of the result and returns another value. Can be conceptualized + /// as an exhaustive switch expression matching all possible values of the type. + /// + /// The type to return from the match. + /// The function to apply to the success value of the result if the result is a success. + /// The function to apply to the failure value of the result if the result is a failure. + /// The result of applying either or on the success + /// value or failure value of the result. + [Pure] + public TResult Match(Func success, Func failure) + { + return IsSuccess ? success(Value!) : failure(Error!); + } + + /// + /// Matches over the success value or failure value of the result and invokes an effectful action onto the success + /// value or failure value. Can be conceptualized as an exhaustive switch statement matching all possible + /// values of the type. + /// + /// The function to call with the success value of the result if the result is a success. + /// The function to call with the failure value of the result if the result is a failure. + public void Switch(Action success, Action failure) + { + if (IsSuccess) + { + success(Value!); + } + else + { + failure(Error!); + } + } +} + +public readonly partial struct Result +{ + /// + /// Asynchronously matches over the success value or failure value of the result and returns another value. Can be + /// conceptualized as an exhaustive switch expression matching all possible values of the type. + /// + /// The type to return from the match. + /// The function to apply to the success value of the result if the result is a success. + /// The function to apply to the failure value of the result if the result is a failure. + /// A task completing with the result of applying either or + /// on the success value or failure value of the result. + [Pure] + public async Task MatchAsync(Func> success, Func> failure) + { + return IsSuccess ? await success(Value!) : await failure(Error!); + } + + /// + /// Asynchronously matches over the success value or failure value of the result and invokes an effectful action + /// onto the success value or failure value. Can be conceptualized as an exhaustive switch statement matching + /// all possible values of + /// the type. + /// + /// The function to call with the success value of the result if the result is a success. + /// The function to call with the failure value of the result if the result is a failure. + public Task SwitchAsync(Func success, Func failure) + { + return IsSuccess ? success(Value!) : failure(Error!); + } +} + +public readonly partial struct Result +{ + /// + /// Asynchronously matches over the success value or failure value of the result and returns another value. Can be + /// conceptualized as an exhaustive switch expression matching all possible values of the type. + /// + /// The type to return from the match. + /// The function to apply to the success value of the result if the result is a success. + /// The function to apply to the failure value of the result if the result is a failure. + /// A task completing with the result of applying either or + /// on the success value or failure value of the result. + [Pure] + public ValueTask MatchAsync(Func> success, Func> failure) + { + return IsSuccess ? success(Value!) : failure(Error!); + } + + /// + /// Asynchronously matches over the success value or failure value of the result and invokes an effectful action + /// onto the success value or the failure value. Can be conceptualized as an exhaustive switch statement + /// matching all possible values of the type. + /// + /// The function to call with the success value of the result if the result is a success. + /// The function to call with the failure value of the result if the result is a failure. + public ValueTask SwitchAsync(Func success, Func failure) + { + return IsSuccess ? success(Value!) : failure(Error!); + } +} \ No newline at end of file diff --git a/src/request.result/Result.Transform.cs b/src/request.result/Result.Transform.cs new file mode 100644 index 0000000..f9d1378 --- /dev/null +++ b/src/request.result/Result.Transform.cs @@ -0,0 +1,366 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.Contracts; + +namespace Geekeey.Request.Result; + +public readonly partial struct Result +{ + /// + /// Maps the success value of the result using a mapping function, or does nothing if the result is a failure. + /// + /// The function used to map the success value. + /// The type of the new value. + /// A new result containing either the mapped success value or the failure value of the original + /// result. + [Pure] + public Result Map(Func func) + { + return IsSuccess ? new Result(func(Value!)) : new Result(Error!); + } + + /// + /// Tries to map the success value of the result using a mapping function, or does nothing if the result is a + /// failure. If the mapping function throws an exception, the exception will be returned wrapped in an + /// . + /// + /// The function used to map the success value. + /// The type of the new value. + /// A new result containing either the mapped value, the exception thrown by + /// wrapped in an , or the failure value of the original result. + [Pure] + public Result TryMap(Func func) + { + try + { + return Map(func); + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } + + /// + /// Maps the success value of the result to a new result using a mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to map the success value to a new result. + /// The type of the new value. + /// A result which is either the mapped result or a new result containing the failure value of the original + /// result. + [Pure] + public Result Then(Func> func) + { + return IsSuccess ? func(Value!) : new Result(Error!); + } + + /// + /// Tries to map the success value of the result to a new result using a mapping function, or does nothing if the result + /// is a failure. If the mapping function throws an exception, the exception will be returned wrapped in an + /// . + /// + /// The function used to map the success value to a new result. + /// The type of the new value. + /// A result which is either the mapped result, the exception thrown by wrapped in + /// an , or a new result containing the failure value of the original result. + [Pure] + public Result ThenTry(Func> func) + { + try + { + return Then(func); + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } +} + +public readonly partial struct Result +{ + /// + /// Maps the success value of the result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to map the success value. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function on + /// the success value of the result and constructing a new result containing the mapped value, or completes + /// synchronously by returning a new result containing the failure value of the original result. + [Pure] + public Task> MapAsync(Func> func) + { + if (!IsSuccess) + { + return Task.FromResult(new Result(Error!)); + } + + var task = func(Value!); + return CreateResult(task); + + static async Task> CreateResult(Task task) + { + var value = await task; + return new Result(value); + } + } + + /// + /// Maps the success value of the result using an asynchronous mapping function, or does nothing if the result is a + /// failure. If the mapping function throws an exception, the exception will be returned wrapped in an + /// . + /// + /// The function used to map the success value. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function on + /// the success value of the result and constructing a new result containing the mapped value, returning any exception + /// thrown by wrapped in an or completes synchronously by + /// returning a new result containing the failure value of the original result. + [Pure] + public Task> TryMapAsync(Func> func) + { + if (!IsSuccess) + { + return Task.FromResult(new Result(Error!)); + } + + try + { + var task = func(Value!); + return CreateResult(task); + } + catch (Exception exception) + { + return Task.FromResult(new Result(new ExceptionError(exception))); + } + + static async Task> CreateResult(Task task) + { + try + { + var value = await task; + return new Result(value); + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } + } + + /// + /// Maps the success value of the result to a new result using an asynchronous mapping function, or does nothing if + /// the result is a failure. + /// + /// The function used to map the success value to a new result. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function on + /// the success value of the result, or completes synchronously by returning a new result containing the failure + /// value of the original result. + [Pure] + public Task> ThenAsync(Func>> func) + { + if (!IsSuccess) + { + return Task.FromResult(new Result(Error!)); + } + + var task = func(Value!); + return CreateResult(task); + + static async Task> CreateResult(Task> task) + { + var result = await task; + return result; + } + } + + /// + /// Maps the success value of the result to a new result using an asynchronous mapping function, or does nothing if + /// the result is a failure. If the mapping function throws an exception, the exception will be returned wrapped in + /// an . + /// + /// The function used to map the success value to a new result. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function on + /// the success value of the result, returning any exception thrown by wrapped in an + /// , or completes synchronously by returning a new result containing the failure value + /// of the original result. + [Pure] + public Task> ThenTryAsync(Func>> func) + { + if (!IsSuccess) + { + return Task.FromResult(new Result(Error!)); + } + + try + { + var task = func(Value!); + return CreateResult(task); + } + catch (Exception exception) + { + return Task.FromResult(new Result(new ExceptionError(exception))); + } + + static async Task> CreateResult(Task> task) + { + try + { + var value = await task; + return value; + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } + } +} + +public readonly partial struct Result +{ + /// + /// Maps the success value of the result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to map the success value. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function on + /// the success value of the result and constructing a new result containing the mapped value, or completes + /// synchronously by returning a new result containing the failure value of the original result. + [Pure] + public ValueTask> MapAsync(Func> func) + { + if (!IsSuccess) + { + return ValueTask.FromResult(new Result(Error!)); + } + + var task = func(Value!); + return CreateResult(task); + + static async ValueTask> CreateResult(ValueTask task) + { + var value = await task; + return new Result(value); + } + } + + /// + /// Maps the success value of the result using an asynchronous mapping function, or does nothing if the result is a + /// failure. If the mapping function throws an exception, the exception will be returned wrapped in an + /// . + /// + /// The function used to map the success value. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function on + /// the success value of the result and constructing a new result containing the mapped value, returning any exception + /// thrown by wrapped in an or completes synchronously by + /// returning a new result containing the failure value of the original result. + [Pure] + public ValueTask> TryMapAsync(Func> func) + { + if (!IsSuccess) + { + return ValueTask.FromResult(new Result(Error!)); + } + + try + { + var task = func(Value!); + return CreateResult(task); + } + catch (Exception exception) + { + return ValueTask.FromResult(new Result(new ExceptionError(exception))); + } + + static async ValueTask> CreateResult(ValueTask task) + { + try + { + var value = await task; + return new Result(value); + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } + } + + /// + /// Maps the success value of the result to a new result using an asynchronous mapping function, or does nothing if + /// the result is a failure. + /// + /// The function used to map the success value to a new result. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function on + /// the success value of the result, or completes synchronously by returning a new result containing the failure + /// value of the original result. + [Pure] + public ValueTask> ThenAsync(Func>> func) + { + if (!IsSuccess) + { + return ValueTask.FromResult(new Result(Error!)); + } + + var task = func(Value!); + return CreateResult(task); + + static async ValueTask> CreateResult(ValueTask> task) + { + var result = await task; + return result; + } + } + + /// + /// Maps the success value of the result to a new result using an asynchronous mapping function, or does nothing if + /// the result is a failure. If the mapping function throws an exception, the exception will be returned wrapped in + /// an . + /// + /// The function used to map the success value to a new result. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function on + /// the success value of the result, returning any exception thrown by wrapped in an + /// , or completes synchronously by returning a new result containing the failure value + /// of the original result. + [Pure] + public ValueTask> ThenTryAsync(Func>> func) + { + if (!IsSuccess) + { + return ValueTask.FromResult(new Result(Error!)); + } + + try + { + var task = func(Value!); + return CreateResult(task); + } + catch (Exception exception) + { + return ValueTask.FromResult(new Result(new ExceptionError(exception))); + } + + static async ValueTask> CreateResult(ValueTask> task) + { + try + { + var value = await task; + return value; + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } + } +} \ No newline at end of file diff --git a/src/request.result/Result.Unbox.cs b/src/request.result/Result.Unbox.cs new file mode 100644 index 0000000..7080669 --- /dev/null +++ b/src/request.result/Result.Unbox.cs @@ -0,0 +1,66 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; + +namespace Geekeey.Request.Result; + +public readonly partial struct Result +{ + /// + /// Tries to get the success value from the result. + /// + /// The success value of the result. + /// Whether the result has success value. + [Pure] + public bool TryGetValue([MaybeNullWhen(false)] out T value) + { + value = Value; + + return IsSuccess; + } + + /// + /// Tries to get the success value from the result. + /// + /// The success value of the result. + /// The failure value of the result. + /// Whether the result has a success value. + [Pure] + public bool TryGetValue([MaybeNullWhen(false)] out T value, [MaybeNullWhen(true)] out Error error) + { + value = Value; + error = !IsSuccess ? Error : null; + + return IsSuccess; + } + + /// + /// Tries to get the failure value from the result. + /// + /// The failure value of the result. + /// Whether the result has a failure value. + [Pure] + public bool TryGetValue([MaybeNullWhen(false)] out Error error) + { + error = !IsSuccess ? Error : null; + + return !IsSuccess; + } + + /// + /// Tries to get the failure value from the result. + /// + /// The failure value of the result. + /// The success value of the result. + /// Whether the result a failure value. + [Pure] + public bool TryGetValue([MaybeNullWhen(false)] out Error error, [MaybeNullWhen(true)] out T value) + { + error = !IsSuccess ? Error : null; + value = Value; + + return !IsSuccess; + } +} \ No newline at end of file diff --git a/src/request.result/Result.cs b/src/request.result/Result.cs new file mode 100644 index 0000000..ee2241a --- /dev/null +++ b/src/request.result/Result.cs @@ -0,0 +1,76 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; + +namespace Geekeey.Request.Result; + +/// +/// A type which contains either a success value or a failure value, which is represented by an . +/// +/// The type of the success value. +[DebuggerTypeProxy(typeof(Result<>.ResultDebugProxy))] +public readonly partial struct Result +{ + /// + /// Creates a new result with a success value. + /// + /// The success value. + public Result(T value) + { + IsSuccess = true; + Value = value; + Error = default; + } + + /// + /// Creates a new result with a failure value. + /// + /// The error of the result. + public Result(Error error) + { + IsSuccess = false; + Value = default; + Error = error; + } + + internal T? Value { get; } + + internal Error? Error => IsSuccess ? null : (field ?? Error.DefaultValueError); + + /// + /// Whether the result is a success. + /// + /// + /// This is always the inverse of but is more specific about intent. + /// + [MemberNotNullWhen(true, nameof(Value))] + public bool IsSuccess { get; } + + /// + /// Whether the result is a failure. + /// + /// + /// This is always the inverse of but is more specific about intent. + /// + [MemberNotNullWhen(true, nameof(Error))] + public bool IsFailure => !IsSuccess; + + /// + /// Gets a string representation of the result. + /// + [Pure] + public override string ToString() + { + return IsSuccess ? $"Success {{ {Value} }}" : $"Failure {{ {Error} }}"; + } + + private sealed class ResultDebugProxy(Result result) + { + public bool IsSuccess => result.IsSuccess; + + public object? Value => result.IsSuccess ? result.Value : result.Error; + } +} \ No newline at end of file diff --git a/src/request.result/_errors/AggregateError.cs b/src/request.result/_errors/AggregateError.cs new file mode 100644 index 0000000..df7dd0c --- /dev/null +++ b/src/request.result/_errors/AggregateError.cs @@ -0,0 +1,27 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result; + +/// +/// An error which is a combination of other errors. +/// +public sealed class AggregateError : Error +{ + /// + /// An error which is a combination of other errors. + /// + /// The errors the error consists of. + public AggregateError(IEnumerable errors) + { + Errors = [.. errors]; + } + + /// + /// The errors the error consists of. + /// + public IReadOnlyCollection Errors { get; } + + /// + public override string Message => string.Join(Environment.NewLine, Errors.Select(error => error.Message)); +} \ No newline at end of file diff --git a/src/request.result/_errors/Error.cs b/src/request.result/_errors/Error.cs new file mode 100644 index 0000000..d57e8ce --- /dev/null +++ b/src/request.result/_errors/Error.cs @@ -0,0 +1,54 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result; + +/// +/// An error containing a simple message. Makes up the other half of a which might be an error. +/// +/// +/// An error is conceptually very similar to an exception but without the ability to be thrown, meant to be a more +/// lightweight type meant to be wrapped in a . +/// An error fundamentally only contains a single string message, however other more concrete types such as +/// or may define other properties. +/// Errors are meant to be small, specific, and descriptive, such that they are easy to match over and provide specific +/// handling for specific kinds of errors. +/// +public abstract class Error +{ + /// + /// A statically accessible default "Result has no value." error. + /// + internal static Error DefaultValueError { get; } = new StringError("The result has no value."); + + /// + /// The message used to display the error. + /// + public abstract string Message { get; } + + /// + /// Gets a string representation of the error. Returns by default. + /// + public override string ToString() + { + return Message; + } + + /// + /// Implicitly converts a string into a . + /// + /// The message of the error. + public static implicit operator Error(string message) + { + return new StringError(message); + } + + /// + /// Implicitly converts an exception into an . + /// + /// The exception to convert. + public static implicit operator Error(Exception exception) + { + return new ExceptionError(exception); + } +} \ No newline at end of file diff --git a/src/request.result/_errors/ExceptionError.cs b/src/request.result/_errors/ExceptionError.cs new file mode 100644 index 0000000..a15aa06 --- /dev/null +++ b/src/request.result/_errors/ExceptionError.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result; + +/// +/// An error which is constructed from an exception. +/// +public sealed class ExceptionError : Error +{ + /// + /// An error which is constructed from an exception. + /// + /// The exception in the error. + public ExceptionError(Exception exception) + { + Exception = exception; + } + + /// + /// The exception in the error. + /// + public Exception Exception { get; } + + /// + /// The exception in the error. + /// + public override string Message => Exception.Message; +} \ No newline at end of file diff --git a/src/request.result/_errors/StringError.cs b/src/request.result/_errors/StringError.cs new file mode 100644 index 0000000..d544add --- /dev/null +++ b/src/request.result/_errors/StringError.cs @@ -0,0 +1,24 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result; + +/// +/// An error which displays a simple string. +/// +public sealed class StringError : Error +{ + private readonly string _message; + + /// + /// An error which displays a simple string. + /// + /// The message to display. + public StringError(string message) + { + _message = message; + } + + /// + public override string Message => _message; +} \ No newline at end of file diff --git a/src/request.result/_exceptions/UnwrapException.cs b/src/request.result/_exceptions/UnwrapException.cs new file mode 100644 index 0000000..ad246d6 --- /dev/null +++ b/src/request.result/_exceptions/UnwrapException.cs @@ -0,0 +1,26 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result; + +/// +/// The exception is thrown when an is attempted to be unwrapped contains only a failure value. +/// +public sealed class UnwrapException : Exception +{ + /// + /// Creates a new . + /// + public UnwrapException() + : base("Cannot unwrap result because it does not have a value.") + { + } + + /// + /// Creates a new . + /// + /// An error message. + public UnwrapException(string error) : base(error) + { + } +} \ No newline at end of file diff --git a/src/request.result/_extensions/Extensions.Enumerable.cs b/src/request.result/_extensions/Extensions.Enumerable.cs new file mode 100644 index 0000000..9551907 --- /dev/null +++ b/src/request.result/_extensions/Extensions.Enumerable.cs @@ -0,0 +1,90 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result; + +/// +/// Extensions for or relating to . +/// +public static partial class Extensions +{ + /// + /// Turns a sequence of results into a single result containing the success values in the results only if all the + /// results have success values. + /// + /// The results to turn into a single sequence. + /// The type of the success values in the results. + /// A single result containing a sequence of all the success values from the original sequence of results, + /// or the first failure value encountered within the sequence. + /// + /// This method completely enumerates the input sequence before returning and is not lazy. As a consequence of this, + /// the sequence within the returned result is an . + /// + public static Result> Join(this IEnumerable> results) + { + _ = results.TryGetNonEnumeratedCount(out var count); + var list = new List(count); + + foreach (var result in results) + { + if (!result.TryGetValue(out T? value, out var error)) + { + return new Result>(error); + } + + list.Add(value); + } + + return list; + } + + /// + /// + /// For parallel execution of the async tasks, one should await the Task.WhenAll() of the provided list + /// before calling this function + /// + /// + // ReSharper disable once InconsistentNaming + public static async ValueTask>> Join(this IEnumerable>> results) + { + _ = results.TryGetNonEnumeratedCount(out var count); + var list = new List(count); + + foreach (var result in results) + { + if (!(await result).TryGetValue(out T? value, out var error)) + { + return new Result>(error); + } + + list.Add(value); + } + + return list; + } + + /// + /// + /// For parallel execution of the async tasks, one should await the Task.WhenAll() of the provided list + /// before calling this function + /// + /// + // ReSharper disable once InconsistentNaming + public static async Task>> Join(this IEnumerable>> results) + { + _ = results.TryGetNonEnumeratedCount(out var count); + var list = new List(count); + + foreach (var result in results) + { + if (!(await result).TryGetValue(out T? value, out var error)) + { + return new Result>(error); + } + + list.Add(value); + } + + return list; + } +} \ No newline at end of file diff --git a/src/request.result/_extensions/Extensions.Task.cs b/src/request.result/_extensions/Extensions.Task.cs new file mode 100644 index 0000000..3ef19b2 --- /dev/null +++ b/src/request.result/_extensions/Extensions.Task.cs @@ -0,0 +1,100 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Geekeey.Request.Result; + +/// +/// Extensions for or relating to . +/// +[ExcludeFromCodeCoverage] +public static partial class Extensions +{ + #region Task> + + /// + /// Maps the success value of the result object of the completed task using a mapping function, or does nothing if + /// the result object of the completed task is a failure. + /// + /// A task object returning a result object when completing. + /// The function used to map the success value. + /// The type of the object inside the result returned by the task. + /// The type of the new value. + /// A new result containing either the mapped success value or the failure value of the original + /// result. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + // ReSharper disable once InconsistentNaming + public static async Task> Map(this Task> result, Func func) + { + return (await result).Map(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> MapAsync(this Task> result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + /// Maps the success value of the result object of the completed task to a new result using a mapping function, or + /// does nothing if the result object of the completed task is a failure. + /// + /// A task object returning a result object when completing. + /// The function used to map the success value. + /// The type of the object inside the result returned by the task. + /// The type of the new value. + /// A new result containing either the mapped success value or the failure value of the original + /// result. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + // ReSharper disable once InconsistentNaming + public static async Task> Then(this Task> result, Func> func) + { + return (await result).Then(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> ThenAsync(this Task> result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + #endregion + + #region ValueTask> + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + // ReSharper disable once InconsistentNaming + public static async ValueTask> Map(this ValueTask> result, Func func) + { + return (await result).Map(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> MapAsync(this ValueTask> result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + // ReSharper disable once InconsistentNaming + public static async ValueTask> Then(this ValueTask> result, Func> func) + { + return (await result).Then(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> ThenAsync(this ValueTask> result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + #endregion +} \ No newline at end of file diff --git a/src/request.result/package-icon.png b/src/request.result/package-icon.png new file mode 100644 index 0000000..35f4099 Binary files /dev/null and b/src/request.result/package-icon.png differ diff --git a/src/request.result/package-readme.md b/src/request.result/package-readme.md new file mode 100644 index 0000000..e9f3167 --- /dev/null +++ b/src/request.result/package-readme.md @@ -0,0 +1,62 @@ +Result is a simple yet powerful [result type](https://doc.rust-lang.org/std/result/) implementation for C#, containing a +variety of utilities and standard functions for working with result types and integrating them into the rest of C#. + +## Features + +- **Success and Failure States:** Represent successful outcomes with a value (`Prelude.Success()`) or failures with an + error (`Prelude.Failure()`). +- **Immutability:** `Result` objects are immutable, ensuring thread safety and preventing accidental modification. +- **Chaining Operations:** Methods like `Map` and `Then` allow for chaining operations on successful results, promoting + a functional programming style. + +## Getting Started + +### Install the NuGet package: + +``` +dotnet add package Geekeey.Extensions.Result +``` + +You may need to add our NuGet Feed to your `nuget.config` this can be done by adding the following lines + +```xml + + + +``` + +### Usage + +```csharp +public Result Divide(int dividend, int divisor) +{ + if (divisor == 0) + { + return Prelude.Failure("Division by zero"); + } + + return Prelude.Success(dividend / divisor); +} + +if (result.IsSuccess) +{ + Console.WriteLine("Result: " + result.Value); +} +else +{ + Console.WriteLine("Error: " + result.Error); +} +``` + +```csharp +_ = await Prelude.Try(() => File.ReadAllLines("i_do_not_exist.txt")) + ThenAsync(static async Task>> (list) => + { + using var client = new HttpClient(); + Task> DoSomeThing(string line) + => Prelude.TryAsync(() => client.GetAsync(line)) + .Map(static async response => int.Parse(await response.Content.ReadAsStringAsync())); + var results = await Task.WhenAll(list.Select(DoSomeThing).ToArray()); + return results.Join(); + }); +``` 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..ec6ec2a --- /dev/null +++ b/src/request.tests/Geekeey.Request.Tests.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + false + + + + + + + + + + + + + + + + + diff --git a/src/request.tests/RequestDispatcherBuilderExtensionsTests.cs b/src/request.tests/RequestDispatcherBuilderExtensionsTests.cs new file mode 100644 index 0000000..ad0d93a --- /dev/null +++ b/src/request.tests/RequestDispatcherBuilderExtensionsTests.cs @@ -0,0 +1,120 @@ +// 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(); + } + } +} \ 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..a0fc0d4 --- /dev/null +++ b/src/request.tests/ScalarBehaviourTests.cs @@ -0,0 +1,111 @@ +// 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"); + } +} + +// Moved to _fixtures \ 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..1ece672 --- /dev/null +++ b/src/request.tests/ScalarDispatcherTests.cs @@ -0,0 +1,251 @@ +// 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(); + } +} + +// Moved to _fixtures \ 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..1a0ce2f --- /dev/null +++ b/src/request.tests/StreamBehaviourTests.cs @@ -0,0 +1,110 @@ +// 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"); + } +} + +// Moved to _fixtures \ 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..4a21a17 --- /dev/null +++ b/src/request.tests/StreamDispatcherTests.cs @@ -0,0 +1,243 @@ +// 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(); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/AmbiguousScalarHandler.cs b/src/request.tests/_fixtures/AmbiguousScalarHandler.cs new file mode 100644 index 0000000..477ad38 --- /dev/null +++ b/src/request.tests/_fixtures/AmbiguousScalarHandler.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/AmbiguousScalarRequest.cs b/src/request.tests/_fixtures/AmbiguousScalarRequest.cs new file mode 100644 index 0000000..733e21c --- /dev/null +++ b/src/request.tests/_fixtures/AmbiguousScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class AmbiguousScalarRequest : IScalarRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/AmbiguousStreamHandler.cs b/src/request.tests/_fixtures/AmbiguousStreamHandler.cs new file mode 100644 index 0000000..8b39193 --- /dev/null +++ b/src/request.tests/_fixtures/AmbiguousStreamHandler.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/AmbiguousStreamRequest.cs b/src/request.tests/_fixtures/AmbiguousStreamRequest.cs new file mode 100644 index 0000000..c82bded --- /dev/null +++ b/src/request.tests/_fixtures/AmbiguousStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class AmbiguousStreamRequest : IStreamRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/AnotherNamedScalarRequest.cs b/src/request.tests/_fixtures/AnotherNamedScalarRequest.cs new file mode 100644 index 0000000..697392d --- /dev/null +++ b/src/request.tests/_fixtures/AnotherNamedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class AnotherNamedScalarRequest : INamedScalarRequest +{ + public string Name { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/AnotherNamedStreamRequest.cs b/src/request.tests/_fixtures/AnotherNamedStreamRequest.cs new file mode 100644 index 0000000..4c3097e --- /dev/null +++ b/src/request.tests/_fixtures/AnotherNamedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class AnotherNamedStreamRequest : INamedStreamRequest +{ + public string Name { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/AnotherTestHandler.cs b/src/request.tests/_fixtures/AnotherTestHandler.cs new file mode 100644 index 0000000..c5a7486 --- /dev/null +++ b/src/request.tests/_fixtures/AnotherTestHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +internal 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/_fixtures/AnotherTestRequest.cs b/src/request.tests/_fixtures/AnotherTestRequest.cs new file mode 100644 index 0000000..09e4eec --- /dev/null +++ b/src/request.tests/_fixtures/AnotherTestRequest.cs @@ -0,0 +1,6 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +internal sealed class AnotherTestRequest : IScalarRequest { } \ No newline at end of file diff --git a/src/request.tests/_fixtures/BaseScalarHandler.cs b/src/request.tests/_fixtures/BaseScalarHandler.cs new file mode 100644 index 0000000..69e5b31 --- /dev/null +++ b/src/request.tests/_fixtures/BaseScalarHandler.cs @@ -0,0 +1,10 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public abstract class BaseScalarHandler : IScalarRequestHandler + where TRequest : IScalarRequest +{ + public abstract Task HandleAsync(TRequest request, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/BaseStreamHandler.cs b/src/request.tests/_fixtures/BaseStreamHandler.cs new file mode 100644 index 0000000..9a87f40 --- /dev/null +++ b/src/request.tests/_fixtures/BaseStreamHandler.cs @@ -0,0 +1,10 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public abstract class BaseStreamHandler : IStreamRequestHandler + where TRequest : IStreamRequest +{ + public abstract IAsyncEnumerable HandleAsync(TRequest request, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ConstrainedScalarHandler.cs b/src/request.tests/_fixtures/ConstrainedScalarHandler.cs new file mode 100644 index 0000000..ff1d369 --- /dev/null +++ b/src/request.tests/_fixtures/ConstrainedScalarHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ConstrainedScalarHandler : IScalarRequestHandler + where TRequest : ConstrainedScalarRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Value}-Constrained"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ConstrainedScalarRequest.cs b/src/request.tests/_fixtures/ConstrainedScalarRequest.cs new file mode 100644 index 0000000..32012ec --- /dev/null +++ b/src/request.tests/_fixtures/ConstrainedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ConstrainedScalarRequest : IScalarRequest +{ + public int Value { get; set; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ConstrainedStreamHandler.cs b/src/request.tests/_fixtures/ConstrainedStreamHandler.cs new file mode 100644 index 0000000..02cf13c --- /dev/null +++ b/src/request.tests/_fixtures/ConstrainedStreamHandler.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ConstrainedStreamRequest.cs b/src/request.tests/_fixtures/ConstrainedStreamRequest.cs new file mode 100644 index 0000000..e48b065 --- /dev/null +++ b/src/request.tests/_fixtures/ConstrainedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ConstrainedStreamRequest : IStreamRequest +{ + public int Value { get; set; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/DeepDerivedScalarRequest.cs b/src/request.tests/_fixtures/DeepDerivedScalarRequest.cs new file mode 100644 index 0000000..4abfd91 --- /dev/null +++ b/src/request.tests/_fixtures/DeepDerivedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class DeepDerivedScalarRequest : InheritedScalarRequest +{ + public int DeepValue { get; set; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/DeepDerivedStreamRequest.cs b/src/request.tests/_fixtures/DeepDerivedStreamRequest.cs new file mode 100644 index 0000000..53bb1c9 --- /dev/null +++ b/src/request.tests/_fixtures/DeepDerivedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class DeepDerivedStreamRequest : InheritedStreamRequest +{ + public int DeepValue { get; set; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/DerivedScalarHandler.cs b/src/request.tests/_fixtures/DerivedScalarHandler.cs new file mode 100644 index 0000000..90db3f4 --- /dev/null +++ b/src/request.tests/_fixtures/DerivedScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class DerivedScalarHandler : BaseScalarHandler +{ + public override Task HandleAsync(DerivedScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"Derived: {request.Value}"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/DerivedScalarRequest.cs b/src/request.tests/_fixtures/DerivedScalarRequest.cs new file mode 100644 index 0000000..5fe79a0 --- /dev/null +++ b/src/request.tests/_fixtures/DerivedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class DerivedScalarRequest : IScalarRequest +{ + public int Value { get; set; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/DerivedStreamHandler.cs b/src/request.tests/_fixtures/DerivedStreamHandler.cs new file mode 100644 index 0000000..3931e6a --- /dev/null +++ b/src/request.tests/_fixtures/DerivedStreamHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/DerivedStreamRequest.cs b/src/request.tests/_fixtures/DerivedStreamRequest.cs new file mode 100644 index 0000000..db4d2c5 --- /dev/null +++ b/src/request.tests/_fixtures/DerivedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class DerivedStreamRequest : IStreamRequest +{ + public int Value { get; set; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ExplicitGenericScalarHandler.cs b/src/request.tests/_fixtures/ExplicitGenericScalarHandler.cs new file mode 100644 index 0000000..82f9b4b --- /dev/null +++ b/src/request.tests/_fixtures/ExplicitGenericScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ExplicitGenericScalarHandler : IScalarRequestHandler +{ + Task IScalarRequestHandler.HandleAsync(ExplicitGenericScalarRequest request, CancellationToken ct) + { + return Task.FromResult($"{request.Value}-ExplicitHandled"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ExplicitGenericScalarRequest.cs b/src/request.tests/_fixtures/ExplicitGenericScalarRequest.cs new file mode 100644 index 0000000..08be608 --- /dev/null +++ b/src/request.tests/_fixtures/ExplicitGenericScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ExplicitGenericScalarRequest : IScalarRequest +{ + public string Value { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ExplicitGenericStreamHandler.cs b/src/request.tests/_fixtures/ExplicitGenericStreamHandler.cs new file mode 100644 index 0000000..47442fe --- /dev/null +++ b/src/request.tests/_fixtures/ExplicitGenericStreamHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ExplicitGenericStreamHandler : IStreamRequestHandler +{ + async IAsyncEnumerable IStreamRequestHandler.HandleAsync(ExplicitGenericStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + yield return $"{request.Value}-ExplicitHandled"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ExplicitGenericStreamRequest.cs b/src/request.tests/_fixtures/ExplicitGenericStreamRequest.cs new file mode 100644 index 0000000..abe725e --- /dev/null +++ b/src/request.tests/_fixtures/ExplicitGenericStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ExplicitGenericStreamRequest : IStreamRequest +{ + public string Value { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/FailingScalarHandler.cs b/src/request.tests/_fixtures/FailingScalarHandler.cs new file mode 100644 index 0000000..641b4e0 --- /dev/null +++ b/src/request.tests/_fixtures/FailingScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class FailingScalarHandler : IScalarRequestHandler +{ + public Task HandleAsync(FailingScalarRequest request, CancellationToken cancellationToken) + { + throw new InvalidOperationException("Handler failed"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/FailingScalarRequest.cs b/src/request.tests/_fixtures/FailingScalarRequest.cs new file mode 100644 index 0000000..86b59c8 --- /dev/null +++ b/src/request.tests/_fixtures/FailingScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class FailingScalarRequest : IScalarRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/FailingStreamHandler.cs b/src/request.tests/_fixtures/FailingStreamHandler.cs new file mode 100644 index 0000000..74055af --- /dev/null +++ b/src/request.tests/_fixtures/FailingStreamHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/FailingStreamRequest.cs b/src/request.tests/_fixtures/FailingStreamRequest.cs new file mode 100644 index 0000000..2789604 --- /dev/null +++ b/src/request.tests/_fixtures/FailingStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class FailingStreamRequest : IStreamRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/INamedScalarRequest.cs b/src/request.tests/_fixtures/INamedScalarRequest.cs new file mode 100644 index 0000000..32f182b --- /dev/null +++ b/src/request.tests/_fixtures/INamedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public interface INamedScalarRequest : IScalarRequest +{ + string Name { get; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/INamedStreamRequest.cs b/src/request.tests/_fixtures/INamedStreamRequest.cs new file mode 100644 index 0000000..7dfaa12 --- /dev/null +++ b/src/request.tests/_fixtures/INamedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public interface INamedStreamRequest : IStreamRequest +{ + string Name { get; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/InheritedScalarRequest.cs b/src/request.tests/_fixtures/InheritedScalarRequest.cs new file mode 100644 index 0000000..0730ef4 --- /dev/null +++ b/src/request.tests/_fixtures/InheritedScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class InheritedScalarRequest : OpenScalarRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/InheritedStreamRequest.cs b/src/request.tests/_fixtures/InheritedStreamRequest.cs new file mode 100644 index 0000000..bb8f1a5 --- /dev/null +++ b/src/request.tests/_fixtures/InheritedStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class InheritedStreamRequest : OpenStreamRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/InterfaceConstrainedScalarHandler.cs b/src/request.tests/_fixtures/InterfaceConstrainedScalarHandler.cs new file mode 100644 index 0000000..7313988 --- /dev/null +++ b/src/request.tests/_fixtures/InterfaceConstrainedScalarHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class InterfaceConstrainedScalarHandler : IScalarRequestHandler + where TRequest : INamedScalarRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Name}-ConstrainedByInterface"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/InterfaceConstrainedStreamHandler.cs b/src/request.tests/_fixtures/InterfaceConstrainedStreamHandler.cs new file mode 100644 index 0000000..f3081df --- /dev/null +++ b/src/request.tests/_fixtures/InterfaceConstrainedStreamHandler.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/InterfaceInheritedScalarHandler.cs b/src/request.tests/_fixtures/InterfaceInheritedScalarHandler.cs new file mode 100644 index 0000000..01f0ceb --- /dev/null +++ b/src/request.tests/_fixtures/InterfaceInheritedScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class InterfaceInheritedScalarHandler : IScalarRequestHandler +{ + public Task HandleAsync(InterfaceInheritedScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Name}-InterfaceHandled"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/InterfaceInheritedScalarRequest.cs b/src/request.tests/_fixtures/InterfaceInheritedScalarRequest.cs new file mode 100644 index 0000000..4bbb317 --- /dev/null +++ b/src/request.tests/_fixtures/InterfaceInheritedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class InterfaceInheritedScalarRequest : INamedScalarRequest +{ + public string Name { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/InterfaceInheritedStreamHandler.cs b/src/request.tests/_fixtures/InterfaceInheritedStreamHandler.cs new file mode 100644 index 0000000..3a8b20d --- /dev/null +++ b/src/request.tests/_fixtures/InterfaceInheritedStreamHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/InterfaceInheritedStreamRequest.cs b/src/request.tests/_fixtures/InterfaceInheritedStreamRequest.cs new file mode 100644 index 0000000..8af5b93 --- /dev/null +++ b/src/request.tests/_fixtures/InterfaceInheritedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class InterfaceInheritedStreamRequest : INamedStreamRequest +{ + public string Name { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/MultiInterfaceScalarHandler.cs b/src/request.tests/_fixtures/MultiInterfaceScalarHandler.cs new file mode 100644 index 0000000..5e924db --- /dev/null +++ b/src/request.tests/_fixtures/MultiInterfaceScalarHandler.cs @@ -0,0 +1,17 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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/_fixtures/MultiInterfaceScalarRequest.cs b/src/request.tests/_fixtures/MultiInterfaceScalarRequest.cs new file mode 100644 index 0000000..0532e50 --- /dev/null +++ b/src/request.tests/_fixtures/MultiInterfaceScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class MultiInterfaceScalarRequest : IScalarRequest, IScalarRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/MultiInterfaceStreamHandler.cs b/src/request.tests/_fixtures/MultiInterfaceStreamHandler.cs new file mode 100644 index 0000000..9aa361d --- /dev/null +++ b/src/request.tests/_fixtures/MultiInterfaceStreamHandler.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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.tests/_fixtures/MultiInterfaceStreamRequest.cs b/src/request.tests/_fixtures/MultiInterfaceStreamRequest.cs new file mode 100644 index 0000000..e75f2f6 --- /dev/null +++ b/src/request.tests/_fixtures/MultiInterfaceStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class MultiInterfaceStreamRequest : IStreamRequest, IStreamRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/OpenScalarHandler.cs b/src/request.tests/_fixtures/OpenScalarHandler.cs new file mode 100644 index 0000000..eda00b1 --- /dev/null +++ b/src/request.tests/_fixtures/OpenScalarHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class OpenScalarHandler : IScalarRequestHandler +where TRequest : OpenScalarRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Data}-Handled"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/OpenScalarRequest.cs b/src/request.tests/_fixtures/OpenScalarRequest.cs new file mode 100644 index 0000000..002fd6a --- /dev/null +++ b/src/request.tests/_fixtures/OpenScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class OpenScalarRequest : IScalarRequest +{ + public string Data { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/OpenStreamHandler.cs b/src/request.tests/_fixtures/OpenStreamHandler.cs new file mode 100644 index 0000000..b9a5279 --- /dev/null +++ b/src/request.tests/_fixtures/OpenStreamHandler.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/OpenStreamRequest.cs b/src/request.tests/_fixtures/OpenStreamRequest.cs new file mode 100644 index 0000000..0447f87 --- /dev/null +++ b/src/request.tests/_fixtures/OpenStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class OpenStreamRequest : IStreamRequest +{ + public string Data { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarChainedBehaviour1.cs b/src/request.tests/_fixtures/ScalarChainedBehaviour1.cs new file mode 100644 index 0000000..7bfe7bb --- /dev/null +++ b/src/request.tests/_fixtures/ScalarChainedBehaviour1.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarChainedBehaviour2.cs b/src/request.tests/_fixtures/ScalarChainedBehaviour2.cs new file mode 100644 index 0000000..fe4aabc --- /dev/null +++ b/src/request.tests/_fixtures/ScalarChainedBehaviour2.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarOpenBehavior.cs b/src/request.tests/_fixtures/ScalarOpenBehavior.cs new file mode 100644 index 0000000..b9a76a7 --- /dev/null +++ b/src/request.tests/_fixtures/ScalarOpenBehavior.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarOrderingClosedBehavior.cs b/src/request.tests/_fixtures/ScalarOrderingClosedBehavior.cs new file mode 100644 index 0000000..dc1ef03 --- /dev/null +++ b/src/request.tests/_fixtures/ScalarOrderingClosedBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ScalarOrderingClosedBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("OrderingClosed"); + return next(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarOrderingOpenBehavior.cs b/src/request.tests/_fixtures/ScalarOrderingOpenBehavior.cs new file mode 100644 index 0000000..84a9d8a --- /dev/null +++ b/src/request.tests/_fixtures/ScalarOrderingOpenBehavior.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarTestBehavior.cs b/src/request.tests/_fixtures/ScalarTestBehavior.cs new file mode 100644 index 0000000..0452988 --- /dev/null +++ b/src/request.tests/_fixtures/ScalarTestBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ScalarTestBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest 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/_fixtures/ScalarTestHandler.cs b/src/request.tests/_fixtures/ScalarTestHandler.cs new file mode 100644 index 0000000..25bc6b6 --- /dev/null +++ b/src/request.tests/_fixtures/ScalarTestHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ScalarTestHandler : IScalarRequestHandler +{ + public Task HandleAsync(ScalarTestRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Value}-Handled"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarTestRequest.cs b/src/request.tests/_fixtures/ScalarTestRequest.cs new file mode 100644 index 0000000..fe7e8b9 --- /dev/null +++ b/src/request.tests/_fixtures/ScalarTestRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ScalarTestRequest : IScalarRequest +{ + public string Value { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarTestTracker.cs b/src/request.tests/_fixtures/ScalarTestTracker.cs new file mode 100644 index 0000000..a0b65ad --- /dev/null +++ b/src/request.tests/_fixtures/ScalarTestTracker.cs @@ -0,0 +1,10 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ScalarTestTracker +{ + public List Log { get; } = []; + public bool Executed { get; set; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarTestWrapperHandler.cs b/src/request.tests/_fixtures/ScalarTestWrapperHandler.cs new file mode 100644 index 0000000..c16a206 --- /dev/null +++ b/src/request.tests/_fixtures/ScalarTestWrapperHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ScalarTestWrapperHandler : IScalarRequestHandler, string> +{ + public Task HandleAsync(ScalarTestWrapperRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"Handled-{request.Item}"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarTestWrapperRequest.cs b/src/request.tests/_fixtures/ScalarTestWrapperRequest.cs new file mode 100644 index 0000000..5c64c75 --- /dev/null +++ b/src/request.tests/_fixtures/ScalarTestWrapperRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class ScalarTestWrapperRequest : IScalarRequest +{ + public T Item { get; set; } = default!; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/ScalarWrapperBehavior.cs b/src/request.tests/_fixtures/ScalarWrapperBehavior.cs new file mode 100644 index 0000000..78c7775 --- /dev/null +++ b/src/request.tests/_fixtures/ScalarWrapperBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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/_fixtures/StreamChainedBehaviour1.cs b/src/request.tests/_fixtures/StreamChainedBehaviour1.cs new file mode 100644 index 0000000..7cb9221 --- /dev/null +++ b/src/request.tests/_fixtures/StreamChainedBehaviour1.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class StreamChainedBehaviour1(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Behaviour1"); + return next(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamChainedBehaviour2.cs b/src/request.tests/_fixtures/StreamChainedBehaviour2.cs new file mode 100644 index 0000000..25430d8 --- /dev/null +++ b/src/request.tests/_fixtures/StreamChainedBehaviour2.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class StreamChainedBehaviour2(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Behaviour2"); + return next(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamOpenBehavior.cs b/src/request.tests/_fixtures/StreamOpenBehavior.cs new file mode 100644 index 0000000..551c1f5 --- /dev/null +++ b/src/request.tests/_fixtures/StreamOpenBehavior.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamOrderingClosedBehavior.cs b/src/request.tests/_fixtures/StreamOrderingClosedBehavior.cs new file mode 100644 index 0000000..f2e893e --- /dev/null +++ b/src/request.tests/_fixtures/StreamOrderingClosedBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class StreamOrderingClosedBehavior(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Closed"); + return next(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamOrderingOpenBehavior.cs b/src/request.tests/_fixtures/StreamOrderingOpenBehavior.cs new file mode 100644 index 0000000..5c9b3f6 --- /dev/null +++ b/src/request.tests/_fixtures/StreamOrderingOpenBehavior.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamTestBehavior.cs b/src/request.tests/_fixtures/StreamTestBehavior.cs new file mode 100644 index 0000000..b49258a --- /dev/null +++ b/src/request.tests/_fixtures/StreamTestBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class StreamTestBehavior(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Executed = true; + return next(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamTestHandler.cs b/src/request.tests/_fixtures/StreamTestHandler.cs new file mode 100644 index 0000000..2d32911 --- /dev/null +++ b/src/request.tests/_fixtures/StreamTestHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamTestRequest.cs b/src/request.tests/_fixtures/StreamTestRequest.cs new file mode 100644 index 0000000..318dc17 --- /dev/null +++ b/src/request.tests/_fixtures/StreamTestRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class StreamTestRequest : IStreamRequest +{ + public string Value { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamTestTracker.cs b/src/request.tests/_fixtures/StreamTestTracker.cs new file mode 100644 index 0000000..76cd67b --- /dev/null +++ b/src/request.tests/_fixtures/StreamTestTracker.cs @@ -0,0 +1,10 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class StreamTestTracker +{ + public List Log { get; } = []; + public bool Executed { get; set; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamTestWrapperHandler.cs b/src/request.tests/_fixtures/StreamTestWrapperHandler.cs new file mode 100644 index 0000000..30219eb --- /dev/null +++ b/src/request.tests/_fixtures/StreamTestWrapperHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamTestWrapperRequest.cs b/src/request.tests/_fixtures/StreamTestWrapperRequest.cs new file mode 100644 index 0000000..3d2083d --- /dev/null +++ b/src/request.tests/_fixtures/StreamTestWrapperRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class StreamTestWrapperRequest : IStreamRequest +{ + public T? Item { get; set; } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/StreamWrapperBehavior.cs b/src/request.tests/_fixtures/StreamWrapperBehavior.cs new file mode 100644 index 0000000..cf0feb3 --- /dev/null +++ b/src/request.tests/_fixtures/StreamWrapperBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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/_fixtures/TestHandler.cs b/src/request.tests/_fixtures/TestHandler.cs new file mode 100644 index 0000000..dd9ff55 --- /dev/null +++ b/src/request.tests/_fixtures/TestHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +internal sealed class TestHandler : IScalarRequestHandler +{ + public Task HandleAsync(TestRequest request, CancellationToken ct) + { + return Task.FromResult("ok"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/TestRequest.cs b/src/request.tests/_fixtures/TestRequest.cs new file mode 100644 index 0000000..0051009 --- /dev/null +++ b/src/request.tests/_fixtures/TestRequest.cs @@ -0,0 +1,6 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +internal sealed class TestRequest : IScalarRequest { } \ No newline at end of file diff --git a/src/request.tests/_fixtures/UnhandledScalarRequest.cs b/src/request.tests/_fixtures/UnhandledScalarRequest.cs new file mode 100644 index 0000000..4ffb868 --- /dev/null +++ b/src/request.tests/_fixtures/UnhandledScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class UnhandledScalarRequest : IScalarRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/UnhandledStreamRequest.cs b/src/request.tests/_fixtures/UnhandledStreamRequest.cs new file mode 100644 index 0000000..5f2ce40 --- /dev/null +++ b/src/request.tests/_fixtures/UnhandledStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class UnhandledStreamRequest : IStreamRequest +{ +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/WrapperScalarHandler.cs b/src/request.tests/_fixtures/WrapperScalarHandler.cs new file mode 100644 index 0000000..6b217fb --- /dev/null +++ b/src/request.tests/_fixtures/WrapperScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class WrapperScalarHandler : IScalarRequestHandler, string> +{ + public Task HandleAsync(WrapperScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"Handled-{request.Item}"); + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/WrapperScalarRequest.cs b/src/request.tests/_fixtures/WrapperScalarRequest.cs new file mode 100644 index 0000000..9d7b08e --- /dev/null +++ b/src/request.tests/_fixtures/WrapperScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class WrapperScalarRequest : IScalarRequest +{ + public T Item { get; set; } = default!; +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/WrapperStreamHandler.cs b/src/request.tests/_fixtures/WrapperStreamHandler.cs new file mode 100644 index 0000000..af8e960 --- /dev/null +++ b/src/request.tests/_fixtures/WrapperStreamHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +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"; + } +} \ No newline at end of file diff --git a/src/request.tests/_fixtures/WrapperStreamRequest.cs b/src/request.tests/_fixtures/WrapperStreamRequest.cs new file mode 100644 index 0000000..8bf16ea --- /dev/null +++ b/src/request.tests/_fixtures/WrapperStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Tests; + +public class WrapperStreamRequest : IStreamRequest +{ + public T Item { get; set; } = default!; +} \ 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..92182f8 --- /dev/null +++ b/src/request/Geekeey.Request.csproj @@ -0,0 +1,35 @@ + + + + Library + net10.0 + true + + + + 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..a003a51 --- /dev/null +++ b/src/request/package-readme.md @@ -0,0 +1,65 @@ +Simple mediator implementation in .NET with minimal dependencies. + +## Features + +- **Simple interfaces:** no complex constraints, just marker interfaces that work. +- **Minmal dependencies:** only depends on `Microsoft.Extensions.DependencyInjection.Abstractions` and the + `Microsoft.Extensions.Options` package. + +## Getting Started + +### Install the NuGet package: + +```shell +dotnet add package Geekeey.Request +``` + +You may need to add our NuGet feed to your `nuget.config` this can be done by running the following command: + +```shell +dotnet nuget add source -n geekeey https://code.geekeey.de/api/packages/geekeey/nuget/index.json +``` + +### Usage + +```csharp +public static Task Main() +{ + var collection = new ServiceCollection(); + collection.AddRequestDispatcher(builder => builder + .Add(typeof(ScalarHandler)) + .Add(typeof(ScalarBehavior))); + await using var provider = collection.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + + var request = new ScalarRequest { Value = "Hello" }; + var result = await dispatcher.DispatchAsync(request); + + Console.WriteLine(result); + return 0; +} + +public class ScalarRequest : IScalarRequest +{ + public string Value { get; set; } = string.Empty; +} + +public class ScalarHandler : IScalarRequestHandler +{ + public Task HandleAsync(ScalarTestRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Value} World"); + } +} + +public class ScalarBehavior : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + Console.WriteLine("Before"); + var result = await next(request, cancellationToken); + Console.WriteLine("After"); + return result; + } +} +```