commit d614788e0683821270adb8721b6a377dfa9e967e Author: Louis Seubert Date: Fri May 8 20:26:26 2026 +0200 feat: create request projects for basic CQRS diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bdd4559 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,397 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +tab_width = 4 +end_of_line = lf +insert_final_newline = true +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 style rule default severity #### +dotnet_analyzer_diagnostic.category-style.severity = warning + +#### .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 + +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 + +#### 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 +csharp_style_prefer_top_level_statements = false + +# 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..51c054c --- /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 normal + + - 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..91ef562 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,40 @@ +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 format --verify-no-changes + run: | + dotnet format --no-restore --verify-no-changes --verbosity normal + + - 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 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..5176cb9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# 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 + +### Changed + +### Removed + +[Unreleased]: https://code.geekeey.de/geekeey/request/src/branch/main diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..cab0c4d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,39 @@ + + + $(MSBuildThisFileDirectory)artifacts + + + + enable + enable + + + + 1.0.0 + preview + + + + 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..235fddd --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,16 @@ + + + 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..0519b63 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# `Geekeey.Request` + +Simple mediator implementation in .NET with minimal dependencies. + +## Overview + +The `Geekeey.Request` framework provides a lightweight request/response pattern with built-in pipeline behaviors and +validation support. It's designed for simplicity with no complex constraints, just marker interfaces that work +seamlessly with the .NET dependency injection container. + +## Packages + +| Package | Description | +|---------------------------------------------|---------------------------------------------------------------------------------------| +| [**Dispatcher**](./src/request.dispatcher/) | Core request dispatcher implementation with handler resolution and pipeline behaviors | +| [**Result**](./src/request.result/) | Optional result wrapper types for structured request responses | +| [**Validation**](./src/request.validation/) | Validation pipeline behavior for automatic request validation | + +## 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. 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..cebc37c --- /dev/null +++ b/request.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/request.dispatcher.tests/.editorconfig b/src/request.dispatcher.tests/.editorconfig new file mode 100644 index 0000000..9de929c --- /dev/null +++ b/src/request.dispatcher.tests/.editorconfig @@ -0,0 +1,15 @@ + +[*.{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 +# disable IDE0005: Unnecessary using directive +dotnet_diagnostic.IDE0005.severity = none +# disable IDE0390: Method can be made synchronous +dotnet_diagnostic.IDE0390.severity = none +# disable IDE0391: Method can be made synchronous +dotnet_diagnostic.IDE0391.severity = none diff --git a/src/request.dispatcher.tests/Geekeey.Request.Dispatcher.Tests.csproj b/src/request.dispatcher.tests/Geekeey.Request.Dispatcher.Tests.csproj new file mode 100644 index 0000000..635cf2d --- /dev/null +++ b/src/request.dispatcher.tests/Geekeey.Request.Dispatcher.Tests.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/request.dispatcher.tests/PackageContentsTests.cs b/src/request.dispatcher.tests/PackageContentsTests.cs new file mode 100644 index 0000000..d01f8a7 --- /dev/null +++ b/src/request.dispatcher.tests/PackageContentsTests.cs @@ -0,0 +1,49 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics; +using System.IO.Compression; + +namespace Geekeey.Request.Tests; + +internal sealed class PackageContentsTests +{ + private static string RepoRoot => Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."); + private static string ArtifactsDir => Path.Combine(RepoRoot, "artifacts"); + private static string ProjectPath => Path.Combine(RepoRoot, "src", "request.dispatcher", "Geekeey.Request.Dispatcher.csproj"); + + private readonly string _outputDir; + + public PackageContentsTests() + { + _outputDir = Path.Combine(ArtifactsDir, Path.GetRandomFileName()); + } + + [Before(Test)] + public async Task SetUpAsync() + { + Directory.CreateDirectory(_outputDir); + } + + [After(Test)] + public async Task CleanUpAsync() + { + Directory.Delete(_outputDir, true); + } + + [Test] + public async Task I_can_verify_the_package_contains_the_expected_contents() + { + using var process = Process.Start("dotnet", $"pack \"{ProjectPath}\" -o \"{_outputDir}\" -c Release"); + await process.WaitForExitAsync(); + + var pkgs = Directory.GetFiles(_outputDir, "*.nupkg"); + await Assert.That(pkgs.Length).IsGreaterThanOrEqualTo(1); + + await using var archive = await ZipFile.OpenReadAsync(pkgs[0]); + var entries = archive.Entries.Select(entry => entry.FullName.Replace('\\', '/')).ToHashSet(); + + await Assert.That(entries).Contains("lib/net10.0/Geekeey.Request.Dispatcher.dll"); + await Assert.That(entries).Contains("lib/net10.0/Geekeey.Request.Dispatcher.xml"); + } +} diff --git a/src/request.dispatcher.tests/RequestDispatcherBuilderExtensionsTests.cs b/src/request.dispatcher.tests/RequestDispatcherBuilderExtensionsTests.cs new file mode 100644 index 0000000..211e257 --- /dev/null +++ b/src/request.dispatcher.tests/RequestDispatcherBuilderExtensionsTests.cs @@ -0,0 +1,142 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Geekeey.Request.Dispatcher.Tests; + +internal sealed class RequestDispatcherBuilderExtensionsTests +{ + [Test] + public async Task I_can_add_a_type_and_register_the_options() + { + var services = new ServiceCollection(); + var builder = services.AddRequestDispatcher(); + var type = typeof(TestHandler); + + builder.Add(type); + + 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() + { + var services = new ServiceCollection(); + var builder = services.AddRequestDispatcher(); + var type = typeof(TestHandler); + var lifetime = ServiceLifetime.Scoped; + + builder.Add(type, lifetime); + + 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() + { + var services = new ServiceCollection(); + var builder = services.AddRequestDispatcher(); + var types = new[] { typeof(TestHandler), typeof(AnotherTestHandler) }; + + builder.Add(types); + + 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() + { + var services = new ServiceCollection(); + var builder = services.AddRequestDispatcher(); + var types = new[] { typeof(TestHandler), typeof(AnotherTestHandler) }; + var lifetime = ServiceLifetime.Singleton; + + builder.Add(types, lifetime); + + 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(); + } + } + + [Test] + public async Task I_can_get_an_exception_when_adding_a_nested_request_handler() + { + var services = new ServiceCollection(); + var builder = services.AddRequestDispatcher(); + var assembly = Roslyn.Compile(options => + { + options.AddFromEmbeddedSource("BuilderNestedHandler.cs"); + }); + var nestedHandlerType = assembly.GetType("Sample.Container+PingHandler", throwOnError: true)!; + + var exception = await Assert.That(() => builder.Add(nestedHandlerType)).Throws(); + await Assert.That(exception?.Message).Contains(nestedHandlerType.FullName!); + } + + [Test] + public async Task I_can_get_an_exception_when_adding_nested_request_handlers_with_a_lifetime() + { + var services = new ServiceCollection(); + var builder = services.AddRequestDispatcher(); + var assembly = Roslyn.Compile(options => + { + options.AddFromEmbeddedSource("BuilderNestedHandler.cs"); + }); + var nestedHandlerType = assembly.GetType("Sample.Container+PingHandler", throwOnError: true)!; + + var exception = await Assert + .That(() => builder.Add([typeof(TestHandler), nestedHandlerType], ServiceLifetime.Transient)) + .Throws(); + await Assert.That(exception?.Message).Contains(nestedHandlerType.FullName!); + } +} diff --git a/src/request.dispatcher.tests/ScalarBehaviourTests.cs b/src/request.dispatcher.tests/ScalarBehaviourTests.cs new file mode 100644 index 0000000..9c10e4b --- /dev/null +++ b/src/request.dispatcher.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.Dispatcher.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 diff --git a/src/request.dispatcher.tests/ScalarDispatcherTests.cs b/src/request.dispatcher.tests/ScalarDispatcherTests.cs new file mode 100644 index 0000000..5c10998 --- /dev/null +++ b/src/request.dispatcher.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.Dispatcher.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 diff --git a/src/request.dispatcher.tests/SearchHandlerInAssemblyTests.cs b/src/request.dispatcher.tests/SearchHandlerInAssemblyTests.cs new file mode 100644 index 0000000..ddd1859 --- /dev/null +++ b/src/request.dispatcher.tests/SearchHandlerInAssemblyTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Collections; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Geekeey.Request.Dispatcher.Tests; + +internal sealed class SearchHandlerInAssemblyTests +{ + [Test] + public async Task I_can_search_handlers_in_an_assembly() + { + var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchTopLevelHandler.cs")); + + var services = new ServiceCollection(); + services.AddRequestDispatcher(builder => builder + .SearchHandlerInAssembly(assembly)); + await using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + var requestType = assembly.GetType("Sample.Ping", throwOnError: true)!; + var handlerType = assembly.GetType("Sample.PingHandler", throwOnError: true)!; + var handlerInterface = typeof(IScalarRequestHandler<,>).MakeGenericType(requestType, typeof(string)); + var handlers = GetRequestHandlers(options, handlerInterface, provider).ToArray(); + + using var scope = Assert.Multiple(); + await Assert.That(handlers).Count().IsEqualTo(1); + await Assert.That(handlers.Single()!.GetType()).IsEqualTo(handlerType); + } + + [Test] + public async Task I_can_search_handlers_in_an_assembly_with_a_lifetime() + { + var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchTopLevelHandler.cs")); + + var services = new ServiceCollection(); + services.AddRequestDispatcher(builder => builder + .SearchHandlerInAssembly(assembly, ServiceLifetime.Singleton)); + var handlerType = assembly.GetType("Sample.PingHandler", throwOnError: true)!; + var descriptor = services.SingleOrDefault(service => service.ServiceType == handlerType); + + using var scope = Assert.Multiple(); + await Assert.That(descriptor).IsNotNull(); + await Assert.That(descriptor!.Lifetime).IsEqualTo(ServiceLifetime.Singleton); + } + + [Test] + public async Task I_can_get_an_exception_when_nested_handlers_are_present() + { + var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchNestedHandler.cs")); + + var services = new ServiceCollection(); + var builder = services.AddRequestDispatcher(); + + await Assert.That(() => builder.SearchHandlerInAssembly(assembly)) + .Throws().And.HasMessageContaining("Sample.Container+PingHandler"); + } + + private static IEnumerable GetRequestHandlers(RequestDispatcherOptions options, Type handlerInterface, + IServiceProvider provider) + { + return ((IEnumerable)typeof(RequestDispatcherOptions) + .GetMethod(nameof(RequestDispatcherOptions.GetRequestHandlers))! + .MakeGenericMethod(handlerInterface) + .Invoke(options, [provider])!) + .Cast(); + } +} diff --git a/src/request.dispatcher.tests/StreamBehaviourTests.cs b/src/request.dispatcher.tests/StreamBehaviourTests.cs new file mode 100644 index 0000000..c96b530 --- /dev/null +++ b/src/request.dispatcher.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.Dispatcher.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 diff --git a/src/request.dispatcher.tests/StreamDispatcherTests.cs b/src/request.dispatcher.tests/StreamDispatcherTests.cs new file mode 100644 index 0000000..c06cb36 --- /dev/null +++ b/src/request.dispatcher.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.Dispatcher.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(); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/AmbiguousScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/AmbiguousScalarHandler.cs new file mode 100644 index 0000000..7e081df --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/AmbiguousScalarHandler.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/AmbiguousScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/AmbiguousScalarRequest.cs new file mode 100644 index 0000000..e64c365 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/AmbiguousScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class AmbiguousScalarRequest : IScalarRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/AmbiguousStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/AmbiguousStreamHandler.cs new file mode 100644 index 0000000..0535a65 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/AmbiguousStreamHandler.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/AmbiguousStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/AmbiguousStreamRequest.cs new file mode 100644 index 0000000..478fdc7 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/AmbiguousStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class AmbiguousStreamRequest : IStreamRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/AnotherNamedScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/AnotherNamedScalarRequest.cs new file mode 100644 index 0000000..5612822 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/AnotherNamedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class AnotherNamedScalarRequest : INamedScalarRequest +{ + public string Name { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/AnotherNamedStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/AnotherNamedStreamRequest.cs new file mode 100644 index 0000000..9427863 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/AnotherNamedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class AnotherNamedStreamRequest : INamedStreamRequest +{ + public string Name { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/AnotherTestHandler.cs b/src/request.dispatcher.tests/_fixtures/AnotherTestHandler.cs new file mode 100644 index 0000000..268403d --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/AnotherTestHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +internal sealed class AnotherTestHandler : IScalarRequestHandler +{ + public Task HandleAsync(AnotherTestRequest request, CancellationToken ct) + { + return Task.FromResult("ok"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/AnotherTestRequest.cs b/src/request.dispatcher.tests/_fixtures/AnotherTestRequest.cs new file mode 100644 index 0000000..9f2e29c --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/AnotherTestRequest.cs @@ -0,0 +1,6 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +internal sealed class AnotherTestRequest : IScalarRequest { } diff --git a/src/request.dispatcher.tests/_fixtures/BaseScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/BaseScalarHandler.cs new file mode 100644 index 0000000..8265996 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/BaseScalarHandler.cs @@ -0,0 +1,10 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public abstract class BaseScalarHandler : IScalarRequestHandler + where TRequest : IScalarRequest +{ + public abstract Task HandleAsync(TRequest request, CancellationToken cancellationToken); +} diff --git a/src/request.dispatcher.tests/_fixtures/BaseStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/BaseStreamHandler.cs new file mode 100644 index 0000000..bed921c --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/BaseStreamHandler.cs @@ -0,0 +1,10 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public abstract class BaseStreamHandler : IStreamRequestHandler + where TRequest : IStreamRequest +{ + public abstract IAsyncEnumerable HandleAsync(TRequest request, CancellationToken cancellationToken); +} diff --git a/src/request.dispatcher.tests/_fixtures/ConstrainedScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/ConstrainedScalarHandler.cs new file mode 100644 index 0000000..22d1e65 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ConstrainedScalarHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ConstrainedScalarHandler : IScalarRequestHandler + where TRequest : ConstrainedScalarRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Value}-Constrained"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ConstrainedScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/ConstrainedScalarRequest.cs new file mode 100644 index 0000000..d95aea1 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ConstrainedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ConstrainedScalarRequest : IScalarRequest +{ + public int Value { get; set; } +} diff --git a/src/request.dispatcher.tests/_fixtures/ConstrainedStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/ConstrainedStreamHandler.cs new file mode 100644 index 0000000..e4fe239 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ConstrainedStreamHandler.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ConstrainedStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/ConstrainedStreamRequest.cs new file mode 100644 index 0000000..06dc84c --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ConstrainedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ConstrainedStreamRequest : IStreamRequest +{ + public int Value { get; set; } +} diff --git a/src/request.dispatcher.tests/_fixtures/DeepDerivedScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/DeepDerivedScalarRequest.cs new file mode 100644 index 0000000..cd61eb3 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DeepDerivedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class DeepDerivedScalarRequest : InheritedScalarRequest +{ + public int DeepValue { get; set; } +} diff --git a/src/request.dispatcher.tests/_fixtures/DeepDerivedStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/DeepDerivedStreamRequest.cs new file mode 100644 index 0000000..92f526b --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DeepDerivedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class DeepDerivedStreamRequest : InheritedStreamRequest +{ + public int DeepValue { get; set; } +} diff --git a/src/request.dispatcher.tests/_fixtures/DerivedScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/DerivedScalarHandler.cs new file mode 100644 index 0000000..8304f5e --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DerivedScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class DerivedScalarHandler : BaseScalarHandler +{ + public override Task HandleAsync(DerivedScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"Derived: {request.Value}"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/DerivedScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/DerivedScalarRequest.cs new file mode 100644 index 0000000..8370868 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DerivedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class DerivedScalarRequest : IScalarRequest +{ + public int Value { get; set; } +} diff --git a/src/request.dispatcher.tests/_fixtures/DerivedStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/DerivedStreamHandler.cs new file mode 100644 index 0000000..2c04401 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DerivedStreamHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/DerivedStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/DerivedStreamRequest.cs new file mode 100644 index 0000000..7c35ecd --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/DerivedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class DerivedStreamRequest : IStreamRequest +{ + public int Value { get; set; } +} diff --git a/src/request.dispatcher.tests/_fixtures/ExplicitGenericScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/ExplicitGenericScalarHandler.cs new file mode 100644 index 0000000..11a1b44 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ExplicitGenericScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ExplicitGenericScalarHandler : IScalarRequestHandler +{ + Task IScalarRequestHandler.HandleAsync(ExplicitGenericScalarRequest request, CancellationToken ct) + { + return Task.FromResult($"{request.Value}-ExplicitHandled"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ExplicitGenericScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/ExplicitGenericScalarRequest.cs new file mode 100644 index 0000000..f8e26b3 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ExplicitGenericScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ExplicitGenericScalarRequest : IScalarRequest +{ + public string Value { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/ExplicitGenericStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/ExplicitGenericStreamHandler.cs new file mode 100644 index 0000000..0225c15 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ExplicitGenericStreamHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ExplicitGenericStreamHandler : IStreamRequestHandler +{ + async IAsyncEnumerable IStreamRequestHandler.HandleAsync(ExplicitGenericStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + yield return $"{request.Value}-ExplicitHandled"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ExplicitGenericStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/ExplicitGenericStreamRequest.cs new file mode 100644 index 0000000..d2ab073 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ExplicitGenericStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ExplicitGenericStreamRequest : IStreamRequest +{ + public string Value { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/FailingScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/FailingScalarHandler.cs new file mode 100644 index 0000000..a31a8eb --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/FailingScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class FailingScalarHandler : IScalarRequestHandler +{ + public Task HandleAsync(FailingScalarRequest request, CancellationToken cancellationToken) + { + throw new InvalidOperationException("Handler failed"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/FailingScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/FailingScalarRequest.cs new file mode 100644 index 0000000..6ed8d86 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/FailingScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class FailingScalarRequest : IScalarRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/FailingStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/FailingStreamHandler.cs new file mode 100644 index 0000000..8f00d51 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/FailingStreamHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/FailingStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/FailingStreamRequest.cs new file mode 100644 index 0000000..a54d1b7 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/FailingStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class FailingStreamRequest : IStreamRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/INamedScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/INamedScalarRequest.cs new file mode 100644 index 0000000..8ffa478 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/INamedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public interface INamedScalarRequest : IScalarRequest +{ + string Name { get; } +} diff --git a/src/request.dispatcher.tests/_fixtures/INamedStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/INamedStreamRequest.cs new file mode 100644 index 0000000..8becebe --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/INamedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public interface INamedStreamRequest : IStreamRequest +{ + string Name { get; } +} diff --git a/src/request.dispatcher.tests/_fixtures/InheritedScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/InheritedScalarRequest.cs new file mode 100644 index 0000000..e79aabb --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/InheritedScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class InheritedScalarRequest : OpenScalarRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/InheritedStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/InheritedStreamRequest.cs new file mode 100644 index 0000000..d0e4892 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/InheritedStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class InheritedStreamRequest : OpenStreamRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/InterfaceConstrainedScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/InterfaceConstrainedScalarHandler.cs new file mode 100644 index 0000000..a56bc64 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/InterfaceConstrainedScalarHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class InterfaceConstrainedScalarHandler : IScalarRequestHandler + where TRequest : INamedScalarRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Name}-ConstrainedByInterface"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/InterfaceConstrainedStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/InterfaceConstrainedStreamHandler.cs new file mode 100644 index 0000000..d7b60c9 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/InterfaceConstrainedStreamHandler.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/InterfaceInheritedScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/InterfaceInheritedScalarHandler.cs new file mode 100644 index 0000000..44e1fc1 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/InterfaceInheritedScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class InterfaceInheritedScalarHandler : IScalarRequestHandler +{ + public Task HandleAsync(InterfaceInheritedScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Name}-InterfaceHandled"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/InterfaceInheritedScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/InterfaceInheritedScalarRequest.cs new file mode 100644 index 0000000..35063b5 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/InterfaceInheritedScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class InterfaceInheritedScalarRequest : INamedScalarRequest +{ + public string Name { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/InterfaceInheritedStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/InterfaceInheritedStreamHandler.cs new file mode 100644 index 0000000..e844f90 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/InterfaceInheritedStreamHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/InterfaceInheritedStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/InterfaceInheritedStreamRequest.cs new file mode 100644 index 0000000..0037c94 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/InterfaceInheritedStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class InterfaceInheritedStreamRequest : INamedStreamRequest +{ + public string Name { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/MultiInterfaceScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/MultiInterfaceScalarHandler.cs new file mode 100644 index 0000000..6e431c3 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/MultiInterfaceScalarHandler.cs @@ -0,0 +1,17 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/MultiInterfaceScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/MultiInterfaceScalarRequest.cs new file mode 100644 index 0000000..df43bd3 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/MultiInterfaceScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class MultiInterfaceScalarRequest : IScalarRequest, IScalarRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/MultiInterfaceStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/MultiInterfaceStreamHandler.cs new file mode 100644 index 0000000..04bab02 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/MultiInterfaceStreamHandler.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/MultiInterfaceStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/MultiInterfaceStreamRequest.cs new file mode 100644 index 0000000..3ae26d4 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/MultiInterfaceStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class MultiInterfaceStreamRequest : IStreamRequest, IStreamRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/OpenScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/OpenScalarHandler.cs new file mode 100644 index 0000000..b84afa5 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OpenScalarHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class OpenScalarHandler : IScalarRequestHandler +where TRequest : OpenScalarRequest +{ + public Task HandleAsync(TRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Data}-Handled"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/OpenScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/OpenScalarRequest.cs new file mode 100644 index 0000000..19b6437 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OpenScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class OpenScalarRequest : IScalarRequest +{ + public string Data { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/OpenStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/OpenStreamHandler.cs new file mode 100644 index 0000000..af7cc5a --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OpenStreamHandler.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/OpenStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/OpenStreamRequest.cs new file mode 100644 index 0000000..c147a7b --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OpenStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class OpenStreamRequest : IStreamRequest +{ + public string Data { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarChainedBehaviour1.cs b/src/request.dispatcher.tests/_fixtures/ScalarChainedBehaviour1.cs new file mode 100644 index 0000000..d79da60 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarChainedBehaviour1.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarChainedBehaviour2.cs b/src/request.dispatcher.tests/_fixtures/ScalarChainedBehaviour2.cs new file mode 100644 index 0000000..c8de827 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarChainedBehaviour2.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarOpenBehavior.cs b/src/request.dispatcher.tests/_fixtures/ScalarOpenBehavior.cs new file mode 100644 index 0000000..ada3c86 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarOpenBehavior.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarOrderingClosedBehavior.cs b/src/request.dispatcher.tests/_fixtures/ScalarOrderingClosedBehavior.cs new file mode 100644 index 0000000..c12820b --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarOrderingClosedBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ScalarOrderingClosedBehavior(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("OrderingClosed"); + return next(request, cancellationToken); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarOrderingOpenBehavior.cs b/src/request.dispatcher.tests/_fixtures/ScalarOrderingOpenBehavior.cs new file mode 100644 index 0000000..6eb0cd3 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarOrderingOpenBehavior.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarTestBehavior.cs b/src/request.dispatcher.tests/_fixtures/ScalarTestBehavior.cs new file mode 100644 index 0000000..d843a8b --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarTestBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarTestHandler.cs b/src/request.dispatcher.tests/_fixtures/ScalarTestHandler.cs new file mode 100644 index 0000000..16f46b4 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarTestHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ScalarTestHandler : IScalarRequestHandler +{ + public Task HandleAsync(ScalarTestRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Value}-Handled"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarTestRequest.cs b/src/request.dispatcher.tests/_fixtures/ScalarTestRequest.cs new file mode 100644 index 0000000..c40710c --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarTestRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ScalarTestRequest : IScalarRequest +{ + public string Value { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarTestTracker.cs b/src/request.dispatcher.tests/_fixtures/ScalarTestTracker.cs new file mode 100644 index 0000000..2672c97 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarTestTracker.cs @@ -0,0 +1,10 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ScalarTestTracker +{ + public List Log { get; } = []; + public bool Executed { get; set; } +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarTestWrapperHandler.cs b/src/request.dispatcher.tests/_fixtures/ScalarTestWrapperHandler.cs new file mode 100644 index 0000000..5900279 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarTestWrapperHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ScalarTestWrapperHandler : IScalarRequestHandler, string> +{ + public Task HandleAsync(ScalarTestWrapperRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"Handled-{request.Item}"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarTestWrapperRequest.cs b/src/request.dispatcher.tests/_fixtures/ScalarTestWrapperRequest.cs new file mode 100644 index 0000000..974e4cf --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarTestWrapperRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class ScalarTestWrapperRequest : IScalarRequest +{ + public T Item { get; set; } = default!; +} diff --git a/src/request.dispatcher.tests/_fixtures/ScalarWrapperBehavior.cs b/src/request.dispatcher.tests/_fixtures/ScalarWrapperBehavior.cs new file mode 100644 index 0000000..199885f --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/ScalarWrapperBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamChainedBehaviour1.cs b/src/request.dispatcher.tests/_fixtures/StreamChainedBehaviour1.cs new file mode 100644 index 0000000..f5c8053 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamChainedBehaviour1.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class StreamChainedBehaviour1(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Behaviour1"); + return next(request, cancellationToken); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamChainedBehaviour2.cs b/src/request.dispatcher.tests/_fixtures/StreamChainedBehaviour2.cs new file mode 100644 index 0000000..125ddc3 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamChainedBehaviour2.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class StreamChainedBehaviour2(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Behaviour2"); + return next(request, cancellationToken); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs b/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs new file mode 100644 index 0000000..3d90be3 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamOpenBehavior.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamOrderingClosedBehavior.cs b/src/request.dispatcher.tests/_fixtures/StreamOrderingClosedBehavior.cs new file mode 100644 index 0000000..3b89129 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamOrderingClosedBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class StreamOrderingClosedBehavior(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("Closed"); + return next(request, cancellationToken); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamOrderingOpenBehavior.cs b/src/request.dispatcher.tests/_fixtures/StreamOrderingOpenBehavior.cs new file mode 100644 index 0000000..3350c54 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamOrderingOpenBehavior.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamTestBehavior.cs b/src/request.dispatcher.tests/_fixtures/StreamTestBehavior.cs new file mode 100644 index 0000000..0a66170 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamTestBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class StreamTestBehavior(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Executed = true; + return next(request, cancellationToken); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamTestHandler.cs b/src/request.dispatcher.tests/_fixtures/StreamTestHandler.cs new file mode 100644 index 0000000..13bd3aa --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamTestHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamTestRequest.cs b/src/request.dispatcher.tests/_fixtures/StreamTestRequest.cs new file mode 100644 index 0000000..1bd8dba --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamTestRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class StreamTestRequest : IStreamRequest +{ + public string Value { get; set; } = string.Empty; +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamTestTracker.cs b/src/request.dispatcher.tests/_fixtures/StreamTestTracker.cs new file mode 100644 index 0000000..c651d2f --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamTestTracker.cs @@ -0,0 +1,10 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class StreamTestTracker +{ + public List Log { get; } = []; + public bool Executed { get; set; } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamTestWrapperHandler.cs b/src/request.dispatcher.tests/_fixtures/StreamTestWrapperHandler.cs new file mode 100644 index 0000000..7aea53f --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamTestWrapperHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamTestWrapperRequest.cs b/src/request.dispatcher.tests/_fixtures/StreamTestWrapperRequest.cs new file mode 100644 index 0000000..be36146 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamTestWrapperRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class StreamTestWrapperRequest : IStreamRequest +{ + public T? Item { get; set; } +} diff --git a/src/request.dispatcher.tests/_fixtures/StreamWrapperBehavior.cs b/src/request.dispatcher.tests/_fixtures/StreamWrapperBehavior.cs new file mode 100644 index 0000000..10d314f --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/StreamWrapperBehavior.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class StreamWrapperBehavior(StreamTestTracker tracker) : IStreamRequestBehavior, string> +{ + public IAsyncEnumerable HandleAsync(StreamTestWrapperRequest request, StreamHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Executed = true; + return next(request, cancellationToken); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/TestHandler.cs b/src/request.dispatcher.tests/_fixtures/TestHandler.cs new file mode 100644 index 0000000..761c6c3 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/TestHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +internal sealed class TestHandler : IScalarRequestHandler +{ + public Task HandleAsync(TestRequest request, CancellationToken ct) + { + return Task.FromResult("ok"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/TestRequest.cs b/src/request.dispatcher.tests/_fixtures/TestRequest.cs new file mode 100644 index 0000000..b52e1f6 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/TestRequest.cs @@ -0,0 +1,6 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +internal sealed class TestRequest : IScalarRequest { } diff --git a/src/request.dispatcher.tests/_fixtures/UnhandledScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/UnhandledScalarRequest.cs new file mode 100644 index 0000000..0d1991d --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/UnhandledScalarRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class UnhandledScalarRequest : IScalarRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/UnhandledStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/UnhandledStreamRequest.cs new file mode 100644 index 0000000..c99ef41 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/UnhandledStreamRequest.cs @@ -0,0 +1,8 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class UnhandledStreamRequest : IStreamRequest +{ +} diff --git a/src/request.dispatcher.tests/_fixtures/WrapperScalarHandler.cs b/src/request.dispatcher.tests/_fixtures/WrapperScalarHandler.cs new file mode 100644 index 0000000..152f7ca --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/WrapperScalarHandler.cs @@ -0,0 +1,12 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class WrapperScalarHandler : IScalarRequestHandler, string> +{ + public Task HandleAsync(WrapperScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"Handled-{request.Item}"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/WrapperScalarRequest.cs b/src/request.dispatcher.tests/_fixtures/WrapperScalarRequest.cs new file mode 100644 index 0000000..8183897 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/WrapperScalarRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class WrapperScalarRequest : IScalarRequest +{ + public T Item { get; set; } = default!; +} diff --git a/src/request.dispatcher.tests/_fixtures/WrapperStreamHandler.cs b/src/request.dispatcher.tests/_fixtures/WrapperStreamHandler.cs new file mode 100644 index 0000000..fbd0812 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/WrapperStreamHandler.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.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"; + } +} diff --git a/src/request.dispatcher.tests/_fixtures/WrapperStreamRequest.cs b/src/request.dispatcher.tests/_fixtures/WrapperStreamRequest.cs new file mode 100644 index 0000000..a21492b --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/WrapperStreamRequest.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class WrapperStreamRequest : IStreamRequest +{ + public T Item { get; set; } = default!; +} diff --git a/src/request.dispatcher.tests/_fixtures/roslyn/BuilderNestedHandler.cs b/src/request.dispatcher.tests/_fixtures/roslyn/BuilderNestedHandler.cs new file mode 100644 index 0000000..3652a9d --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/roslyn/BuilderNestedHandler.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; + +using Geekeey.Request.Dispatcher; + +namespace Sample; + +public sealed class Ping : IScalarRequest +{ +} + +public sealed class Container +{ + public sealed class PingHandler : IScalarRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/roslyn/SearchNestedHandler.cs b/src/request.dispatcher.tests/_fixtures/roslyn/SearchNestedHandler.cs new file mode 100644 index 0000000..3652a9d --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/roslyn/SearchNestedHandler.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; + +using Geekeey.Request.Dispatcher; + +namespace Sample; + +public sealed class Ping : IScalarRequest +{ +} + +public sealed class Container +{ + public sealed class PingHandler : IScalarRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong"); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/roslyn/SearchTopLevelHandler.cs b/src/request.dispatcher.tests/_fixtures/roslyn/SearchTopLevelHandler.cs new file mode 100644 index 0000000..66e036d --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/roslyn/SearchTopLevelHandler.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +using Geekeey.Request.Dispatcher; + +namespace Sample; + +public sealed class Ping : IScalarRequest +{ +} + +public sealed class PingHandler : IScalarRequestHandler +{ + public Task HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong"); +} diff --git a/src/request.dispatcher.tests/_helpers/Roslyn.cs b/src/request.dispatcher.tests/_helpers/Roslyn.cs new file mode 100644 index 0000000..e6b65fe --- /dev/null +++ b/src/request.dispatcher.tests/_helpers/Roslyn.cs @@ -0,0 +1,177 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +using Assembly = System.Reflection.Assembly; + +namespace Geekeey.Request.Dispatcher.Tests; + +internal static class Roslyn +{ + private static CSharpCompilation CreateCompilation(CompilerOptions options) + { + if (options.SourceCode.Count is 0) + { + throw new InvalidOperationException("At least one source file must be configured."); + } + + var syntax = options.SourceCode + .Select(static sourceCode => CSharpSyntaxTree.ParseText(sourceCode, CSharpParseOptions.Default + .WithLanguageVersion(LanguageVersion.Preview))) + .ToArray(); + + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Concat(options.References.Select(static assembly => assembly.Location)) + .Distinct(StringComparer.Ordinal) + .Select(static path => MetadataReference.CreateFromFile(path)); + + var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); + return CSharpCompilation.Create(options.AssemblyName, syntax, references, compilationOptions); + } + + public sealed class CompilerOptions + { + public string AssemblyName { get; set; } = Guid.NewGuid().ToString(); + public ICollection References { get; } = new HashSet(); + public IDictionary Properties { get; } = new Dictionary(StringComparer.Ordinal); + public ICollection SourceCode { get; } = new HashSet(StringComparer.Ordinal); + + public void AddFromEmbeddedSource(string fileName) + { + var assembly = Assembly.GetExecutingAssembly(); + var relativePath = $"_fixtures/roslyn/{fileName}"; + var suffix = relativePath.Replace(Path.DirectorySeparatorChar, '.') + .Replace(Path.AltDirectorySeparatorChar, '.'); + var resourceName = assembly.GetManifestResourceNames() + .SingleOrDefault(name => name.EndsWith(suffix, StringComparison.Ordinal)); + + if (resourceName is null) + { + throw new InvalidOperationException($"Embedded fixture '{relativePath}' was not found."); + } + + if (assembly.GetManifestResourceStream(resourceName) is not { } stream) + { + throw new InvalidOperationException($"Embedded fixture '{resourceName}' could not be opened."); + } + + using var reader = new StreamReader(stream); + SourceCode.Add(reader.ReadToEnd()); + } + } + + public static Assembly Compile(Action? configure = null) + { + var options = new CompilerOptions(); + configure?.Invoke(options); + var compilation = CreateCompilation(options); + + using var stream = new MemoryStream(); + + if (compilation.Emit(stream) is { Success: false, Diagnostics: var diagnostics }) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, + diagnostics.Select(static diagnostic => diagnostic.ToString()))); + } + + stream.Seek(0, SeekOrigin.Begin); + + return Assembly.Load(stream.ToArray()); + } + + public static GeneratorDriverRunResult SourceGenerator(Action? configure = null) + where TGenerator : IIncrementalGenerator, new() + { + var options = new CompilerOptions(); + configure?.Invoke(options); + var compilation = CreateCompilation(options); + + GeneratorDriver driver = CSharpGeneratorDriver.Create([new TGenerator().AsSourceGenerator()], + parseOptions: (CSharpParseOptions)compilation.SyntaxTrees.First().Options, + optionsProvider: new TestAnalyzerConfigOptionsProvider( + new Dictionary(options.Properties, StringComparer.Ordinal) + { + ["build_property.AssemblyName"] = options.AssemblyName, + })); + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + return driver.GetRunResult(); + } + + extension(GeneratorDriverRunResult result) + { + public string? File([StringSyntax(StringSyntaxAttribute.Regex)] string name) + { + foreach (var value in result.Results) + { + foreach (var source in value.GeneratedSources) + { + if (Regex.IsMatch(source.HintName, name)) + { + return source.SourceText.ToString(); + } + } + } + + return null; + } + } + + private sealed class TestAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider + { + public TestAnalyzerConfigOptionsProvider(IDictionary values) + { + GlobalOptions = new TestAnalyzerConfigOptions(values); + } + + public override AnalyzerConfigOptions GlobalOptions { get; } + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + { + return GlobalOptions; + } + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + { + return GlobalOptions; + } + } + + private sealed class TestAnalyzerConfigOptions : AnalyzerConfigOptions + { + private readonly ImmutableDictionary _values; + + public TestAnalyzerConfigOptions(IDictionary values) + { + _values = values.ToImmutableDictionary(StringComparer.Ordinal); + } + + public override bool TryGetValue(string key, out string value) + { + if (_values.TryGetValue(key, out var existingValue) && existingValue is not null) + { + value = existingValue; + return true; + } + + if (key.StartsWith("build_property.", StringComparison.Ordinal) && + _values.TryGetValue(key["build_property.".Length..], out existingValue) && + existingValue is not null) + { + value = existingValue; + return true; + } + + value = string.Empty; + return false; + } + } +} diff --git a/src/request.dispatcher/Geekeey.Request.Dispatcher.csproj b/src/request.dispatcher/Geekeey.Request.Dispatcher.csproj new file mode 100644 index 0000000..eb5b620 --- /dev/null +++ b/src/request.dispatcher/Geekeey.Request.Dispatcher.csproj @@ -0,0 +1,36 @@ + + + + Library + net10.0 + true + + + + true + + + + + + + + package-readme.md + Simple mediator implementation in .NET with minimal dependencies. + package-icon.png + https://code.geekeey.de/geekeey/request/src/branch/main/src/request.dispatcher + EUPL-1.2 + + + + + + + + + + + + + + diff --git a/src/request.dispatcher/IRequestDispatcher.cs b/src/request.dispatcher/IRequestDispatcher.cs new file mode 100644 index 0000000..bd39e6f --- /dev/null +++ b/src/request.dispatcher/IRequestDispatcher.cs @@ -0,0 +1,28 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher; + +/// +/// 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); +} diff --git a/src/request.dispatcher/IRequestDispatcherBuilder.cs b/src/request.dispatcher/IRequestDispatcherBuilder.cs new file mode 100644 index 0000000..a8b52da --- /dev/null +++ b/src/request.dispatcher/IRequestDispatcherBuilder.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request.Dispatcher; + +/// +/// 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; } +} diff --git a/src/request.dispatcher/IScalarPipelineBehavior.cs b/src/request.dispatcher/IScalarPipelineBehavior.cs new file mode 100644 index 0000000..55d3e70 --- /dev/null +++ b/src/request.dispatcher/IScalarPipelineBehavior.cs @@ -0,0 +1,35 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +#pragma warning disable CA1711 +#pragma warning disable CA1716 + +namespace Geekeey.Request.Dispatcher; + +/// +/// 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); diff --git a/src/request.dispatcher/IScalarRequest.cs b/src/request.dispatcher/IScalarRequest.cs new file mode 100644 index 0000000..6ca89b2 --- /dev/null +++ b/src/request.dispatcher/IScalarRequest.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher; + +/// +/// 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 +{ +} diff --git a/src/request.dispatcher/IScalarRequestHandler.cs b/src/request.dispatcher/IScalarRequestHandler.cs new file mode 100644 index 0000000..2be54f7 --- /dev/null +++ b/src/request.dispatcher/IScalarRequestHandler.cs @@ -0,0 +1,20 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher; + +/// +/// 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); +} diff --git a/src/request.dispatcher/IStreamPipelineBehavior.cs b/src/request.dispatcher/IStreamPipelineBehavior.cs new file mode 100644 index 0000000..ac3a10c --- /dev/null +++ b/src/request.dispatcher/IStreamPipelineBehavior.cs @@ -0,0 +1,35 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +#pragma warning disable CA1711 +#pragma warning disable CA1716 + +namespace Geekeey.Request.Dispatcher; + +/// +/// 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); diff --git a/src/request.dispatcher/IStreamRequest.cs b/src/request.dispatcher/IStreamRequest.cs new file mode 100644 index 0000000..08f3d7d --- /dev/null +++ b/src/request.dispatcher/IStreamRequest.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher; + +/// +/// 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 +{ +} diff --git a/src/request.dispatcher/IStreamRequestHandler.cs b/src/request.dispatcher/IStreamRequestHandler.cs new file mode 100644 index 0000000..d217171 --- /dev/null +++ b/src/request.dispatcher/IStreamRequestHandler.cs @@ -0,0 +1,21 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher; + +/// +/// 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); +} diff --git a/src/request.dispatcher/RequestDispatcher.cs b/src/request.dispatcher/RequestDispatcher.cs new file mode 100644 index 0000000..c4083bd --- /dev/null +++ b/src/request.dispatcher/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.Dispatcher.RequestDispatcher))] + +namespace Geekeey.Request.Dispatcher; + +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); + } +} diff --git a/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs b/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs new file mode 100644 index 0000000..8d53e3b --- /dev/null +++ b/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs @@ -0,0 +1,174 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Reflection; +using System.Runtime.CompilerServices; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using static Geekeey.Request.Dispatcher.RequestDispatcherOptions; + +namespace Geekeey.Request.Dispatcher; + +/// +/// 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. + /// + /// + /// Prefer the generated Add<Name>(builder) extensions for assemblies that directly reference + /// Geekeey.Request.Dispatcher. This runtime scanning API remains supported, but nested request handlers are rejected + /// during registration and generated registration should not be mixed with + /// for the same assembly. + /// + /// 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(IsRequestHandlerImplementationType); + + 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. + /// + /// + /// Prefer the generated Add<Name>(builder, lifetime) extensions for assemblies that directly reference + /// Geekeey.Request.Dispatcher. This runtime scanning API remains supported, but nested request handlers are rejected + /// during registration and generated registration should not be mixed with + /// for the same assembly. + /// + /// 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(IsRequestHandlerImplementationType); + + 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); + ValidateNoNestedRequestHandlers([type]); + + 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); + ValidateNoNestedRequestHandlers([type]); + + 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); + + var types = ValidateNoNestedRequestHandlers([.. type]); + + builder.Services.AddOptions() + .Configure(options => options.Inspect(types)); + + 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); + + var types = ValidateNoNestedRequestHandlers([.. type]); + + builder.Services.AddOptions() + .Configure(options => options.Inspect(types)); + + builder.Services.Add(types.Select(export => new ServiceDescriptor(export, export, lifetime))); + + return builder; + } + + private static IReadOnlyCollection ValidateNoNestedRequestHandlers(IReadOnlyCollection types, [CallerMemberName] string? invoker = null) + { + var nestedHandlers = types + .Where(type => type is { IsClass: true, IsAbstract: false, IsNested: true }) + .Where(IsRequestHandlerImplementationType) + .Select(type => type.FullName ?? type.Name) + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + + if (nestedHandlers.Length > 0) + { + throw new InvalidOperationException($"Nested request handlers are not supported by {invoker}: {string.Join(", ", nestedHandlers)}"); + } + + return types; + } + + private static bool IsRequestHandlerImplementationType(Type type) + { + return type.GetInterfaces().Any(IsRequestHandlerType); + } +} diff --git a/src/request.dispatcher/RequestDispatcherOptions.cs b/src/request.dispatcher/RequestDispatcherOptions.cs new file mode 100644 index 0000000..d18def9 --- /dev/null +++ b/src/request.dispatcher/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.Dispatcher; + +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; + } + } +} diff --git a/src/request.dispatcher/ScalarRequestInvoker.cs b/src/request.dispatcher/ScalarRequestInvoker.cs new file mode 100644 index 0000000..feb02bf --- /dev/null +++ b/src/request.dispatcher/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.Dispatcher; + +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); + } + } +} diff --git a/src/request.dispatcher/ServiceCollectionExtensions.cs b/src/request.dispatcher/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6cebd83 --- /dev/null +++ b/src/request.dispatcher/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.Dispatcher; + +/// +/// 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; + } +} diff --git a/src/request.dispatcher/StreamRequestInvoker.cs b/src/request.dispatcher/StreamRequestInvoker.cs new file mode 100644 index 0000000..6557146 --- /dev/null +++ b/src/request.dispatcher/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.Dispatcher; + +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); + } + } +} diff --git a/src/request.dispatcher/package-icon.png b/src/request.dispatcher/package-icon.png new file mode 100644 index 0000000..35f4099 Binary files /dev/null and b/src/request.dispatcher/package-icon.png differ diff --git a/src/request.dispatcher/package-readme.md b/src/request.dispatcher/package-readme.md new file mode 100644 index 0000000..8ed5c19 --- /dev/null +++ b/src/request.dispatcher/package-readme.md @@ -0,0 +1,68 @@ +## 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.Dispatcher +``` + +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 + .SearchHandlerInAssembly(typeof(ScalarHandler).Assembly) + .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(ScalarRequest request, CancellationToken cancellationToken) + { + return Task.FromResult($"{request.Value} World"); + } +} + +public class ScalarBehavior : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarRequest 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 container 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/src/request.result.tests/.editorconfig b/src/request.result.tests/.editorconfig new file mode 100644 index 0000000..9de929c --- /dev/null +++ b/src/request.result.tests/.editorconfig @@ -0,0 +1,15 @@ + +[*.{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 +# disable IDE0005: Unnecessary using directive +dotnet_diagnostic.IDE0005.severity = none +# disable IDE0390: Method can be made synchronous +dotnet_diagnostic.IDE0390.severity = none +# disable IDE0391: Method can be made synchronous +dotnet_diagnostic.IDE0391.severity = none diff --git a/src/request.result.tests/ErrorTests.cs b/src/request.result.tests/ErrorTests.cs new file mode 100644 index 0000000..dafe5c9 --- /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(); + } +} diff --git a/src/request.result.tests/ExtensionsEnumerableTests.cs b/src/request.result.tests/ExtensionsEnumerableTests.cs new file mode 100644 index 0000000..3453f49 --- /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(); + } +} diff --git a/src/request.result.tests/ExtensionsTaskTests.cs b/src/request.result.tests/ExtensionsTaskTests.cs new file mode 100644 index 0000000..8f34e24 --- /dev/null +++ b/src/request.result.tests/ExtensionsTaskTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Globalization; + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ExtensionsTaskTests +{ + [Test] + public async Task I_can_map_on_task_and_it_returns_success_for_success() + { + var start = Task.FromResult(Prelude.Success(2)); + var result = await 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_try_map_on_task_and_it_returns_failure_for_success_with_throwing() + { + var start = Task.FromResult(Prelude.Success(2)); + var result = await start.TryMap(value => 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_map_async_on_task_and_it_returns_success_for_success() + { + var start = Task.FromResult(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_then_on_task_and_it_returns_success_for_success() + { + var start = Task.FromResult(Prelude.Success(2)); + var result = await 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_then_non_generic_on_task_and_it_returns_success_for_success() + { + var start = Task.FromResult(Prelude.Success(2)); + var result = await start.Then(value => Prelude.Success()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_map_on_value_task_and_it_returns_success_for_success() + { + var start = ValueTask.FromResult(Prelude.Success(2)); + var result = await 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_on_task_result_and_it_returns_success_for_success() + { + var start = Task.FromResult(Prelude.Success()); + var result = await start.Map(() => "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_then_on_task_result_and_it_returns_success_for_success() + { + var start = Task.FromResult(Prelude.Success()); + var result = await start.Then(Prelude.Success); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_then_generic_on_task_result_and_it_returns_success_for_success() + { + var start = Task.FromResult(Prelude.Success()); + var result = await start.Then(() => Prelude.Success("2")); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo("2"); + } +} 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/IResultFactoryTests.cs b/src/request.result.tests/IResultFactoryTests.cs new file mode 100644 index 0000000..7d4a652 --- /dev/null +++ b/src/request.result.tests/IResultFactoryTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Result.Tests; + +public class IResultFactoryTests +{ + [Test] + public async Task I_can_assign_result_to_result_factory() + { + await Assert.That(typeof(IResultFactory).IsAssignableFrom(typeof(Result))).IsTrue(); + await Assert.That(typeof(IResultFactory>).IsAssignableFrom(typeof(Result))).IsTrue(); + } + + [Test] + public async Task I_can_create_failure_result_from_error_with_result_factory() + { + { + var error = new StringError("error"); + var result = CreateFailure(error); + await Assert.That(result.IsFailure).IsTrue(); + } + { + var error = new StringError("error"); + var result = CreateFailure>(error); + await Assert.That(result.IsFailure).IsTrue(); + } + } + + private static TResult CreateFailure(Error error) where TResult : IResultFactory + { + return TResult.Failure(error); + } +} diff --git a/src/request.result.tests/PreludeTests.cs b/src/request.result.tests/PreludeTests.cs new file mode 100644 index 0000000..31c89a0 --- /dev/null +++ b/src/request.result.tests/PreludeTests.cs @@ -0,0 +1,180 @@ +// 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(); + } + + [Test] + public async Task I_can_create_success_result_using_prelude() + { + var result = Prelude.Success(); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.IsFailure).IsFalse(); + } + + [Test] + public async Task I_can_create_failure_result_using_prelude() + { + var result = Prelude.Failure("error"); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task I_can_try_action_with_success_and_get_a_success_result() + { + var result = Prelude.Try(() => { }); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_action_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_async_action_with_success_and_get_a_success_result() + { + var result = await Prelude.TryAsync(() => Task.CompletedTask); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_async_action_with_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_async_value_action_with_success_and_get_a_success_result() + { + var result = await Prelude.TryAsync(() => ValueTask.CompletedTask); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_async_value_action_with_throwing_exception_and_get_a_failure_result() + { + 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(); + } +} diff --git a/src/request.result.tests/ResultConversionTests.cs b/src/request.result.tests/ResultConversionTests.cs new file mode 100644 index 0000000..1d47dd7 --- /dev/null +++ b/src/request.result.tests/ResultConversionTests.cs @@ -0,0 +1,76 @@ +// 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(); + } + + [Test] + public async Task I_can_implicitly_convert_from_error_to_non_generic_result() + { + var error = new CustomTestError(); + Result result = error; + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsTypeOf(); + } +} diff --git a/src/request.result.tests/ResultEqualityTests.cs b/src/request.result.tests/ResultEqualityTests.cs new file mode 100644 index 0000000..67233dd --- /dev/null +++ b/src/request.result.tests/ResultEqualityTests.cs @@ -0,0 +1,202 @@ +// 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(); + } + + [Test] + public async Task I_can_equal_non_generic_result_and_get_true_for_success_and_success() + { + var a = Prelude.Success(); + var b = Prelude.Success(); + + await Assert.That(a.Equals(b)).IsTrue(); + } + + [Test] + public async Task I_can_equal_non_generic_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_non_generic_result_and_get_false_for_success_and_failure() + { + var a = Prelude.Success(); + var b = Prelude.Failure("error"); + + await Assert.That(a.Equals(b)).IsFalse(); + } +} diff --git a/src/request.result.tests/ResultMatchingTests.cs b/src/request.result.tests/ResultMatchingTests.cs new file mode 100644 index 0000000..e36c57d --- /dev/null +++ b/src/request.result.tests/ResultMatchingTests.cs @@ -0,0 +1,365 @@ +// 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; + } + } + + [Test] + public async Task I_can_match_non_generic_result_and_it_calls_success_func_for_success() + { + var result = Prelude.Success(); + var match = result.Match( + () => "success", + _ => throw new InvalidOperationException()); + + await Assert.That(match).IsEqualTo("success"); + } + + [Test] + public async Task I_can_match_non_generic_result_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_non_generic_result_and_it_calls_success_action_for_success() + { + var called = false; + + var result = Prelude.Success(); + result.Switch(OnSuccess, OnFailure); + + await Assert.That(called).IsTrue(); + + void OnSuccess() + { + called = true; + } + + void OnFailure(Error e) + { + throw new InvalidOperationException(); + } + } + + [Test] + public async Task I_can_switch_non_generic_result_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"); + + void OnSuccess() + { + throw new InvalidOperationException(); + } + + void OnFailure(Error e) + { + value = e; + called = true; + } + } + + [Test] + public async Task I_can_match_async_non_generic_result_and_it_calls_success_func_for_success() + { + var result = Prelude.Success(); + var match = await result.MatchAsync( + () => Task.FromResult("success"), + _ => throw new InvalidOperationException()); + + await Assert.That(match).IsEqualTo("success"); + } + + [Test] + public async Task I_can_switch_async_non_generic_result_and_it_calls_success_action_for_success() + { + var called = false; + + var result = Prelude.Success(); + await result.SwitchAsync(OnSuccess, OnFailure); + + await Assert.That(called).IsTrue(); + + Task OnSuccess() + { + called = true; + return Task.CompletedTask; + } + + Task OnFailure(Error e) + { + throw new InvalidOperationException(); + } + } + + [Test] + public async Task I_can_match_async_non_generic_result_and_it_calls_success_func_for_success_ValueTask() + { + var result = Prelude.Success(); + var match = await result.MatchAsync( + () => ValueTask.FromResult("success"), + _ => throw new InvalidOperationException()); + + await Assert.That(match).IsEqualTo("success"); + } + + [Test] + public async Task I_can_switch_async_non_generic_result_and_it_calls_success_action_for_success_ValueTask() + { + var called = false; + + var result = Prelude.Success(); + await result.SwitchAsync(OnSuccess, OnFailure); + + await Assert.That(called).IsTrue(); + + ValueTask OnSuccess() + { + called = true; + return ValueTask.CompletedTask; + } + + ValueTask OnFailure(Error e) + { + throw new InvalidOperationException(); + } + } +} diff --git a/src/request.result.tests/ResultOperatorTests.cs b/src/request.result.tests/ResultOperatorTests.cs new file mode 100644 index 0000000..95918b9 --- /dev/null +++ b/src/request.result.tests/ResultOperatorTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Geekeey.Request.Result; + +namespace Geekeey.Request.Result.Tests; + +internal sealed class ResultOperatorTests +{ + [Test] + public async Task I_can_compare_non_generic_results_with_operator_and_get_true_for_success_and_success() + { + var a = Prelude.Success(); + var b = Prelude.Success(); + + await Assert.That(a == b).IsTrue(); + } + + [Test] + public async Task I_can_compare_non_generic_results_with_operator_and_get_true_for_failure_and_failure() + { + var a = Prelude.Failure("error 1"); + var b = Prelude.Failure("error 2"); + + await Assert.That(a == b).IsTrue(); + } + + [Test] + public async Task I_can_compare_non_generic_results_with_operator_and_get_false_for_success_and_failure() + { + var a = Prelude.Success(); + var b = Prelude.Failure("error"); + + await Assert.That(a == b).IsFalse(); + } + + [Test] + public async Task I_can_compare_non_generic_results_with_operator_and_handle_nulls() + { + Result? a = null; + Result? b = null; + var c = Prelude.Success(); + + await Assert.That(a == b).IsTrue(); + await Assert.That(a == c).IsFalse(); + await Assert.That(c == a).IsFalse(); + await Assert.That(a != b).IsFalse(); + } + + [Test] + public async Task I_can_compare_generic_results_with_operator_and_get_true_for_success_with_equal_value() + { + var a = Prelude.Success(2); + var b = Prelude.Success(2); + + await Assert.That(a == b).IsTrue(); + } + + [Test] + public async Task I_can_compare_generic_results_with_operator_and_get_false_for_success_with_unequal_value() + { + var a = Prelude.Success(2); + var b = Prelude.Success(3); + + await Assert.That(a == b).IsFalse(); + } + + [Test] + public async Task I_can_compare_generic_results_with_operator_and_get_true_for_failure_and_failure() + { + var a = Prelude.Failure("error 1"); + var b = Prelude.Failure("error 2"); + + await Assert.That(a == b).IsTrue(); + } + + [Test] + public async Task I_can_compare_generic_result_with_value_using_operator_true_when_success_and_equal() + { + var a = Prelude.Success(2); + var b = 2; + + await Assert.That(a == b).IsTrue(); + } + + [Test] + public async Task I_can_compare_generic_result_with_value_using_operator_false_when_failure() + { + var a = Prelude.Failure("error"); + var b = 2; + + await Assert.That(a == b).IsFalse(); + } + + [Test] + public async Task I_can_get_hashcode_non_generic_success_and_failure() + { + var success = Prelude.Success(); + var failure = Prelude.Failure("error"); + + await Assert.That(success.GetHashCode()).IsEqualTo(true.GetHashCode()); + await Assert.That(failure.GetHashCode()).IsEqualTo(false.GetHashCode()); + } +} diff --git a/src/request.result.tests/ResultTests.cs b/src/request.result.tests/ResultTests.cs new file mode 100644 index 0000000..4d201f0 --- /dev/null +++ b/src/request.result.tests/ResultTests.cs @@ -0,0 +1,96 @@ +// 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_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 }"); + } + + [Test] + public async Task I_can_create_new_success_result() + { + var result = new Result(); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.IsFailure).IsFalse(); + await Assert.That(result.Error).IsNull(); + } + + [Test] + public async Task I_can_create_new_failure_result() + { + 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.Error).IsTypeOf(); + } + + [Test] + public async Task I_can_to_string_success_result() + { + var result = new Result(); + + await Assert.That(result.ToString()).IsEqualTo("Success"); + } + + [Test] + public async Task I_can_to_string_failure_result() + { + var result = new Result(new StringError("error")); + + await Assert.That(result.ToString()).IsEqualTo("Failure { error }"); + } + + [Test] + public async Task I_can_use_generic_result_as_non_generic_result() + { + Result 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.Error).IsNull(); + } +} diff --git a/src/request.result.tests/ResultTransformTests.cs b/src/request.result.tests/ResultTransformTests.cs new file mode 100644 index 0000000..92f2a18 --- /dev/null +++ b/src/request.result.tests/ResultTransformTests.cs @@ -0,0 +1,1273 @@ +// 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"); + } + + [Test] + public async Task I_can_map_non_generic_result_and_it_returns_success_for_success() + { + var start = Prelude.Success(); + var result = start.Map(() => 42); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_map_non_generic_result_and_it_returns_failure_for_failure() + { + var start = Prelude.Failure("error"); + var result = start.Map(() => 42); + + 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_non_generic_result_and_it_returns_failure_for_exception() + { + var start = Prelude.Success(); + 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_transform_non_generic_result_with_then_and_it_returns_success_for_success() + { + var start = Prelude.Success(); + var result = start.Then(Prelude.Success); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_transform_non_generic_result_with_then_to_generic_and_it_returns_success_for_success() + { + var start = Prelude.Success(); + var result = start.Then(() => Prelude.Success(42)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_transform_generic_result_with_then_to_non_generic_and_it_returns_success_for_success() + { + var start = Prelude.Success(42); + var result = start.Then(_ => Prelude.Success()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_map_non_generic_result_async_and_it_returns_success_for_success_Task() + { + var start = Prelude.Success(); + var result = await start.MapAsync(() => Task.FromResult(42)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_try_map_non_generic_result_async_and_it_returns_failure_for_exception_Task() + { + var start = Prelude.Success(); + var result = await start.TryMapAsync((Func>)(() => 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_transform_non_generic_result_async_with_then_and_it_returns_success_for_success_Task() + { + var start = Prelude.Success(); + var result = await start.ThenAsync(() => Task.FromResult(Prelude.Success())); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_transform_generic_result_async_with_then_to_non_generic_and_it_returns_success_for_success_Task() + { + var start = Prelude.Success(42); + var result = await start.ThenAsync(_ => Task.FromResult(Prelude.Success())); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_map_non_generic_result_async_and_it_returns_success_for_success_ValueTask() + { + var start = Prelude.Success(); + var result = await start.MapAsync(() => ValueTask.FromResult(42)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_try_map_non_generic_result_async_and_it_returns_failure_for_exception_ValueTask() + { + var start = Prelude.Success(); + var result = await start.TryMapAsync((Func>)(() => 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_transform_generic_result_async_with_then_to_non_generic_and_it_returns_success_for_success_ValueTask() + { + var start = Prelude.Success(42); + var result = await start.ThenAsync(_ => ValueTask.FromResult(Prelude.Success())); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_transform_result_to_non_generic_and_it_returns_success_for_success() + { + var start = Prelude.Success(2); + var result = start.ThenTry(value => Prelude.Success()); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_transform_result_to_non_generic_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_to_non_generic_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_async_to_non_generic_and_it_returns_success_for_success_Task() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(value => Task.FromResult(Prelude.Success())); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_transform_result_async_to_non_generic_and_it_returns_failure_for_success_and_mapping_throwing_Task() + { + 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_to_non_generic_and_it_returns_success_for_success_ValueTask() + { + var start = Prelude.Success(2); + var result = await start.ThenTryAsync(value => ValueTask.FromResult(Prelude.Success())); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_transform_result_async_to_non_generic_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_non_generic_result_with_then_and_it_returns_success_for_success() + { + var start = Prelude.Success(); + var result = start.ThenTry(Prelude.Success); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_transform_non_generic_result_with_then_and_it_returns_failure_for_exception() + { + var start = Prelude.Success(); + 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_non_generic_result_with_then_to_generic_and_it_returns_success_for_success() + { + var start = Prelude.Success(); + var result = start.ThenTry(() => Prelude.Success(42)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_try_transform_non_generic_result_with_then_to_generic_and_it_returns_failure_for_exception() + { + var start = Prelude.Success(); + 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_map_non_generic_result_async_and_it_returns_failure_for_failure_Task() + { + var start = Prelude.Failure("error"); + var result = await start.MapAsync(() => Task.FromResult(42)); + + 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_non_generic_result_async_and_it_returns_success_for_success_Task() + { + var start = Prelude.Success(); + var result = await start.TryMapAsync(() => Task.FromResult(42)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_try_map_non_generic_result_async_and_it_returns_failure_for_failure_Task() + { + var start = Prelude.Failure("error"); + var result = await start.TryMapAsync(() => Task.FromResult(42)); + + 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_non_generic_result_async_and_it_returns_failure_for_await_exception_Task() + { + var start = Prelude.Success(); + var result = await start.TryMapAsync((Func>)(async () => + { + 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_transform_non_generic_result_async_with_then_and_it_returns_failure_for_failure_Task() + { + var start = Prelude.Failure("error"); + var result = await start.ThenAsync(() => Task.FromResult(Prelude.Success())); + + 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_non_generic_result_async_with_then_and_it_returns_success_for_success_Task() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync(() => Task.FromResult(Prelude.Success())); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_transform_non_generic_result_async_with_then_and_it_returns_failure_for_exception_Task() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync((Func>)(() => 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_non_generic_result_async_with_then_and_it_returns_failure_for_await_exception_Task() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync((Func>)(async () => + { + 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_transform_non_generic_result_async_with_then_to_generic_and_it_returns_success_for_success_Task() + { + var start = Prelude.Success(); + var result = await start.ThenAsync(() => Task.FromResult(Prelude.Success(42))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_transform_non_generic_result_async_with_then_to_generic_and_it_returns_failure_for_failure_Task() + { + var start = Prelude.Failure("error"); + var result = await start.ThenAsync(() => Task.FromResult(Prelude.Success(42))); + + 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_non_generic_result_async_with_then_to_generic_and_it_returns_success_for_success_Task() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync(() => Task.FromResult(Prelude.Success(42))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_try_transform_non_generic_result_async_with_then_to_generic_and_it_returns_failure_for_exception_Task() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync((Func>>)(() => 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_non_generic_result_async_with_then_to_generic_and_it_returns_failure_for_await_exception_Task() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync((Func>>)(async () => + { + 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_transform_generic_result_async_with_then_to_non_generic_and_it_returns_failure_for_failure_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.ThenAsync(_ => ValueTask.FromResult(Prelude.Success())); + + 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_to_non_generic_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_to_non_generic_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_map_non_generic_result_async_and_it_returns_failure_for_failure_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.MapAsync(() => ValueTask.FromResult(42)); + + 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_non_generic_result_async_and_it_returns_success_for_success_ValueTask() + { + var start = Prelude.Success(); + var result = await start.TryMapAsync(() => ValueTask.FromResult(42)); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_try_map_non_generic_result_async_and_it_returns_failure_for_failure_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.TryMapAsync(() => ValueTask.FromResult(42)); + + 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_non_generic_result_async_and_it_returns_failure_for_await_exception_ValueTask() + { + var start = Prelude.Success(); + var result = await start.TryMapAsync((Func>)(async () => + { + 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_transform_non_generic_result_async_with_then_and_it_returns_success_for_success_ValueTask() + { + var start = Prelude.Success(); + var result = await start.ThenAsync(() => ValueTask.FromResult(Prelude.Success())); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_transform_non_generic_result_async_with_then_and_it_returns_failure_for_failure_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.ThenAsync(() => ValueTask.FromResult(Prelude.Success())); + + 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_non_generic_result_async_with_then_and_it_returns_success_for_success_ValueTask() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync(() => ValueTask.FromResult(Prelude.Success())); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + } + + [Test] + public async Task I_can_try_transform_non_generic_result_async_with_then_and_it_returns_failure_for_exception_ValueTask() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync((Func>)(() => 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_non_generic_result_async_with_then_and_it_returns_failure_for_await_exception_ValueTask() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync((Func>)(async () => + { + 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_transform_non_generic_result_async_with_then_to_generic_and_it_returns_success_for_success_ValueTask() + { + var start = Prelude.Success(); + var result = await start.ThenAsync(() => ValueTask.FromResult(Prelude.Success(42))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_transform_non_generic_result_async_with_then_to_generic_and_it_returns_failure_for_failure_ValueTask() + { + var start = Prelude.Failure("error"); + var result = await start.ThenAsync(() => ValueTask.FromResult(Prelude.Success(42))); + + 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_non_generic_result_async_with_then_to_generic_and_it_returns_success_for_success_ValueTask() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync(() => ValueTask.FromResult(Prelude.Success(42))); + + using var scope = Assert.Multiple(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task I_can_try_transform_non_generic_result_async_with_then_to_generic_and_it_returns_failure_for_exception_ValueTask() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync((Func>>)(() => 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_non_generic_result_async_with_then_to_generic_and_it_returns_failure_for_await_exception_ValueTask() + { + var start = Prelude.Success(); + var result = await start.ThenTryAsync((Func>>)(async () => + { + 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(); + } +} diff --git a/src/request.result.tests/ResultUnboxTests.cs b/src/request.result.tests/ResultUnboxTests.cs new file mode 100644 index 0000000..7cd991c --- /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); + } +} diff --git a/src/request.result.tests/_fixtures/CustomTestError.cs b/src/request.result.tests/_fixtures/CustomTestError.cs new file mode 100644 index 0000000..411e786 --- /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; +} diff --git a/src/request.result.tests/_fixtures/CustomTestException.cs b/src/request.result.tests/_fixtures/CustomTestException.cs new file mode 100644 index 0000000..28c59ab --- /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 +{ +} diff --git a/src/request.result/Geekeey.Request.Result.csproj b/src/request.result/Geekeey.Request.Result.csproj new file mode 100644 index 0000000..9647999 --- /dev/null +++ b/src/request.result/Geekeey.Request.Result.csproj @@ -0,0 +1,31 @@ + + + + Library + net10.0 + true + + + + true + + + + + + + + package-readme.md + Simple result type implementation for C# with utilities for composing success and failure flows. + package-icon.png + https://code.geekeey.de/geekeey/request/src/branch/main/src/request.result + EUPL-1.2 + + + + + + + + + diff --git a/src/request.result/IResultFactory.cs b/src/request.result/IResultFactory.cs new file mode 100644 index 0000000..b46566b --- /dev/null +++ b/src/request.result/IResultFactory.cs @@ -0,0 +1,20 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.CodeAnalysis; + +namespace Geekeey.Request.Result; + +/// +/// An interface for a result. +/// +[SuppressMessage("Naming", "CA1716:Identifiers should not match keywords")] +public interface IResultFactory where TSelf : IResultFactory +{ + /// + /// Creates a new result with a failure value. + /// + /// The error of the result. + /// A new result with a failure value. + static abstract TSelf Failure(Error error); +} diff --git a/src/request.result/Prelude.cs b/src/request.result/Prelude.cs new file mode 100644 index 0000000..de48354 --- /dev/null +++ b/src/request.result/Prelude.cs @@ -0,0 +1,183 @@ +// 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. + /// + [Pure] + public static Result Success() + { + return new Result(); + } + + /// + /// Creates a result containing a failure. + /// + /// The failure value to create the result from. + [Pure] + public static Result Failure(Error error) + { + return new Result(error); + } + + /// + /// 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 an action and return the result. If the action throws an exception, the exception will be + /// returned wrapped in an . + /// + /// The action to try to execute. + /// A result containing success or an containing the exception thrown by the + /// action. + [Pure] + public static Result Try(Action function) + { + try + { + function(); + return new Result(); + } + 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 function to try to execute. + /// A result containing success or an containing the exception thrown by the + /// function. + [Pure] + public static async ValueTask TryAsync(Func function) + { + try + { + await function(); + return new Result(); + } + 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 function to try to execute. + /// A result containing success or an containing the exception thrown by the + /// function. + [Pure] + public static async Task TryAsync(Func function) + { + try + { + await function(); + return new Result(); + } + catch (Exception exception) + { + return new Result(new ExceptionError(exception)); + } + } + + /// + /// 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)); + } + } +} diff --git a/src/request.result/Result.Conversion.cs b/src/request.result/Result.Conversion.cs new file mode 100644 index 0000000..6305c6e --- /dev/null +++ b/src/request.result/Result.Conversion.cs @@ -0,0 +1,64 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.Contracts; + +namespace Geekeey.Request.Result; + +public partial class Result +{ + /// + /// 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); + } +} + +public partial class 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(); + } +} diff --git a/src/request.result/Result.Equality.cs b/src/request.result/Result.Equality.cs new file mode 100644 index 0000000..0253b88 --- /dev/null +++ b/src/request.result/Result.Equality.cs @@ -0,0 +1,239 @@ +// 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 partial class Result : IEquatable +{ + /// + [Pure] + public bool Equals(Result? other) + { + return Equals(this, other); + } + + /// + [Pure] + public override bool Equals(object? obj) + { + return obj is Result r && Equals(r); + } + + /// + [Pure] + public override int GetHashCode() + { + return GetHashCode(this); + } + + internal static bool Equals(Result? a, Result? b) + { + if (a is null || b is null) + { + return a is null && b is null; + } + + return a.IsSuccess == b.IsSuccess; + } + + internal static int GetHashCode(Result? result) + { + return result?.IsSuccess.GetHashCode() ?? 0; + } +} + +public partial class Result : IEqualityOperators +{ + /// + /// Checks whether two results are equal. Results are equal if they are both success or both failure. + /// + /// The first result to compare. + /// The second result to compare. + [Pure] + [ExcludeFromCodeCoverage] + public static bool operator ==(Result? a, Result? b) + { + return Equals(a, b); + } + + /// + /// Checks whether two results are not equal. Results are equal if they are both success or both failure. + /// + /// The first result to compare. + /// The second result to compare. + [Pure] + [ExcludeFromCodeCoverage] + public static bool operator !=(Result? a, Result? b) + { + return !Equals(a, b); + } +} + +public partial class 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 is null || b is null) + { + return a is null && b is null; + } + + 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 is null) + { + return b is null; + } + + 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 partial class 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); + } +} diff --git a/src/request.result/Result.Matching.cs b/src/request.result/Result.Matching.cs new file mode 100644 index 0000000..28e5a58 --- /dev/null +++ b/src/request.result/Result.Matching.cs @@ -0,0 +1,199 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.Contracts; + +namespace Geekeey.Request.Result; + +public partial class Result +{ + /// + /// Matches over the success or failure of the result and returns another value. Can be conceptualized as an + /// exhaustive switch expression matching all possible states of the type. + /// + /// The type to return from the match. + /// The function to invoke 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 result. + [Pure] + public TResult Match(Func success, Func failure) + { + return IsSuccess ? success() : failure(Error); + } + + /// + /// Matches over the success or failure of the result and invokes an effectful action. Can be conceptualized as an + /// exhaustive switch statement matching all possible states of the type. + /// + /// The function to call 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(); + } + else + { + failure(Error); + } + } +} + +public partial class Result +{ + /// + /// Asynchronously matches over the success or failure of the result and returns another value. Can be + /// conceptualized as an exhaustive switch expression matching all possible states of the type. + /// + /// The type to return from the match. + /// The function to invoke 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 failure value of the result. + [Pure] + public async Task MatchAsync(Func> success, Func> failure) + { + return IsSuccess ? await success() : await failure(Error); + } + + /// + /// Asynchronously matches over the success or failure of the result and invokes an effectful action onto the + /// failure value. Can be conceptualized as an exhaustive switch statement matching all possible states of + /// the type. + /// + /// The function to call 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() : failure(Error); + } +} + +public partial class Result +{ + /// + /// Asynchronously matches over the success or failure of the result and returns another value. Can be + /// conceptualized as an exhaustive switch expression matching all possible states of the type. + /// + /// The type to return from the match. + /// The function to invoke 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 failure value of the result. + [Pure] + public ValueTask MatchAsync(Func> success, Func> failure) + { + return IsSuccess ? success() : failure(Error); + } + + /// + /// Asynchronously matches over the success or failure of the result and invokes an effectful action onto the + /// failure value. Can be conceptualized as an exhaustive switch statement matching all possible states of + /// the type. + /// + /// The function to call 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() : failure(Error); + } +} + +public partial class 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 partial class 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 partial class 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); + } +} diff --git a/src/request.result/Result.Transform.cs b/src/request.result/Result.Transform.cs new file mode 100644 index 0000000..9885daa --- /dev/null +++ b/src/request.result/Result.Transform.cs @@ -0,0 +1,1059 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.Contracts; + +namespace Geekeey.Request.Result; + +public partial class 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 partial class 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 partial class 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)); + } + } + } +} + +public partial class Result +{ + /// + /// Chains a non-generic result to this result if it is a success, or does nothing if the result is a failure. + /// + /// The function used to create the next result. + /// 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 chain a non-generic result to this result if it is a success, or does nothing if the result is a + /// failure. If the function throws an exception, the exception will be returned wrapped in an + /// . + /// + /// The function used to create the next result. + /// 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 partial class Result +{ + /// + /// Chains a non-generic result to this result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to create the next result. + /// A which either completes asynchronously by invoking the mapping function, 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; + } + } + + /// + /// Tries to chain a non-generic result to this 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 create the next result. + /// A which either completes asynchronously by invoking the mapping function, + /// 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 partial class Result +{ + /// + /// Chains a non-generic result to this result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to create the next result. + /// A which either completes asynchronously by invoking the mapping function, 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; + } + } + + /// + /// Tries to chain a non-generic result to this 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 create the next result. + /// A which either completes asynchronously by invoking the mapping function, + /// 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)); + } + } + } +} + +public partial class Result +{ + /// + /// Maps the success of the result to a new success value using a mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to map the success to a new 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()) : new Result(Error); + } + + /// + /// Tries to map the success of the result to a new success value 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 to a new 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)); + } + } + + /// + /// Chains another result to this result if it is a success, or does nothing if the result is a failure. + /// + /// The function used to create the next result. + /// 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() : new Result(Error); + } + + /// + /// Tries to chain another result to this result if it is a success, or does nothing if the result is a failure. If + /// the function throws an exception, the exception will be returned wrapped in an . + /// + /// The function used to create the next result. + /// 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)); + } + } + + /// + /// Chains a generic result to this result if it is a success, or does nothing if the result is a failure. + /// + /// The function used to create the next generic 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() : new Result(Error); + } + + /// + /// Tries to chain a generic result to this result if it is a success, or does nothing if the result is a failure. If + /// the function throws an exception, the exception will be returned wrapped in an . + /// + /// The function used to create the next generic 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 partial class Result +{ + /// + /// Maps the success of the result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to map the success. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function 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(); + return CreateResult(task); + + static async Task> CreateResult(Task task) + { + var value = await task; + return new Result(value); + } + } + + /// + /// Maps the success 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. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function 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(); + 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)); + } + } + } + + /// + /// Chains another result to this result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to create the next result. + /// A which either completes asynchronously by invoking the mapping function, 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(); + return CreateResult(task); + + static async Task CreateResult(Task task) + { + var result = await task; + return result; + } + } + + /// + /// Tries to chain another result to this 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 create the next result. + /// A which either completes asynchronously by invoking the mapping function, + /// 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(); + 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)); + } + } + } + + /// + /// Chains a generic result to this result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to create the next result. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function, 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(); + return CreateResult(task); + + static async Task> CreateResult(Task> task) + { + var result = await task; + return result; + } + } + + /// + /// Tries to chain a generic result to this 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 create the next result. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function, + /// 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(); + 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 partial class Result +{ + /// + /// Maps the success of the result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to map the success. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function 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(); + return CreateResult(task); + + static async ValueTask> CreateResult(ValueTask task) + { + var value = await task; + return new Result(value); + } + } + + /// + /// Maps the success 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. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function 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(); + 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)); + } + } + } + + /// + /// Chains another result to this result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to create the next result. + /// A which either completes asynchronously by invoking the mapping function, 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(); + return CreateResult(task); + + static async ValueTask CreateResult(ValueTask task) + { + var result = await task; + return result; + } + } + + /// + /// Tries to chain another result to this 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 create the next result. + /// A which either completes asynchronously by invoking the mapping function, + /// 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(); + 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)); + } + } + } + + /// + /// Chains a generic result to this result using an asynchronous mapping function, or does nothing if the result is + /// a failure. + /// + /// The function used to create the next result. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function, 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(); + return CreateResult(task); + + static async ValueTask> CreateResult(ValueTask> task) + { + var result = await task; + return result; + } + } + + /// + /// Tries to chain a generic result to this 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 create the next result. + /// The type of the new value. + /// A which either completes asynchronously by invoking the mapping function, + /// 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(); + 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)); + } + } + } +} diff --git a/src/request.result/Result.Unbox.cs b/src/request.result/Result.Unbox.cs new file mode 100644 index 0000000..b633bdd --- /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 partial class 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; + } +} diff --git a/src/request.result/Result.cs b/src/request.result/Result.cs new file mode 100644 index 0000000..1265cd0 --- /dev/null +++ b/src/request.result/Result.cs @@ -0,0 +1,141 @@ +// 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 . +/// +public partial class Result : IResultFactory +{ + /// + /// Creates a new result with a success value. + /// + public Result() + { + Error = default; + } + + /// + /// Creates a new result with a failure value. + /// + /// The error of the result. + public Result(Error error) + { + Error = error; + } + + /// + /// Represents the error associated with a failed result. + /// + /// + /// When a result is unsuccessful, this property will hold an instance of a type derived from . + /// It is null when the result is successful. + /// + public Error? Error { get; } + + /// + /// Whether the result is a success. + /// + /// + /// This is always the inverse of but is more specific about intent. + /// + [MemberNotNullWhen(false, nameof(Error))] + public virtual bool IsSuccess => Error is null; + + /// + /// Whether the result is a failure. + /// + /// + /// This is always the inverse of but is more specific about intent. + /// + [MemberNotNullWhen(true, nameof(Error))] + public virtual bool IsFailure => !IsSuccess; + + /// + [Pure] + static Result IResultFactory.Failure(Error error) + { + return new Result(error); + } + + /// + /// Gets a string representation of the result. + /// + [Pure] + public override string ToString() + { + return IsSuccess ? $"Success" : $"Failure {{ {Error} }}"; + } +} + +/// +/// 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 partial class Result : Result, IResultFactory> +{ + /// + /// Creates a new result with a success value. + /// + /// The success value. + public Result(T value) : base() + { + Value = value; + } + + /// + /// Creates a new result with a failure value. + /// + /// The error of the result. + public Result(Error error) : base(error) + { + Value = default; + } + + /// + /// Gets the success value of the result if the operation was successful. + /// + /// + /// This property contains the value of type when the result is successful. + /// If the result is unsuccessful, this property will be null or default, depending on the type. + /// Accessing this property when the result is unsuccessful may raise an exception if not properly handled. + /// + public T? Value { get; } + + /// + [MemberNotNullWhen(true, nameof(Value))] + public override bool IsSuccess => base.IsSuccess; + + /// + [MemberNotNullWhen(false, nameof(Value))] + public override bool IsFailure => base.IsFailure; + + /// + [Pure] + static Result IResultFactory>.Failure(Error error) + { + return new Result(error); + } + + /// + /// 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; + } +} diff --git a/src/request.result/_errors/AggregateError.cs b/src/request.result/_errors/AggregateError.cs new file mode 100644 index 0000000..5c7ea7e --- /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)); +} diff --git a/src/request.result/_errors/Error.cs b/src/request.result/_errors/Error.cs new file mode 100644 index 0000000..d43da98 --- /dev/null +++ b/src/request.result/_errors/Error.cs @@ -0,0 +1,56 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +#pragma warning disable CA1716 + +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); + } +} diff --git a/src/request.result/_errors/ExceptionError.cs b/src/request.result/_errors/ExceptionError.cs new file mode 100644 index 0000000..22d6bec --- /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; +} diff --git a/src/request.result/_errors/StringError.cs b/src/request.result/_errors/StringError.cs new file mode 100644 index 0000000..cc0eb36 --- /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; +} diff --git a/src/request.result/_exceptions/UnwrapException.cs b/src/request.result/_exceptions/UnwrapException.cs new file mode 100644 index 0000000..9837112 --- /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) + { + } +} diff --git a/src/request.result/_extensions/Extensions.Enumerable.cs b/src/request.result/_extensions/Extensions.Enumerable.cs new file mode 100644 index 0000000..7226f60 --- /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; + } +} diff --git a/src/request.result/_extensions/Extensions.Task.cs b/src/request.result/_extensions/Extensions.Task.cs new file mode 100644 index 0000000..8ce54b9 --- /dev/null +++ b/src/request.result/_extensions/Extensions.Task.cs @@ -0,0 +1,599 @@ +// 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)] + // ReSharper disable once InconsistentNaming + public static async Task> TryMap(this Task> result, Func func) + { + return (await result).TryMap(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> MapAsync(this Task> result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> MapAsync(this Task> result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> TryMapAsync(this Task> result, Func> func) + { + return await (await result).TryMapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> TryMapAsync(this Task> result, Func> func) + { + return await (await result).TryMapAsync(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)] + // ReSharper disable once InconsistentNaming + public static async Task> ThenTry(this Task> result, Func> func) + { + return (await result).ThenTry(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> ThenAsync(this Task> result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> ThenAsync(this Task> result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> ThenTryAsync(this Task> result, Func>> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> ThenTryAsync(this Task> result, Func>> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + [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)] + // ReSharper disable once InconsistentNaming + public static async Task ThenTry(this Task> result, Func func) + { + return (await result).ThenTry(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task ThenAsync(this Task> result, Func> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task ThenAsync(this Task> result, Func> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task ThenTryAsync(this Task> result, Func> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task ThenTryAsync(this Task> result, Func> func) + { + return await (await result).ThenTryAsync(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)] + // ReSharper disable once InconsistentNaming + public static async ValueTask> TryMap(this ValueTask> result, Func func) + { + return (await result).TryMap(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> MapAsync(this ValueTask> result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> MapAsync(this ValueTask> result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> TryMapAsync(this ValueTask> result, Func> func) + { + return await (await result).TryMapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> TryMapAsync(this ValueTask> result, Func> func) + { + return await (await result).TryMapAsync(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)] + // ReSharper disable once InconsistentNaming + public static async ValueTask> ThenTry(this ValueTask> result, Func> func) + { + return (await result).ThenTry(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> ThenAsync(this ValueTask> result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> ThenAsync(this ValueTask> result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> ThenTryAsync(this ValueTask> result, Func>> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> ThenTryAsync(this ValueTask> result, Func>> func) + { + return await (await result).ThenTryAsync(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)] + // ReSharper disable once InconsistentNaming + public static async ValueTask ThenTry(this ValueTask> result, Func func) + { + return (await result).ThenTry(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask ThenAsync(this ValueTask> result, Func> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask ThenAsync(this ValueTask> result, Func> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask ThenTryAsync(this ValueTask> result, Func> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask ThenTryAsync(this ValueTask> result, Func> func) + { + return await (await result).ThenTryAsync(func); + } + + #endregion + + #region Task + + /// + /// Maps the success of the result object of the completed task to a new success value 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 to a new value. + /// 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)] + // ReSharper disable once InconsistentNaming + public static async Task> TryMap(this Task result, Func func) + { + return (await result).TryMap(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> MapAsync(this Task result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> MapAsync(this Task result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> TryMapAsync(this Task result, Func> func) + { + return await (await result).TryMapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> TryMapAsync(this Task result, Func> func) + { + return await (await result).TryMapAsync(func); + } + + /// + /// Chains another result to the result object of the completed task if it is a success, 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 create the next result. + /// A result which is either the mapped result or a new result containing 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)] + // ReSharper disable once InconsistentNaming + public static async Task ThenTry(this Task result, Func func) + { + return (await result).ThenTry(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task ThenAsync(this Task result, Func> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task ThenAsync(this Task result, Func> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task ThenTryAsync(this Task result, Func> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task ThenTryAsync(this Task result, Func> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + /// Chains a generic result to the result object of the completed task if it is a success, 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 create the next generic 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. + [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)] + // ReSharper disable once InconsistentNaming + public static async Task> ThenTry(this Task result, Func> func) + { + return (await result).ThenTry(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> ThenAsync(this Task result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> ThenAsync(this Task result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> ThenTryAsync(this Task result, Func>> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task> ThenTryAsync(this Task result, Func>> func) + { + return await (await result).ThenTryAsync(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)] + // ReSharper disable once InconsistentNaming + public static async ValueTask> TryMap(this ValueTask result, Func func) + { + return (await result).TryMap(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> MapAsync(this ValueTask result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> MapAsync(this ValueTask result, Func> func) + { + return await (await result).MapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> TryMapAsync(this ValueTask result, Func> func) + { + return await (await result).TryMapAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> TryMapAsync(this ValueTask result, Func> func) + { + return await (await result).TryMapAsync(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)] + // ReSharper disable once InconsistentNaming + public static async ValueTask ThenTry(this ValueTask result, Func func) + { + return (await result).ThenTry(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask ThenAsync(this ValueTask result, Func> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask ThenAsync(this ValueTask result, Func> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask ThenTryAsync(this ValueTask result, Func> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask ThenTryAsync(this ValueTask result, Func> func) + { + return await (await result).ThenTryAsync(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)] + // ReSharper disable once InconsistentNaming + public static async ValueTask> ThenTry(this ValueTask result, Func> func) + { + return (await result).ThenTry(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> ThenAsync(this ValueTask result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> ThenAsync(this ValueTask result, Func>> func) + { + return await (await result).ThenAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> ThenTryAsync(this ValueTask result, Func>> func) + { + return await (await result).ThenTryAsync(func); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async ValueTask> ThenTryAsync(this ValueTask result, Func>> func) + { + return await (await result).ThenTryAsync(func); + } + + #endregion +} 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..fa3a745 --- /dev/null +++ b/src/request.result/package-readme.md @@ -0,0 +1,79 @@ +## 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: + +```shell +dotnet add package Geekeey.Request.Result +``` + +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 + +**Basic success/failure handling:** + +```csharp +public Result Divide(int dividend, int divisor) +{ + return divisor == 0 + ? Prelude.Failure("Division by zero") + : Prelude.Success(dividend / divisor); +} + +var result = Divide(10, 2); +var message = result.IsSuccess + ? $"Result: {result.Value}" + : $"Error: {result.Error}"; +``` + +**Chaining with Map and Then:** + +```csharp +var result = Prelude.Success(10) + .Map(x => x * 2) // 20 + .Map(x => x + 5) // 25 + .Then(x => x > 20 + ? Prelude.Success(x) + : Prelude.Failure("Value too small")); +``` + +**Async operations with error handling:** + +```csharp +public async Task> FetchUserAsync(int userId) +{ + return await Prelude.TryAsync(() => _httpClient.GetAsync($"/api/users/{userId}")) + .ThenAsync(static async response => + { + var json = await response.Content.ReadAsStringAsync(); + return Prelude.Try(() => JsonSerializer.Deserialize(json)!); + }); +} +``` + +**Collecting multiple results:** + +```csharp +var results = await Task.WhenAll(userIds.Select(FetchUserAsync)); +var combined = results.Join(); // Result> + +if (combined.IsSuccess) +{ + foreach (var user in combined.Value) + { + Console.WriteLine($"User: {user.Name}"); + } +} +``` diff --git a/src/request.validation.tests/.editorconfig b/src/request.validation.tests/.editorconfig new file mode 100644 index 0000000..9de929c --- /dev/null +++ b/src/request.validation.tests/.editorconfig @@ -0,0 +1,15 @@ + +[*.{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 +# disable IDE0005: Unnecessary using directive +dotnet_diagnostic.IDE0005.severity = none +# disable IDE0390: Method can be made synchronous +dotnet_diagnostic.IDE0390.severity = none +# disable IDE0391: Method can be made synchronous +dotnet_diagnostic.IDE0391.severity = none diff --git a/src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj b/src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj new file mode 100644 index 0000000..cee590b --- /dev/null +++ b/src/request.validation.tests/Geekeey.Request.Validation.Tests.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + false + + + + + + + + + + + + + + + + diff --git a/src/request.validation.tests/ProblemTests.cs b/src/request.validation.tests/ProblemTests.cs new file mode 100644 index 0000000..9bc59be --- /dev/null +++ b/src/request.validation.tests/ProblemTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ProblemTests +{ + [Test] + public async Task I_can_create_a_problem_using_the_builder() + { + var problem = Problem.Create("Name is required") + .WithPropertyPath("Name") + .WithCode("CODE") + .WithSeverity(Severity.Warning) + .WithAttemptedValue("Value") + .Build(); + + using (Assert.Multiple()) + { + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo("Name"); + await Assert.That(problem.Message).IsEqualTo("Name is required"); + await Assert.That(problem.Code).IsEqualTo("CODE"); + await Assert.That(problem.Severity).IsEqualTo(Severity.Warning); + await Assert.That(problem.AttemptedValue).IsEqualTo("Value"); + } + } + + [Test] + public async Task I_can_implicitly_convert_a_builder_to_a_problem() + { + Problem problem = Problem.Create("Message").WithPropertyPath("Path"); + + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo("Path"); + } +} diff --git a/src/request.validation.tests/PropertyPathTests.cs b/src/request.validation.tests/PropertyPathTests.cs new file mode 100644 index 0000000..bce4614 --- /dev/null +++ b/src/request.validation.tests/PropertyPathTests.cs @@ -0,0 +1,274 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Text.Json; + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class PropertyPathTests +{ + private static readonly JsonSerializerOptions CamelCaseJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + [Test] + public async Task I_can_serialize_property_paths_using_the_json_naming_policy() + { + var problem = new Problem + { + PropertyPath = "Address.Street", + Message = "Street is required.", + }; + + var json = JsonSerializer.Serialize(problem, CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"address.street","severity":0,"message":"Street is required.","code":null,"attemptedValue":null}"""); + } + + [Test] + public async Task I_can_serialize_indexed_property_paths_using_the_json_naming_policy() + { + var problem = new Problem + { + PropertyPath = "Members[1].Name", + Message = "Member name is required.", + }; + + var json = JsonSerializer.Serialize(problem, CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"members[1].name","severity":0,"message":"Member name is required.","code":null,"attemptedValue":null}"""); + } + + [Test] + public async Task I_can_serialize_paths_with_multiple_indexers_using_the_json_naming_policy() + { + var problem = new Problem + { + PropertyPath = "Matrix[1][2].Value", + Message = "Value is required.", + }; + + var json = JsonSerializer.Serialize(problem, CamelCaseJsonOptions); + + await Assert.That(json).IsEqualTo(/*lang=json,strict*/ """{"propertyPath":"matrix[1][2].value","severity":0,"message":"Value is required.","code":null,"attemptedValue":null}"""); + } + + [Test] + public async Task I_can_iterate_and_index_segments() + { + var propertyPath = (PropertyPath)"Members[1].Name"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(propertyPath.Count).IsEqualTo(2); + await Assert.That(propertyPath.IsEmpty).IsFalse(); + await Assert.That(segments.Count).IsEqualTo(2); + await Assert.That(segments[0]).IsEqualTo("Members[1]"); + await Assert.That(segments[1]).IsEqualTo("Name"); + await Assert.That(propertyPath[0].ToString()).IsEqualTo("Members[1]"); + await Assert.That(propertyPath[1].ToString()).IsEqualTo("Name"); + } + + [Test] + public async Task I_can_treat_multiple_indexers_as_a_single_segment() + { + var propertyPath = (PropertyPath)"Matrix[1][2].Value"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(propertyPath.Count).IsEqualTo(2); + await Assert.That(segments.Count).IsEqualTo(2); + await Assert.That(segments[0]).IsEqualTo("Matrix[1][2]"); + await Assert.That(segments[1]).IsEqualTo("Value"); + } + + [Test] + public async Task I_can_avoid_splitting_dots_inside_brackets() + { + var propertyPath = (PropertyPath)"Items[foo.bar].Name"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(propertyPath.Count).IsEqualTo(2); + await Assert.That(segments.Count).IsEqualTo(2); + await Assert.That(segments[0]).IsEqualTo("Items[foo.bar]"); + await Assert.That(segments[1]).IsEqualTo("Name"); + await Assert.That(propertyPath[0].ToString()).IsEqualTo("Items[foo.bar]"); + await Assert.That(propertyPath[1].ToString()).IsEqualTo("Name"); + } + + [Test] + public async Task I_can_expose_empty_segments_for_malformed_paths() + { + var propertyPath = (PropertyPath)"Address..Street"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(propertyPath.Count).IsEqualTo(3); + await Assert.That(propertyPath.IsEmpty).IsFalse(); + await Assert.That(segments.Count).IsEqualTo(3); + await Assert.That(segments[0]).IsEqualTo("Address"); + await Assert.That(segments[1]).IsEqualTo(string.Empty); + await Assert.That(segments[2]).IsEqualTo("Street"); + await Assert.That(propertyPath[1].ToString()).IsEqualTo(string.Empty); + } + + [Test] + public async Task I_can_treat_an_empty_path_as_having_no_segments() + { + var propertyPath = (PropertyPath)""; + + await Assert.That(propertyPath.Count).IsEqualTo(0); + await Assert.That(propertyPath.IsEmpty).IsTrue(); + // ReSharper disable once RedundantCast + await Assert.That(((IReadOnlyList)propertyPath).Count).IsEqualTo(0); + } + + [Test] + public async Task I_can_access_segments_through_the_explicit_read_only_list_interface() + { + var propertyPath = (PropertyPath)"Members[1].Name"; + var segments = (IReadOnlyList)propertyPath; + + await Assert.That(segments.Count).IsEqualTo(2); + await Assert.That(segments[0]).IsEqualTo("Members[1]"); + await Assert.That(segments[1]).IsEqualTo("Name"); + await Assert.That(string.Join("|", segments)).IsEqualTo("Members[1]|Name"); + } + + [Test] + public async Task I_can_see_it_throw_for_out_of_range_segment_access() + { + var propertyPath = (PropertyPath)"Name"; + + await Assert.That(() => propertyPath[1].ToString()) + .Throws(); + } + + [Test] + public async Task I_can_compare_a_property_path_to_a_string() + { + var propertyPath = (PropertyPath)"Address.Street"; + + using (Assert.Multiple()) + { + await Assert.That(propertyPath.Equals("Address.Street")).IsTrue(); + await Assert.That(propertyPath.Equals("Address.Street")).IsTrue(); + await Assert.That(propertyPath.Equals("address.street")).IsFalse(); + await Assert.That(propertyPath.Equals((string?)null)).IsFalse(); + } + } + + [Test] + public async Task I_can_compare_a_property_path_to_a_string_through_object_equality() + { + var propertyPath = (PropertyPath)"Address.Street"; + + using (Assert.Multiple()) + { + // ReSharper disable once SuspiciousTypeConversion.Global + await Assert.That(propertyPath.Equals((object?)"Address.Street")).IsTrue(); + // ReSharper disable once SuspiciousTypeConversion.Global + await Assert.That(propertyPath.Equals((object?)"address.street")).IsFalse(); + // ReSharper disable once SuspiciousTypeConversion.Global + await Assert.That(propertyPath.Equals((object?)null)).IsFalse(); + } + } + + [Test] + public async Task I_can_combine_property_paths_with_the_plus_operator() + { + var propertyPath = "Address" + (PropertyPath)"Street"; + + await Assert.That(propertyPath.ToString()).IsEqualTo("Address.Street"); + } + + [Test] + public async Task I_can_combine_property_paths_with_the_plus_operator_when_one_side_is_empty() + { + var leftEmpty = "" + (PropertyPath)"Street"; + var rightEmpty = (PropertyPath)"Address" + ""; + + await Assert.That(leftEmpty.ToString()).IsEqualTo("Street"); + await Assert.That(rightEmpty.ToString()).IsEqualTo("Address"); + } + + [Test] + public async Task I_can_append_a_collection_index_with_the_plus_operator() + { + var propertyPath = (PropertyPath)"Members" + 1; + + await Assert.That(propertyPath.ToString()).IsEqualTo("Members[1]"); + } + + [Test] + public async Task I_can_append_a_collection_index_with_the_plus_operator_when_the_property_name_is_empty() + { + var propertyPath = (PropertyPath)"" + 1; + + await Assert.That(propertyPath.ToString()).IsEqualTo("[1]"); + } + + [Test] + public async Task I_can_create_a_property_path_from_an_expression() + { + var path = PropertyPath.Of(p => p.Name); + await Assert.That(path.ToString()).IsEqualTo("Name"); + } + + [Test] + public async Task I_can_create_a_nested_property_path_from_an_expression() + { + var path = PropertyPath.Of(p => p.Address!.Street); + await Assert.That(path.ToString()).IsEqualTo("Address.Street"); + } + + [Test] + public async Task I_can_create_a_property_path_with_a_list_indexer_from_an_expression() + { + var i = 5; + var path = PropertyPath.Of(c => c.People![i].Name); + await Assert.That(path.ToString()).IsEqualTo("People[5].Name"); + } + + [Test] + public async Task I_can_create_a_property_path_with_an_array_indexer_from_an_expression() + { + var i = 2; + var path = PropertyPath.Of(c => c.PeopleArray![i].Name); + await Assert.That(path.ToString()).IsEqualTo("PeopleArray[2].Name"); + } + + [Test] + public async Task I_can_create_a_property_path_with_a_constant_indexer_from_an_expression() + { + var path = PropertyPath.Of(c => c.People![0].Name); + await Assert.That(path.ToString()).IsEqualTo("People[0].Name"); + } + + [Test] + public async Task I_can_create_a_property_path_with_nested_indexers_from_an_expression() + { + var path = PropertyPath.Of(c => c.Matrix![1][2]); + await Assert.That(path.ToString()).IsEqualTo("Matrix[1][2]"); + } + + private sealed class Container + { + public List? People { get; set; } + public Person[]? PeopleArray { get; set; } + public List>? Matrix { get; set; } + } + + private sealed class Person + { + public string? Name { get; set; } + } + + private sealed class PersonWithAddress + { + public Address? Address { get; set; } + } + + private sealed class Address + { + public string? Street { get; set; } + } +} diff --git a/src/request.validation.tests/RuleBuilderExtensionsTests.cs b/src/request.validation.tests/RuleBuilderExtensionsTests.cs new file mode 100644 index 0000000..5b5271a --- /dev/null +++ b/src/request.validation.tests/RuleBuilderExtensionsTests.cs @@ -0,0 +1,337 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Text.RegularExpressions; + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class RuleBuilderExtensionsTests +{ + [Test] + public async Task I_can_validate_not_null_for_reference_types() + { + var validator = new PropertyValidator(model => + model.Value, rule => rule.NotNull()); + + var invalid = validator.Validate(new ReferenceValueModel()); + var valid = validator.Validate(new ReferenceValueModel { Value = new object() }); + + await AssertSingleProblem(invalid, nameof(ReferenceValueModel.Value), "Value is required."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_not_null_for_nullable_value_types() + { + var validator = new PropertyValidator(model => + model.Value, rule => rule.NotNull()); + + var invalid = validator.Validate(new NullableIntValueModel()); + var valid = validator.Validate(new NullableIntValueModel { Value = 1 }); + + await AssertSingleProblem(invalid, nameof(NullableIntValueModel.Value), "Value is required."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_not_empty_for_strings() + { + var validator = new PropertyValidator(model => + model.Value, rule => rule.NotEmpty()); + + var invalid = validator.Validate(new StringValueModel { Value = " " }); + var valid = validator.Validate(new StringValueModel { Value = "abc" }); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is required."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_remove_the_property_path() + { + var validator = new PropertyValidator(model => + model.Value, rule => rule.WithoutPropertyPath().NotEmpty()); + + var result = validator.Validate(new StringValueModel { Value = " " }); + + await AssertSingleProblem(result, string.Empty, "Value is required."); + } + + [Test] + public async Task I_can_validate_not_empty_for_collections() + { + var validator = new PropertyValidator?>(model => + model.Value, rule => rule.NotEmpty()); + + var invalid = validator.Validate(new CollectionValueModel { Value = [] }); + var valid = validator.Validate(new CollectionValueModel { Value = ["item"] }); + + await AssertSingleProblem(invalid, nameof(CollectionValueModel.Value), "Value is required."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_min_length() + { + var validator = + new PropertyValidator(model => + model.Value, rule => rule.MinLength(3)); + + var invalid = validator.Validate(new StringValueModel { Value = "ab" }); + var valid = validator.Validate(new StringValueModel { Value = "abc" }); + var ignoredNull = validator.Validate(new StringValueModel()); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be at least 3 characters long."); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_max_length() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.MaxLength(5)); + + var invalid = validator.Validate(new StringValueModel { Value = "abcdef" }); + var valid = validator.Validate(new StringValueModel { Value = "abcde" }); + var ignoredNull = validator.Validate(new StringValueModel()); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be at most 5 characters long."); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_length() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.Length(2, 4)); + + var invalid = validator.Validate(new StringValueModel { Value = "a" }); + var valid = validator.Validate(new StringValueModel { Value = "ab" }); + var ignoredNull = validator.Validate(new StringValueModel()); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), + "Value must be between 2 and 4 characters long."); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_greater_than() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.GreaterThan(18)); + + var invalid = validator.Validate(new IntValueModel { Value = 18 }); + var valid = validator.Validate(new IntValueModel { Value = 19 }); + + await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be greater than 18."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_greater_than_or_equal_to() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.GreaterThanOrEqualTo(1)); + + var invalid = validator.Validate(new IntValueModel { Value = 0 }); + var valid = validator.Validate(new IntValueModel { Value = 1 }); + + await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be greater than or equal to 1."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_less_than() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.LessThan(10)); + + var invalid = validator.Validate(new IntValueModel { Value = 10 }); + var valid = validator.Validate(new IntValueModel { Value = 9 }); + + await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be less than 10."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_less_than_or_equal_to() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.LessThanOrEqualTo(5)); + + var invalid = validator.Validate(new IntValueModel { Value = 6 }); + var valid = validator.Validate(new IntValueModel { Value = 5 }); + + await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be less than or equal to 5."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_between() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.Between(0, 100)); + + var invalid = validator.Validate(new IntValueModel { Value = 101 }); + var valid = validator.Validate(new IntValueModel { Value = 100 }); + + await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be between 0 and 100."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_equal() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.Equal("ACTIVE")); + + var invalid = validator.Validate(new StringValueModel { Value = "INACTIVE" }); + var valid = validator.Validate(new StringValueModel { Value = "ACTIVE" }); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be equal to ACTIVE."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_not_equal() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.NotEqual("BANNED")); + + var invalid = validator.Validate(new StringValueModel { Value = "BANNED" }); + var valid = validator.Validate(new StringValueModel { Value = "ALLOWED" }); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must not be equal to BANNED."); + await Assert.That(valid.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_equal_with_null_reference_values() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.Equal("ACTIVE")); + + var invalid = validator.Validate(new StringValueModel { Value = "INACTIVE" }); + var valid = validator.Validate(new StringValueModel { Value = "ACTIVE" }); + var nullValue = validator.Validate(new StringValueModel()); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be equal to ACTIVE."); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(nullValue.IsValid).IsFalse(); + } + + [Test] + public async Task I_can_validate_not_equal_with_null_reference_values() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.NotEqual("BANNED")); + + var invalid = validator.Validate(new StringValueModel { Value = "BANNED" }); + var valid = validator.Validate(new StringValueModel { Value = "ALLOWED" }); + var nullValue = validator.Validate(new StringValueModel()); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must not be equal to BANNED."); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(nullValue.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_skip_null_values_for_reference_greater_than() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.GreaterThan("B")); + + var invalid = validator.Validate(new StringValueModel { Value = "A" }); + var valid = validator.Validate(new StringValueModel { Value = "C" }); + var ignoredNull = validator.Validate(new StringValueModel()); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be greater than B."); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_skip_null_values_for_reference_between() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.Between("A", "C")); + + var invalid = validator.Validate(new StringValueModel { Value = "D" }); + var valid = validator.Validate(new StringValueModel { Value = "C" }); + var ignoredNull = validator.Validate(new StringValueModel()); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be between A and C."); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_matches_with_a_pattern() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.Matches("^[A-Z]+$")); + + var invalid = validator.Validate(new StringValueModel { Value = "Abc" }); + var valid = validator.Validate(new StringValueModel { Value = "ABC" }); + var ignoredNull = validator.Validate(new StringValueModel()); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is not in the correct format."); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_validate_matches_with_a_regex() + { + var validator = new PropertyValidator(model + => model.Value, rule => rule.Matches(new Regex(@"^\d{4}$", RegexOptions.CultureInvariant))); + + var invalid = validator.Validate(new StringValueModel { Value = "12AB" }); + var valid = validator.Validate(new StringValueModel { Value = "1234" }); + var ignoredNull = validator.Validate(new StringValueModel()); + + await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is not in the correct format."); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(ignoredNull.IsValid).IsTrue(); + } + + [Test] + public async Task I_can_see_it_throw_for_invalid_length_configuration() + { + await Assert.That(() => new PropertyValidator(model + => model.Value, rule => rule.Length(5, 4))) + .Throws(); + } + + [Test] + public async Task I_can_see_it_throw_for_negative_min_length_configuration() + { + await Assert.That(() => new PropertyValidator(model + => model.Value, rule => rule.MinLength(-1))) + .Throws(); + } + + [Test] + public async Task I_can_see_it_throw_for_invalid_between_configuration() + { + await Assert.That(() => new PropertyValidator(model + => model.Value, rule => rule.Between(10, 0))) + .Throws(); + } + + private static async Task AssertSingleProblem(Validation validation, string propertyPath, string message) + { + await Assert.That(validation.Problems).Count().IsEqualTo(1); + + var problem = validation.Problems.Single(); + + using (Assert.Multiple()) + { + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo(propertyPath); + await Assert.That(problem.Message).IsEqualTo(message); + } + } +} diff --git a/src/request.validation.tests/ValidatorTests.cs b/src/request.validation.tests/ValidatorTests.cs new file mode 100644 index 0000000..14239f7 --- /dev/null +++ b/src/request.validation.tests/ValidatorTests.cs @@ -0,0 +1,349 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using Microsoft.Extensions.DependencyInjection; + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ValidatorTests +{ + [Test] + public async Task I_can_validate_a_property_rule_with_metadata() + { + var validator = CreatePersonValidator(); + + var result = validator.Validate(new Person { Name = "" }); + + await Assert.That(result.IsValid).IsFalse(); + await Assert.That(result.Problems).Count().IsEqualTo(1); + + var problem = result.Problems.Single(); + + using (Assert.Multiple()) + { + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo(nameof(Person.Name)); + await Assert.That(problem.Message).IsEqualTo("Name is required."); + await Assert.That(problem.Code).IsEqualTo("NAME_REQUIRED"); + await Assert.That(problem.Severity).IsEqualTo(Severity.Warning); + await Assert.That(problem.AttemptedValue).IsEqualTo(""); + } + } + + [Test] + public async Task I_can_validate_all_rules_on_every_call() + { + var validator = CreatePersonValidator(); + + var invalid = validator.Validate(new Person { Name = "" }); + var valid = validator.Validate(new Person { Name = "Louis" }); + + using (Assert.Multiple()) + { + await Assert.That(invalid.IsValid).IsFalse(); + await Assert.That(valid.IsValid).IsTrue(); + await Assert.That(valid.Problems).IsEmpty(); + } + } + + [Test] + public async Task I_can_compose_nested_validators_and_aggregate_property_paths() + { + var validator = new PropertyValidator( + person => person.Address, + rule => rule.SetValidator(CreateAddressValidator())); + + var result = validator.Validate(new PersonWithAddress + { + Address = new Address { Street = "" }, + }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Address.Street"); + } + + [Test] + public async Task I_can_remove_the_parent_property_path_for_nested_validators() + { + var validator = new PropertyValidator( + person => person.Address, + rule => rule.WithoutPropertyPath().SetValidator(CreateAddressValidator())); + + var result = validator.Validate(new PersonWithAddress + { + Address = new Address { Street = "" }, + }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Street"); + } + + [Test] + public async Task I_can_skip_null_nested_values_for_composed_validators() + { + var validator = new PropertyValidator( + person => person.Address, + rule => rule.SetValidator(CreateAddressValidator())); + + var result = validator.Validate(new PersonWithAddress()); + + await Assert.That(result.IsValid).IsTrue(); + await Assert.That(result.Problems).IsEmpty(); + } + + [Test] + public async Task I_can_validate_each_collection_element_and_include_the_index() + { + var validator = new CollectionValidator( + team => team.Members, + rule => rule.SetValidator(CreateMemberValidator())); + + var result = validator.Validate(new Team + { + Members = [new Member { Name = "Ada" }, new Member { Name = "" }], + }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Members[1].Name"); + } + + [Test] + public async Task I_can_transform_the_collection_property_path_before_appending_indices() + { + var validator = new CollectionValidator( + team => team.Members, + rule => rule.WithPropertyPath(static _ => "People").SetValidator(CreateMemberValidator())); + + var result = validator.Validate(new Team + { + Members = [new Member { Name = "Ada" }, new Member { Name = "" }], + }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("People[1].Name"); + } + + [Test] + public async Task I_can_build_property_paths_for_nested_member_access() + { + var validator = new PropertyValidator( + person => person.Address!.Street, + rule => rule.NotEmpty()); + + var result = validator.Validate(new PersonWithAddress { Address = new Address { Street = "" } }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + + var problem = result.Problems.Single(); + + using (Assert.Multiple()) + { + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo("Address.Street"); + await Assert.That(problem.Message).IsEqualTo("Value is required."); + } + } + + [Test] + public async Task I_can_build_property_paths_for_converted_expressions() + { + var validator = new PropertyValidator( + person => person.Name, + rule => rule.NotNull()); + + var result = validator.Validate(new Person()); + await Assert.That(result.Problems).Count().IsEqualTo(1); + + var problem = result.Problems.Single(); + + using (Assert.Multiple()) + { + await Assert.That(problem.PropertyPath.ToString()).IsEqualTo(nameof(Person.Name)); + await Assert.That(problem.Message).IsEqualTo("Value is required."); + } + } + + [Test] + public async Task I_can_transform_a_property_rule_path() + { + var validator = new PropertyValidator( + person => person.Name, + rule => rule.WithPropertyPath(static _ => "Username").NotEmpty()); + + var result = validator.Validate(new Person { Name = "" }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Username"); + } + + [Test] + public async Task I_can_compose_parent_and_child_property_path_transforms() + { + var validator = new PropertyValidator( + person => person.Address, + rule => rule.WithPropertyPath(static _ => "Location") + .SetValidator(CreateRenamedAddressValidator())); + + var result = validator.Validate(new PersonWithAddress + { + Address = new Address { Street = "" }, + }); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Location.Road"); + } + + [Test] + public async Task I_can_see_it_throw_for_non_member_expressions() + { + await Assert.That(() => new PropertyValidator( + person => person.Name!.Trim().Length, + rule => rule.GreaterThan(0))) + .Throws(); + } + + [Test] + public async Task I_can_aggregate_all_failures_in_rule_order() + { + var validator = new OrderedFailuresValidator(); + + var result = validator.Validate(new StringValueModel()); + + await Assert.That(result.Problems).Count().IsEqualTo(3); + await Assert.That(result.Problems[0].Message).IsEqualTo("First failure."); + await Assert.That(result.Problems[1].Message).IsEqualTo("Second failure."); + await Assert.That(result.Problems[2].Message).IsEqualTo("Third failure."); + } + + [Test] + public async Task I_can_apply_default_problem_metadata() + { + var validator = new PropertyValidator( + model => model.Value, + rule => rule.NotEmpty()); + + var result = validator.Validate(new StringValueModel { Value = " " }); + var problem = result.Problems.Single(); + + using (Assert.Multiple()) + { + await Assert.That(problem.Code).IsNull(); + await Assert.That(problem.Severity).IsEqualTo(Severity.Error); + await Assert.That(problem.AttemptedValue).IsEqualTo(" "); + } + } + + [Test] + public async Task I_can_skip_null_collections_for_collection_validators() + { + var validator = new CollectionValidator( + team => team.Members!, + rule => rule.SetValidator(CreateMemberValidator())); + + var result = validator.Validate(new NullableTeam()); + + await Assert.That(result.IsValid).IsTrue(); + await Assert.That(result.Problems).IsEmpty(); + } + + [Test] + public async Task I_can_skip_empty_collections_for_collection_validators() + { + var validator = new CollectionValidator( + team => team.Members, + rule => rule.SetValidator(CreateMemberValidator())); + + var result = validator.Validate(new Team()); + + await Assert.That(result.IsValid).IsTrue(); + await Assert.That(result.Problems).IsEmpty(); + } + + [Test] + public async Task I_can_aggregate_problems_for_multiple_invalid_collection_elements() + { + var validator = new CollectionValidator( + team => team.Members, + rule => rule.SetValidator(CreateMemberValidator())); + + var result = validator.Validate(new Team + { + Members = [new Member { Name = "" }, new Member { Name = "Ada" }, new Member { Name = "" }], + }); + + await Assert.That(result.Problems).Count().IsEqualTo(2); + await Assert.That(result.Problems[0].PropertyPath.ToString()).IsEqualTo("Members[0].Name"); + await Assert.That(result.Problems[1].PropertyPath.ToString()).IsEqualTo("Members[2].Name"); + } + + [Test] + public async Task I_can_resolve_nested_validators_from_the_context_service_provider() + { + var addressValidator = CreateAddressValidator(); + var services = new ServiceCollection() + .AddSingleton(addressValidator) + .BuildServiceProvider(); + + var validator = new PropertyValidator( + person => person.Address, + rule => rule.SetValidator>()); + var context = new ValidationContext( + new PersonWithAddress { Address = new Address { Street = "" } }, + services); + + var result = validator.Validate(context); + + await Assert.That(result.Problems).Count().IsEqualTo(1); + await Assert.That(result.Problems.Single().PropertyPath.ToString()).IsEqualTo("Address.Street"); + } + + [Test] + public async Task I_can_see_it_throw_for_type_mismatch() + { + var validator = CreatePersonValidator(); + var context = new ValidationContext(new object()); + + await Assert.That(() => validator.Validate(context)).Throws(); + } + + [Test] + public async Task I_can_see_it_throw_when_a_context_resolved_validator_cannot_be_resolved() + { + var validator = new PropertyValidator( + person => person.Address, + rule => rule.SetValidator>()); + var context = new ValidationContext( + new PersonWithAddress { Address = new Address { Street = "" } }); + + await Assert.That(() => validator.Validate(context)).Throws(); + } + + private static PropertyValidator CreatePersonValidator() + { + return new PropertyValidator( + person => person.Name, + rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Name is required.") + .WithCode("NAME_REQUIRED") + .WithSeverity(Severity.Warning)); + } + + private static PropertyValidator CreateAddressValidator() + { + return new PropertyValidator( + address => address.Street, + rule => rule.Must(street => !string.IsNullOrWhiteSpace(street), "Street is required.")); + } + + private static PropertyValidator CreateMemberValidator() + { + return new PropertyValidator( + member => member.Name, + rule => rule.Must(name => !string.IsNullOrWhiteSpace(name), "Member name is required.")); + } + + private static PropertyValidator CreateRenamedAddressValidator() + { + return new PropertyValidator( + address => address.Street, + rule => rule.WithPropertyPath(static _ => "Road") + .Must(street => !string.IsNullOrWhiteSpace(street), "Street is required.")); + } +} diff --git a/src/request.validation.tests/_fixtures/Address.cs b/src/request.validation.tests/_fixtures/Address.cs new file mode 100644 index 0000000..479288b --- /dev/null +++ b/src/request.validation.tests/_fixtures/Address.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class Address +{ + public string? Street { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/CollectionValidator.cs b/src/request.validation.tests/_fixtures/CollectionValidator.cs new file mode 100644 index 0000000..e916896 --- /dev/null +++ b/src/request.validation.tests/_fixtures/CollectionValidator.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class CollectionValidator : Validator +{ + public CollectionValidator( + Expression>> expression, + Action> configure) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(configure); + + configure(RuleForEach(expression)); + } +} diff --git a/src/request.validation.tests/_fixtures/CollectionValueModel.cs b/src/request.validation.tests/_fixtures/CollectionValueModel.cs new file mode 100644 index 0000000..66fab07 --- /dev/null +++ b/src/request.validation.tests/_fixtures/CollectionValueModel.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class CollectionValueModel +{ + public IEnumerable? Value { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/IntValueModel.cs b/src/request.validation.tests/_fixtures/IntValueModel.cs new file mode 100644 index 0000000..afea197 --- /dev/null +++ b/src/request.validation.tests/_fixtures/IntValueModel.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class IntValueModel +{ + public int Value { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/Member.cs b/src/request.validation.tests/_fixtures/Member.cs new file mode 100644 index 0000000..53aa467 --- /dev/null +++ b/src/request.validation.tests/_fixtures/Member.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class Member +{ + public string? Name { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/NullableIntValueModel.cs b/src/request.validation.tests/_fixtures/NullableIntValueModel.cs new file mode 100644 index 0000000..2f9ad23 --- /dev/null +++ b/src/request.validation.tests/_fixtures/NullableIntValueModel.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class NullableIntValueModel +{ + public int? Value { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/NullableTeam.cs b/src/request.validation.tests/_fixtures/NullableTeam.cs new file mode 100644 index 0000000..91c1142 --- /dev/null +++ b/src/request.validation.tests/_fixtures/NullableTeam.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class NullableTeam +{ + public IEnumerable? Members { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/OrderedFailuresValidator.cs b/src/request.validation.tests/_fixtures/OrderedFailuresValidator.cs new file mode 100644 index 0000000..7751c4e --- /dev/null +++ b/src/request.validation.tests/_fixtures/OrderedFailuresValidator.cs @@ -0,0 +1,17 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class OrderedFailuresValidator : Validator +{ + public OrderedFailuresValidator() + { + RuleFor(model => model.Value) + .Must(_ => false, "First failure.") + .Must(_ => false, "Second failure."); + + RuleFor(model => model.Value) + .Must(_ => false, "Third failure."); + } +} diff --git a/src/request.validation.tests/_fixtures/Person.cs b/src/request.validation.tests/_fixtures/Person.cs new file mode 100644 index 0000000..7edb444 --- /dev/null +++ b/src/request.validation.tests/_fixtures/Person.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class Person +{ + public string? Name { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/PersonWithAddress.cs b/src/request.validation.tests/_fixtures/PersonWithAddress.cs new file mode 100644 index 0000000..0903b68 --- /dev/null +++ b/src/request.validation.tests/_fixtures/PersonWithAddress.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class PersonWithAddress +{ + public Address? Address { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/PropertyValidator.cs b/src/request.validation.tests/_fixtures/PropertyValidator.cs new file mode 100644 index 0000000..4c03576 --- /dev/null +++ b/src/request.validation.tests/_fixtures/PropertyValidator.cs @@ -0,0 +1,19 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class PropertyValidator : Validator +{ + public PropertyValidator( + Expression> expression, + Action> configure) + { + ArgumentNullException.ThrowIfNull(expression); + ArgumentNullException.ThrowIfNull(configure); + + configure(RuleFor(expression)); + } +} diff --git a/src/request.validation.tests/_fixtures/ReferenceValueModel.cs b/src/request.validation.tests/_fixtures/ReferenceValueModel.cs new file mode 100644 index 0000000..72996d6 --- /dev/null +++ b/src/request.validation.tests/_fixtures/ReferenceValueModel.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class ReferenceValueModel +{ + public object? Value { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/StringValueModel.cs b/src/request.validation.tests/_fixtures/StringValueModel.cs new file mode 100644 index 0000000..232dd67 --- /dev/null +++ b/src/request.validation.tests/_fixtures/StringValueModel.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class StringValueModel +{ + public string? Value { get; init; } +} diff --git a/src/request.validation.tests/_fixtures/Team.cs b/src/request.validation.tests/_fixtures/Team.cs new file mode 100644 index 0000000..6b17c01 --- /dev/null +++ b/src/request.validation.tests/_fixtures/Team.cs @@ -0,0 +1,9 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation.Tests; + +internal sealed class Team +{ + public IEnumerable Members { get; init; } = []; +} diff --git a/src/request.validation/Geekeey.Request.Validation.csproj b/src/request.validation/Geekeey.Request.Validation.csproj new file mode 100644 index 0000000..098287f --- /dev/null +++ b/src/request.validation/Geekeey.Request.Validation.csproj @@ -0,0 +1,31 @@ + + + + Library + net10.0 + true + + + + true + + + + + + + + package-readme.md + Lightweight validation library for C# with composable validators, fluent rules, and structured validation results. + package-icon.png + https://code.geekeey.de/geekeey/request/src/branch/main/src/request.validation + EUPL-1.2 + + + + + + + + + diff --git a/src/request.validation/IPropertyRuleBuilder.cs b/src/request.validation/IPropertyRuleBuilder.cs new file mode 100644 index 0000000..d9fa539 --- /dev/null +++ b/src/request.validation/IPropertyRuleBuilder.cs @@ -0,0 +1,55 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Represents a builder for defining a validation rule for a specific property of a type. +/// +/// The type of the object to be validated. +/// The type of the property to validate. +public interface IPropertyRuleBuilder +{ + /// + /// Defines a validation rule that must be met for the property to be considered valid. + /// + /// The predicate function that determines if the property value is valid. + /// The error message to be returned if the validation fails. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder Must(Func predicate, string message); + + /// + /// Sets the validator to be used for validating the property value. + /// + /// The validator instance to use for validation. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder SetValidator(IValidator validator); + + /// + /// Sets the validator to be used for validating the property value. + /// + /// The type of the validator to use for validation. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder SetValidator() where TValidator : IValidator; + + /// + /// Sets the error code for the validation rule. + /// + /// The error code to be associated with the validation rule. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder WithCode(string code); + + /// + /// Transforms the property path reported by the validation rule. + /// + /// The function used to transform the rule property path. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder WithPropertyPath(Func transform); + + /// + /// Sets the severity of the validation rule. + /// + /// The severity level of the validation rule. + /// The current rule builder instance for method chaining. + IPropertyRuleBuilder WithSeverity(Severity severity); +} diff --git a/src/request.validation/IValidator.cs b/src/request.validation/IValidator.cs new file mode 100644 index 0000000..b468aba --- /dev/null +++ b/src/request.validation/IValidator.cs @@ -0,0 +1,31 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Defines a validator for a particular type. +/// +public interface IValidator +{ + /// + /// Performs validation on the provided and returns the result. + /// + /// The validation context containing the instance to validate, service provider, and additional items. + /// A object containing the results of the validation, including any problems encountered. + Validation Validate(ValidationContext context); +} + +/// +/// Defines a validator for a particular type. +/// +/// The type of the instance to validate. +public interface IValidator : IValidator +{ + /// + /// Executes the validation logic for a specified instance of type and returns the validation result. + /// + /// The instance of type to validate. + /// A object containing the results of the validation, including any problems encountered. + Validation Validate(T instance); +} diff --git a/src/request.validation/Problem.cs b/src/request.validation/Problem.cs new file mode 100644 index 0000000..e192036 --- /dev/null +++ b/src/request.validation/Problem.cs @@ -0,0 +1,45 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Represents a validation problem identified during a request validation process. +/// +public record Problem +{ + /// + /// The name of the property. + /// + public required PropertyPath PropertyPath { get; init; } + + /// + /// Custom severity level associated with the failure. + /// + public Severity Severity { get; init; } = Severity.Error; + + /// + /// The error message + /// + public required string Message { get; init; } + + /// + /// Gets or sets the error code. + /// + public string? Code { get; init; } + + /// + /// The property value that caused the failure. + /// + public object? AttemptedValue { get; init; } + + /// + /// Starts building a new problem with the specified message. + /// + /// The error message. + /// A builder for the problem. + public static ProblemBuilder Create(string message) + { + return new ProblemBuilder(message); + } +} diff --git a/src/request.validation/ProblemBuilder.cs b/src/request.validation/ProblemBuilder.cs new file mode 100644 index 0000000..2d4fad8 --- /dev/null +++ b/src/request.validation/ProblemBuilder.cs @@ -0,0 +1,94 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// A builder for creating instances. +/// +public sealed class ProblemBuilder +{ + private string? _code; + private Severity _severity = Severity.Error; + private PropertyPath _propertyPath; + private object? _attemptedValue; + private readonly string _message; + + /// + /// A builder for creating instances. + /// + /// The error message. + public ProblemBuilder(string message) + { + _message = message; + } + + /// + /// Sets the error code. + /// + /// The error code. + /// The builder instance. + public ProblemBuilder WithCode(string code) + { + _code = code; + return this; + } + + /// + /// Sets the severity. + /// + /// The severity. + /// The builder instance. + public ProblemBuilder WithSeverity(Severity severity) + { + _severity = severity; + return this; + } + + /// + /// Sets the property path. + /// + /// The property path. + /// The builder instance. + public ProblemBuilder WithPropertyPath(PropertyPath propertyPath) + { + _propertyPath = propertyPath; + return this; + } + + /// + /// Sets the attempted value. + /// + /// The attempted value. + /// The builder instance. + public ProblemBuilder WithAttemptedValue(object? value) + { + _attemptedValue = value; + return this; + } + + /// + /// Builds the instance. + /// + /// A new . + public Problem Build() + { + return new Problem + { + Message = _message, + Code = _code, + Severity = _severity, + PropertyPath = _propertyPath, + AttemptedValue = _attemptedValue + }; + } + + /// + /// Implicitly converts the builder to a . + /// + /// The builder to convert. + public static implicit operator Problem(ProblemBuilder builder) + { + return builder.Build(); + } +} diff --git a/src/request.validation/PropertyPath.cs b/src/request.validation/PropertyPath.cs new file mode 100644 index 0000000..4bf0e62 --- /dev/null +++ b/src/request.validation/PropertyPath.cs @@ -0,0 +1,407 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Collections; +using System.Linq.Expressions; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Geekeey.Request.Validation; + +/// +/// Represents a property path used by validation problems. +/// +[JsonConverter(typeof(PropertyPathJsonConverter))] +public readonly struct PropertyPath : IEquatable, IEquatable, IReadOnlyList +{ + /// + /// Creates a new property path. + /// + public PropertyPath(string value) + { + ArgumentNullException.ThrowIfNull(value); + Value = value; + } + + /// + /// Creates a property path from a lambda expression. + /// + /// The type containing the property. + /// The expression representing the property access. + /// A new . + public static PropertyPath Of(Expression> expression) + { + ArgumentNullException.ThrowIfNull(expression); + return FromExpression(expression); + } + + internal static PropertyPath FromExpression(Expression expression) + { + var segments = new List(); + + var current = expression; + while (current is not null) + { + switch (current) + { + case LambdaExpression expr: + current = expr.Body; + break; + case MemberExpression expr: + segments.Add(expr.Member.Name); + current = expr.Expression; + break; + case MethodCallExpression { Method.Name: "get_Item", Arguments.Count: 1 } expr: + segments.Add($"[{Evaluate(expr.Arguments[0])}]"); + current = expr.Object; + break; + case BinaryExpression { NodeType: ExpressionType.ArrayIndex } expr: + segments.Add($"[{Evaluate(expr.Right)}]"); + current = expr.Left; + break; + case UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } expr: + current = expr.Operand; + break; + case ParameterExpression: + current = null; + break; + default: + throw new InvalidOperationException("Only simple member access expressions are supported."); + } + } + + var builder = new StringBuilder(); + for (var i = segments.Count - 1; i >= 0; i--) + { + var segment = segments[i]; + if (builder.Length > 0 && !segment.StartsWith('[')) + { + builder.Append('.'); + } + + builder.Append(segment); + } + + return builder.ToString(); + } + + private static object? Evaluate(Expression expression) + { + if (expression is ConstantExpression constant) + { + return constant.Value; + } + + if (expression is MemberExpression member && member.Expression is ConstantExpression closure) + { + var value = closure.Value; + if (member.Member is System.Reflection.FieldInfo field) + { + return field.GetValue(value); + } + + if (member.Member is System.Reflection.PropertyInfo prop) + { + return prop.GetValue(value); + } + } + + return Expression.Lambda(expression).Compile().DynamicInvoke(); + } + + /// + /// Gets the raw property path. + /// + public string Value { get; } = string.Empty; + + /// + /// Gets the number of path segments. + /// + public int Count + { + get + { + var count = 0; + var enumerator = new Enumerator(Value); + while (enumerator.MoveNext()) + { + count++; + } + + return count; + } + } + + /// + /// Gets whether the property path contains no segments. + /// + public bool IsEmpty => Value.Length is 0; + + /// + /// Gets the segment at the specified index. + /// + public ReadOnlySpan this[int index] + { + get + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + + var currentIndex = 0; + var enumerator = new Enumerator(Value); + while (enumerator.MoveNext()) + { + if (currentIndex == index) + { + return enumerator.Current; + } + + currentIndex++; + } + + throw new ArgumentOutOfRangeException(nameof(index), index, "The segment index must refer to an existing property path segment."); + } + } + + /// + public override string ToString() + { + return Value; + } + + /// + /// Converts a string into a property path. + /// + public static implicit operator PropertyPath(string value) + { + return new PropertyPath(value); + } + + /// + /// Converts a property path into its string representation. + /// + public static implicit operator string(PropertyPath propertyPath) + { + return propertyPath.Value; + } + + /// + public bool Equals(PropertyPath other) + { + return string.Equals(Value, other.Value, StringComparison.Ordinal); + } + + /// + public bool Equals(string? other) + { + return string.Equals(Value, other, StringComparison.Ordinal); + } + + /// + public override bool Equals(object? obj) + { + return (obj is PropertyPath other && Equals(other)) || (obj is string s && Equals(s)); + } + + /// + public override int GetHashCode() + { + return StringComparer.Ordinal.GetHashCode(Value ?? string.Empty); + } + + /// + /// Compares two property paths for ordinal equality. + /// + public static bool operator ==(PropertyPath left, PropertyPath right) + { + return left.Equals(right); + } + + /// + /// Compares two property paths for ordinal inequality. + /// + public static bool operator !=(PropertyPath left, PropertyPath right) + { + return !left.Equals(right); + } + + /// + /// Combines two property paths using the same rules as nested validation paths. + /// + public static PropertyPath operator +(PropertyPath prefix, PropertyPath suffix) + { + if (prefix.Value is not { Length: > 0 }) + { + return suffix; + } + + if (suffix.Value is not { Length: > 0 }) + { + return prefix; + } + + return $"{prefix}.{suffix}"; + } + + /// + /// Appends a collection index to a property path. + /// + public static PropertyPath operator +(PropertyPath propertyPath, int index) + { + return propertyPath.Value is { Length: > 0 } ? $"{propertyPath}[{index}]" : $"[{index}]"; + } + + /// + /// Returns an allocation-free enumerator over the path segments. + /// + public Enumerator GetEnumerator() + { + return new Enumerator(Value); + } + + /// + string IReadOnlyList.this[int index] => this[index].ToString(); + + /// + IEnumerator IEnumerable.GetEnumerator() + { + var enumerator = new Enumerator(Value); + + List segments = []; + while (enumerator.MoveNext()) + { + segments.Add(enumerator.Current.ToString()); + } + + return segments.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + var enumerator = new Enumerator(Value); + + List segments = []; + while (enumerator.MoveNext()) + { + segments.Add(enumerator.Current.ToString()); + } + + return segments.GetEnumerator(); + } + + internal string ToJsonName(JsonNamingPolicy? namingPolicy) + { + var value = Value; + if (namingPolicy is null || value.Length is 0) + { + return value; + } + + var builder = new StringBuilder(value.Length); + var first = true; + var enumerator = new Enumerator(value); + while (enumerator.MoveNext()) + { + if (!first) + { + builder.Append('.'); + } + + if (enumerator.Current is { IsEmpty: false } segment) + { + if (segment.IndexOf('[') is >= 0 and var x) + { + builder.Append(namingPolicy.ConvertName(segment[..x].ToString())); + builder.Append(segment[x..]); + } + else + { + builder.Append(namingPolicy.ConvertName(segment.ToString())); + } + } + + first = false; + } + + return builder.ToString(); + } + + /// + /// Enumerates path segments without allocations. + /// + public ref struct Enumerator + { + private readonly ReadOnlySpan _value; + private int _nextStart; + + internal Enumerator(ReadOnlySpan value) + { + _value = value; + _nextStart = value.Length is 0 ? -1 : 0; + Current = default; + } + + /// + /// Gets the current path segment. + /// + public ReadOnlySpan Current { get; private set; } + + /// + /// Advances to the next path segment. + /// + public bool MoveNext() + { + if (_nextStart < 0) + { + return false; + } + + var start = _nextStart; + var bracketDepth = 0; + + for (var i = start; i < _value.Length; i++) + { + switch (_value[i]) + { + case '[': + bracketDepth++; + break; + case ']': + bracketDepth = int.Max(bracketDepth - 1, 0); + break; + case '.' when bracketDepth is 0: + Current = _value[start..i]; + _nextStart = i + 1; + return true; + default: + break; + } + } + + Current = _value[start..]; + _nextStart = -1; + return true; + } + } +} + +internal sealed class PropertyPathJsonConverter : JsonConverter +{ + /// + public override PropertyPath Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.String) + { + return new PropertyPath(reader.GetString()!); + } + + throw new JsonException($"Expected {nameof(JsonTokenType.String)} but got {reader.TokenType}."); + } + + /// + public override void Write(Utf8JsonWriter writer, PropertyPath value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToJsonName(options.PropertyNamingPolicy)); + } +} diff --git a/src/request.validation/Rule.cs b/src/request.validation/Rule.cs new file mode 100644 index 0000000..0c65cc0 --- /dev/null +++ b/src/request.validation/Rule.cs @@ -0,0 +1,119 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Validation; + +internal abstract record Rule +{ + protected Rule(Expression expression) + { + PropertyPath = PropertyPath.FromExpression(expression); + } + + protected PropertyPath GetPropertyPath() + { + return PropertyPathTransform(PropertyPath); + } + + public PropertyPath PropertyPath { get; } + + public Severity Severity { get; init; } = Severity.Error; + + public string? Code { get; init; } + + public Func PropertyPathTransform { get; init; } = static path => path; + + public abstract IEnumerable Validate(ValidationContext context); +} + +internal abstract record Rule : Rule +{ + protected Rule(Expression expression) + : base(expression) + { + } + + public IReadOnlyList> Steps { get; init; } = []; + + public override IEnumerable Validate(ValidationContext context) + { + if (context.Instance is T instance) + { + return Validate(instance, context); + } + + if (context.Instance is null && default(T) is null) + { + return Validate((T)context.Instance!, context); + } + + var actualType = context.Instance?.GetType().FullName ?? "null"; + throw new InvalidOperationException( + $"Expected validation context instance of type '{typeof(T).FullName}', but got '{actualType}'."); + } + + protected abstract IEnumerable Validate(T instance, ValidationContext context); +} + +internal sealed record PropertyRule : Rule +{ + private readonly Func _accessor; + + public PropertyRule(Expression> expression) : base(expression) + { + _accessor = expression.Compile(); + } + + protected override IEnumerable Validate(T instance, ValidationContext context) + { + if (Steps.Count is 0) + { + return []; + } + + var value = _accessor(instance); + var propertyPath = GetPropertyPath(); + return Steps.SelectMany(step => step.Validate(value, context, propertyPath, Code, Severity)); + } +} + +internal sealed record CollectionRule : Rule +{ + private readonly Func> _accessor; + + public CollectionRule(Expression>> expression) : base(expression) + { + _accessor = expression.Compile(); + } + + protected override IEnumerable Validate(T instance, ValidationContext context) + { + if (Steps.Count is 0) + { + yield break; + } + + if (_accessor(instance) is not { } collection) + { + yield break; + } + + var index = 0; + foreach (var element in collection) + { + var propertyPath = GetPropertyPath() + index; + + foreach (var step in Steps) + { + foreach (var problem in step.Validate(element, context, propertyPath, Code, Severity)) + { + yield return problem; + } + } + + index++; + } + } +} diff --git a/src/request.validation/RuleBuilderExtensions.cs b/src/request.validation/RuleBuilderExtensions.cs new file mode 100644 index 0000000..528b387 --- /dev/null +++ b/src/request.validation/RuleBuilderExtensions.cs @@ -0,0 +1,334 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Geekeey.Request.Validation; + +/// +/// Provides built-in validators for common validation scenarios. +/// +public static class RuleBuilderExtensions +{ + private static bool IsNull([NotNullWhen(false)] TProperty? value) + { + object? boxed = value; + return boxed is null; + } + + /// + /// Adds a rule to ensure that the property value is not null for reference types. + /// + /// The type of the object being validated. + /// The type of the property being validated. Must be a nullable reference type. + /// The rule builder to which the condition is applied. + /// The updated rule builder with the not-null condition added. + public static IPropertyRuleBuilder NotNull( + this IPropertyRuleBuilder rule) + where TProperty : class? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(static value => value is not null, "Value is required."); + } + + /// + /// Adds a rule to ensure that the property value is not null for nullable value types. + /// + /// The type of the object being validated. + /// The underlying non-nullable value type of the property being validated. + /// The rule builder to which the condition is applied. + /// The updated rule builder with the not-null condition added. + public static IPropertyRuleBuilder NotNull( + this IPropertyRuleBuilder rule) + where TProperty : struct + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(static value => value.HasValue, "Value is required."); + } + + /// + /// Adds a rule to ensure that the string property value is not null, empty, or whitespace. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The updated rule builder with the not-empty condition added. + public static IPropertyRuleBuilder NotEmpty(this IPropertyRuleBuilder rule) + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(static value => !string.IsNullOrWhiteSpace(value), "Value is required."); + } + + /// + /// Adds a rule to ensure that the collection property value is not null and contains at least one element. + /// + /// The type of the object being validated. + /// The type of the elements in the collection being validated. + /// The rule builder to which the condition is applied. + /// The updated rule builder with the not-empty condition added. + public static IPropertyRuleBuilder?> NotEmpty( + this IPropertyRuleBuilder?> rule) + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(static value => value is not null && value.Any(), "Value is required."); + } + + /// + /// Removes the current rule property path from emitted validation problems. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the property path transform is applied. + /// The updated rule builder with the property path removed. + public static IPropertyRuleBuilder WithoutPropertyPath( + this IPropertyRuleBuilder rule) + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.WithPropertyPath(static _ => string.Empty); + } + + /// + /// Adds a rule to ensure that the string property value meets the specified minimum length. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The minimum allowed number of characters. + /// The updated rule builder with the minimum length condition added. + public static IPropertyRuleBuilder MinLength( + this IPropertyRuleBuilder rule, + int minLength) + { + ArgumentNullException.ThrowIfNull(rule); + ArgumentOutOfRangeException.ThrowIfNegative(minLength); + + return rule.Must(value => IsNull(value) || value.Length >= minLength, + $"Value must be at least {minLength} characters long."); + } + + /// + /// Adds a rule to ensure that the string property value does not exceed the specified maximum length. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The maximum allowed number of characters. + /// The updated rule builder with the maximum length condition added. + public static IPropertyRuleBuilder MaxLength( + this IPropertyRuleBuilder rule, + int maxLength) + { + ArgumentNullException.ThrowIfNull(rule); + ArgumentOutOfRangeException.ThrowIfNegative(maxLength); + + return rule.Must(value => IsNull(value) || value.Length <= maxLength, + $"Value must be at most {maxLength} characters long."); + } + + /// + /// Adds a rule to ensure that the string property value falls within the specified inclusive length range. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The minimum allowed number of characters. + /// The maximum allowed number of characters. + /// The updated rule builder with the length range condition added. + public static IPropertyRuleBuilder Length( + this IPropertyRuleBuilder rule, + int minLength, + int maxLength) + { + ArgumentNullException.ThrowIfNull(rule); + ArgumentOutOfRangeException.ThrowIfNegative(minLength); + ArgumentOutOfRangeException.ThrowIfNegative(maxLength); + + if (maxLength < minLength) + { + throw new ArgumentOutOfRangeException(nameof(maxLength), + "Maximum length must be greater than or equal to minimum length."); + } + + return rule.Must(value => IsNull(value) || (value.Length >= minLength && value.Length <= maxLength), + $"Value must be between {minLength} and {maxLength} characters long."); + } + + /// + /// Adds a rule to ensure that the property value is greater than the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must be greater than. + /// The updated rule builder with the greater-than condition added. + public static IPropertyRuleBuilder GreaterThan( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IComparable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0, + $"Value must be greater than {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value is greater than or equal to the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must be greater than or equal to. + /// The updated rule builder with the greater-than-or-equal condition added. + public static IPropertyRuleBuilder GreaterThanOrEqualTo( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IComparable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0, + $"Value must be greater than or equal to {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value is less than the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must be less than. + /// The updated rule builder with the less-than condition added. + public static IPropertyRuleBuilder LessThan( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IComparable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0, + $"Value must be less than {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value is less than or equal to the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must be less than or equal to. + /// The updated rule builder with the less-than-or-equal condition added. + public static IPropertyRuleBuilder LessThanOrEqualTo( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IComparable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0, + $"Value must be less than or equal to {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value falls within the specified inclusive range. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The minimum allowed value. + /// The maximum allowed value. + /// The updated rule builder with the range condition added. + public static IPropertyRuleBuilder Between( + this IPropertyRuleBuilder rule, + TProperty minValue, + TProperty maxValue) + where TProperty : IComparable? + { + ArgumentNullException.ThrowIfNull(rule); + + if (minValue is not null && maxValue is not null && minValue.CompareTo(maxValue) > 0) + { + throw new ArgumentOutOfRangeException(nameof(maxValue), + "Maximum value must be greater than or equal to minimum value."); + } + + return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0), + $"Value must be between {minValue} and {maxValue}."); + } + + /// + /// Adds a rule to ensure that the property value is equal to the specified comparison value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The required value for the property. + /// The updated rule builder with the equality condition added. + public static IPropertyRuleBuilder Equal( + this IPropertyRuleBuilder rule, + TProperty comparisonValue) + where TProperty : IEquatable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => EqualityComparer.Default.Equals(value, comparisonValue), + $"Value must be equal to {comparisonValue}."); + } + + /// + /// Adds a rule to ensure that the property value is not equal to the specified disallowed value. + /// + /// The type of the object being validated. + /// The type of the property being validated. + /// The rule builder to which the condition is applied. + /// The value that the property must not equal. + /// The updated rule builder with the inequality condition added. + public static IPropertyRuleBuilder NotEqual( + this IPropertyRuleBuilder rule, + TProperty disallowedValue) + where TProperty : IEquatable? + { + ArgumentNullException.ThrowIfNull(rule); + + return rule.Must(value => !EqualityComparer.Default.Equals(value, disallowedValue), + $"Value must not be equal to {disallowedValue}."); + } + + /// + /// Adds a rule to ensure that the string property value matches the specified regular expression pattern. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The regular expression pattern that the property value must match. + /// The updated rule builder with the pattern-matching condition added. + public static IPropertyRuleBuilder Matches( + this IPropertyRuleBuilder rule, + string pattern) + { + ArgumentNullException.ThrowIfNull(rule); + ArgumentException.ThrowIfNullOrEmpty(pattern); + + return rule.Must(value => IsNull(value) || Regex.IsMatch(value, pattern), + "Value is not in the correct format."); + } + + /// + /// Adds a rule to ensure that the string property value matches the specified regular expression. + /// + /// The type of the object being validated. + /// The rule builder to which the condition is applied. + /// The regular expression that the property value must match. + /// The updated rule builder with the pattern-matching condition added. + public static IPropertyRuleBuilder Matches( + this IPropertyRuleBuilder rule, + Regex regex) + { + ArgumentNullException.ThrowIfNull(rule); + ArgumentNullException.ThrowIfNull(regex); + + return rule.Must(value => IsNull(value) || regex.IsMatch(value), + "Value is not in the correct format."); + } +} diff --git a/src/request.validation/RuleStep.cs b/src/request.validation/RuleStep.cs new file mode 100644 index 0000000..bb47091 --- /dev/null +++ b/src/request.validation/RuleStep.cs @@ -0,0 +1,64 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +internal interface IRuleStep +{ + IEnumerable Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity); +} + +internal sealed record PredicateRuleStep : IRuleStep +{ + private readonly Func _predicate; + private readonly string _message; + + public PredicateRuleStep(Func predicate, string message) + { + _predicate = predicate; + _message = message; + } + + public IEnumerable Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity) + { + if (_predicate(value)) + { + yield break; + } + + yield return new Problem + { + PropertyPath = propertyPath, + Message = _message, + Code = code, + Severity = severity, + AttemptedValue = value, + }; + } +} + +internal sealed record ValidatorRuleStep : IRuleStep +{ + private readonly Func _resolver; + + public ValidatorRuleStep(Func resolver) + { + _resolver = resolver; + } + + public IEnumerable Validate(TValue value, ValidationContext context, PropertyPath propertyPath, string? code, Severity severity) + { + if (value is null) + { + yield break; + } + + var clone = new ValidationContext(value, context.ServiceProvider, context.Items); + var validation = _resolver(context).Validate(clone); + + foreach (var problem in validation.Problems) + { + yield return problem with { PropertyPath = propertyPath + problem.PropertyPath }; + } + } +} diff --git a/src/request.validation/Severity.cs b/src/request.validation/Severity.cs new file mode 100644 index 0000000..c4a2670 --- /dev/null +++ b/src/request.validation/Severity.cs @@ -0,0 +1,25 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Specifies the severity of a rule. +/// +public enum Severity +{ + /// + /// Error + /// + Error, + + /// + /// Warning + /// + Warning, + + /// + /// Info + /// + Info, +} diff --git a/src/request.validation/Validation.cs b/src/request.validation/Validation.cs new file mode 100644 index 0000000..fadadff --- /dev/null +++ b/src/request.validation/Validation.cs @@ -0,0 +1,32 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Validation; + +/// +/// Represents the result of executing one or more validations against a given object or request. +/// +public sealed class Validation +{ + /// + /// Represents the result of executing one or more validations against a given object or request. + /// + /// + /// This class contains the list of validation problems identified during the validation process. + /// A validation is considered successful if no problems are found. + /// + public Validation(IEnumerable problems) + { + Problems = [.. problems]; + } + + /// + /// Whether the validation was successful. + /// + public bool IsValid => Problems.Count is 0; + + /// + /// The problems that were found during validation. + /// + public IReadOnlyList Problems { get; } +} diff --git a/src/request.validation/ValidationContext.cs b/src/request.validation/ValidationContext.cs new file mode 100644 index 0000000..ac417bf --- /dev/null +++ b/src/request.validation/ValidationContext.cs @@ -0,0 +1,64 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Collections.ObjectModel; + +namespace Geekeey.Request.Validation; + +/// +/// Represents the context in which validation occurs, providing information about the +/// instance being validated, service provider, and additional items for use in validation. +/// +public abstract class ValidationContext +{ + /// + /// Creates a new validation context. + /// + /// The object currently being validated. + /// The service provider available for nested validator resolution. + /// Per-call state shared across nested validation operations. + protected ValidationContext(object? instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary? items = null) + { + Instance = instance; + ServiceProvider = serviceProvider; + Items = items ?? ReadOnlyDictionary.Empty; + } + + /// + /// The object currently being validated. + /// + public object? Instance { get; } + + /// + /// The service provider available for nested validator resolution. + /// + public IServiceProvider? ServiceProvider { get; } + + /// + /// Per-call state shared across nested validation operations. + /// + public IReadOnlyDictionary Items { get; } +} + +/// +/// Represents the context in which validation occurs, providing information about the +/// instance being validated, service provider, and additional items for use in validation. +/// +public sealed class ValidationContext : ValidationContext +{ + /// + /// Creates a new validation context. + /// + /// The object currently being validated. + /// The service provider available for nested validator resolution. + /// Per-call state shared across nested validation operations. + public ValidationContext(T instance, IServiceProvider? serviceProvider = null, IReadOnlyDictionary? items = null) + : base(instance, serviceProvider, items) + { + } + + /// + /// The object currently being validated. + /// + public new T? Instance => base.Instance is T value ? value : default; +} diff --git a/src/request.validation/Validator.cs b/src/request.validation/Validator.cs new file mode 100644 index 0000000..d476de7 --- /dev/null +++ b/src/request.validation/Validator.cs @@ -0,0 +1,152 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +using System.Linq.Expressions; + +namespace Geekeey.Request.Validation; + +/// +/// Represents the base class for defining validation logic for a specific type. +/// +/// The type of the object to be validated. +public abstract class Validator : IValidator +{ + private readonly List _rules = []; + + /// + /// Defines a validation rule for a specific property of the type being validated. + /// + /// The type of the property to validate. + /// An expression representing the property to validate. + /// An object that allows further configuration of the validation rule. + public IPropertyRuleBuilder RuleFor( + Expression> expression) + { + ArgumentNullException.ThrowIfNull(expression); + + _rules.Add(new PropertyRule(expression)); + + return new PropertyRuleBuilder(_rules, _rules.Count - 1); + } + + /// + /// Defines a validation rule for each element in a collection property of the type being validated. + /// + /// The type of the elements in the collection to validate. + /// An expression representing the collection property to validate. + /// An object that allows further configuration of the validation rule. + public IPropertyRuleBuilder RuleForEach( + Expression>> expression) + { + ArgumentNullException.ThrowIfNull(expression); + + _rules.Add(new CollectionRule(expression)); + + return new PropertyRuleBuilder(_rules, _rules.Count - 1); + } + + /// + public Validation Validate(T instance) + { + return Validate(new ValidationContext(instance)); + } + + /// + public Validation Validate(ValidationContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return new Validation(_rules.SelectMany(rule => rule.Validate(context))); + } + + private static IValidator ResolveValidator(ValidationContext context) + where TValidator : IValidator + { + if (context.ServiceProvider is null) + { + throw new InvalidOperationException( + $"Cannot resolve validator of type '{typeof(TValidator).FullName}' because the validation context has no service provider."); + } + + if (context.ServiceProvider.GetService(typeof(TValidator)) is not TValidator validator) + { + throw new InvalidOperationException( + $"Cannot resolve validator of type '{typeof(TValidator).FullName}' from the validation context service provider."); + } + + return validator; + } + + private sealed class PropertyRuleBuilder + : IPropertyRuleBuilder + { + private readonly List _rules; + private readonly int _index; + + public PropertyRuleBuilder(List rules, int index) + { + _rules = rules; + _index = index; + } + + private Rule CurrentRule + { + get => (Rule)_rules[_index]; + set => _rules[_index] = value; + } + + public IPropertyRuleBuilder Must(Func predicate, string message) + { + ArgumentNullException.ThrowIfNull(predicate); + ArgumentException.ThrowIfNullOrEmpty(message); + + var step = new PredicateRuleStep(predicate, message); + CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] }; + + return this; + } + + public IPropertyRuleBuilder SetValidator(IValidator validator) + { + ArgumentNullException.ThrowIfNull(validator); + + var step = new ValidatorRuleStep(_ => validator); + CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] }; + + return this; + } + + public IPropertyRuleBuilder SetValidator() where TValidator : IValidator + { + var step = new ValidatorRuleStep(ResolveValidator); + CurrentRule = CurrentRule with { Steps = [.. CurrentRule.Steps, step] }; + + return this; + } + + public IPropertyRuleBuilder WithCode(string code) + { + ArgumentException.ThrowIfNullOrEmpty(code); + + CurrentRule = CurrentRule with { Code = code }; + + return this; + } + + public IPropertyRuleBuilder WithPropertyPath(Func transform) + { + ArgumentNullException.ThrowIfNull(transform); + + CurrentRule = CurrentRule with { PropertyPathTransform = transform }; + + return this; + } + + public IPropertyRuleBuilder WithSeverity(Severity severity) + { + CurrentRule = CurrentRule with { Severity = severity }; + + return this; + } + } +} diff --git a/src/request.validation/package-icon.png b/src/request.validation/package-icon.png new file mode 100644 index 0000000..35f4099 Binary files /dev/null and b/src/request.validation/package-icon.png differ diff --git a/src/request.validation/package-readme.md b/src/request.validation/package-readme.md new file mode 100644 index 0000000..c259d34 --- /dev/null +++ b/src/request.validation/package-readme.md @@ -0,0 +1,110 @@ +## Features + +- **Composable validators:** Build validators by inheriting from `Validator` and defining rules with `RuleFor` and + `RuleForEach`. +- **Built-in and custom rules:** Use helpers like `NotEmpty`, `Length`, `Between`, and `Matches`, or define custom + predicates with `Must`. +- **Structured validation output:** Each failure is returned as a `Problem` with a property path, message, severity, + code, and attempted value. +- **Nested validation:** Reuse validators for complex object graphs with `SetValidator`, including DI-based resolution. +- **Configurable paths:** Rewrite or remove rule property paths when the reported path should differ from the CLR + member path. + +## Getting Started + +### Install the NuGet package: + +```shell +dotnet add package Geekeey.Request.Validation +``` + +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 +using Geekeey.Request.Validation; + +public sealed record Address(string? Street); + +public sealed record CreateUserRequest( + string? Name, + int Age, + Address? Address, + IReadOnlyList Tags); + +public sealed class AddressValidator : Validator
+{ + public AddressValidator() + { + RuleFor(address => address.Street) + .NotEmpty(); + } +} + +public sealed class CreateUserRequestValidator : Validator +{ + public CreateUserRequestValidator() + { + RuleFor(request => request.Name) + .NotEmpty() + .Length(2, 100) + .WithCode("NAME_INVALID"); + + RuleFor(request => request.Age) + .Between(18, 120); + + RuleFor(request => request.Address) + .SetValidator(new AddressValidator()); + + RuleForEach(request => request.Tags) + .NotEmpty() + .WithSeverity(Severity.Warning); + } +} + +public sealed record LoginInput(string? Username); + +public sealed record LoginRequest(LoginInput Input); + +public sealed class LoginInputValidator : Validator +{ + public LoginInputValidator() + { + RuleFor(input => input.Username) + .NotEmpty(); + } +} + +public sealed class LoginRequestValidator : Validator +{ + public LoginRequestValidator() + { + RuleFor(request => request.Input) + .WithoutPropertyPath() + .SetValidator(new LoginInputValidator()); + } +} + +var validator = new CreateUserRequestValidator(); +var validation = validator.Validate(new CreateUserRequest( + Name: "", + Age: 16, + Address: new Address(""), + Tags: ["", "admin"])); + +foreach (var problem in validation.Problems) +{ + Console.WriteLine($"{problem.PropertyPath}: {problem.Message}"); +} +``` + +The resulting `Problem` entries include full property paths like `Address.Street` and `Tags[0]`, making it easy to +surface validation errors back to callers or APIs. + +If the validation path needs to differ from the CLR property path, use `WithPropertyPath(...)` for a custom transform +or `WithoutPropertyPath()` to drop the current rule path entirely before nested paths are appended.