From 823d895dd2b375894054e00904796ac59fae42ff Mon Sep 17 00:00:00 2001 From: Matthew Abbott Date: Mon, 23 Sep 2024 22:35:01 +0100 Subject: [PATCH] feat: Refactor API Client --- .editorconfig | 259 +++++++-- .github/workflows/main.yml | 22 + .github/workflows/release.yml | 36 ++ .gitignore | 2 +- Build | 2 +- Directory.Build.props | 18 +- Directory.Build.targets | 11 +- Directory.Packages.props | 29 + LICENSE | 21 + NuGet.config | 6 + README.md | 7 +- RELEASE_NOTES.md | 0 SailthruSDK.sln | 15 +- build.cmd | 8 +- build.sh | 8 +- ...uSDK.Extensions.DependencyInjection.csproj | 10 +- .../ServiceCollectionExtensions.cs | 45 +- libs/SailthruSDK/Api/Purchases/Purchase.cs | 233 ++++++++ .../Api/Purchases/PurchaseOperations.cs | 53 ++ libs/SailthruSDK/Api/SailthruApiClient.cs | 15 + libs/SailthruSDK/Api/Users/User.cs | 448 ++++++++++++++++ libs/SailthruSDK/Api/Users/UserOperations.cs | 88 +++ libs/SailthruSDK/ApiClient.cs | 499 ++++++++++++++++++ libs/SailthruSDK/Converters/MapConverter.cs | 29 + libs/SailthruSDK/GlobalUsings.cs | 3 + libs/SailthruSDK/JsonUtility.cs | 53 +- libs/SailthruSDK/KeyConflict.cs | 17 +- libs/SailthruSDK/Map.cs | 23 +- libs/SailthruSDK/Model.cs | 41 ++ libs/SailthruSDK/OneOf.cs | 87 ++- libs/SailthruSDK/{User => }/OptOutStatus.cs | 2 +- libs/SailthruSDK/Primitives/PathString.cs | 473 +++++++++++++++++ .../Primitives/PathStringHelper.cs | 46 ++ libs/SailthruSDK/Primitives/QueryString.cs | 259 +++++++++ .../Primitives/QueryStringBuilder.cs | 25 + .../Purchase/SailthruPurchaseExtensions.cs | 33 -- .../Purchase/UpsertPurchaseRequest.cs | 142 ----- libs/SailthruSDK/Resources.Designer.cs | 108 ++++ libs/SailthruSDK/Resources.resx | 135 +++++ libs/SailthruSDK/SailthruApiClientFactory.cs | 31 ++ libs/SailthruSDK/SailthruApiConstants.cs | 9 + libs/SailthruSDK/SailthruClient.cs | 129 ----- libs/SailthruSDK/SailthruHttpClientFactory.cs | 23 + libs/SailthruSDK/SailthruRequest.cs | 74 +-- libs/SailthruSDK/SailthruResponse.cs | 189 ++++--- libs/SailthruSDK/SailthruSDK.csproj | 37 +- libs/SailthruSDK/SailthruSettings.cs | 10 + libs/SailthruSDK/User/GetUserRequest.cs | 115 ---- libs/SailthruSDK/User/SailthruUser.cs | 279 ---------- .../User/SailthruUserExtensions.cs | 96 ---- libs/SailthruSDK/User/UpsertUserRequest.cs | 182 ------- .../SailthruSDK.Samples.Console/Program.cs | 65 ++- .../SailthruSDK.Samples.Console.csproj | 23 +- 53 files changed, 3282 insertions(+), 1291 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/release.yml create mode 100644 Directory.Packages.props create mode 100644 LICENSE create mode 100644 NuGet.config create mode 100644 RELEASE_NOTES.md create mode 100644 libs/SailthruSDK/Api/Purchases/Purchase.cs create mode 100644 libs/SailthruSDK/Api/Purchases/PurchaseOperations.cs create mode 100644 libs/SailthruSDK/Api/SailthruApiClient.cs create mode 100644 libs/SailthruSDK/Api/Users/User.cs create mode 100644 libs/SailthruSDK/Api/Users/UserOperations.cs create mode 100644 libs/SailthruSDK/ApiClient.cs create mode 100644 libs/SailthruSDK/GlobalUsings.cs create mode 100644 libs/SailthruSDK/Model.cs rename libs/SailthruSDK/{User => }/OptOutStatus.cs (82%) create mode 100644 libs/SailthruSDK/Primitives/PathString.cs create mode 100644 libs/SailthruSDK/Primitives/PathStringHelper.cs create mode 100644 libs/SailthruSDK/Primitives/QueryString.cs create mode 100644 libs/SailthruSDK/Primitives/QueryStringBuilder.cs delete mode 100644 libs/SailthruSDK/Purchase/SailthruPurchaseExtensions.cs delete mode 100644 libs/SailthruSDK/Purchase/UpsertPurchaseRequest.cs create mode 100644 libs/SailthruSDK/Resources.Designer.cs create mode 100644 libs/SailthruSDK/Resources.resx create mode 100644 libs/SailthruSDK/SailthruApiClientFactory.cs create mode 100644 libs/SailthruSDK/SailthruApiConstants.cs delete mode 100644 libs/SailthruSDK/SailthruClient.cs create mode 100644 libs/SailthruSDK/SailthruHttpClientFactory.cs delete mode 100644 libs/SailthruSDK/User/GetUserRequest.cs delete mode 100644 libs/SailthruSDK/User/SailthruUser.cs delete mode 100644 libs/SailthruSDK/User/SailthruUserExtensions.cs delete mode 100644 libs/SailthruSDK/User/UpsertUserRequest.cs diff --git a/.editorconfig b/.editorconfig index 9a228d2..7372269 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,67 +1,232 @@ -# EditorConfig is awesome:http://EditorConfig.org +# editorconfig.org # top-most EditorConfig file root = true -# Don't use tabs for indentation. +# Default settings: +# A newline ending every file +# Use tab for spaces [*] +insert_final_newline = true indent_style = tab -guidelines = 80, 120, 160 -# (Please don't specify an indent_size here; that has too many unintended consequences.) - -# Code files -[*.{cs,csx,vb,vbx}] -indent_size = 2 - -# Xml project files -[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] indent_size = 2 +tab_width = 2 +trim_trailing_whitespace = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +end_of_line = crlf +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_prefer_collection_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_allow_multiple_blank_lines_experimental = true:silent +dotnet_style_allow_statement_immediately_after_block_experimental = true:silent +dotnet_code_quality_unused_parameters = all:suggestion +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion -# Xml config files -[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] -indent_size = 2 +[project.json] -# JSON files -[*.json] -indent_size = 2 +# Generated code +[*{_AssemblyInfo.cs,.notsupported.cs}] +generated_code = true -# YAML files -[*.yml] -indent_style = space - -# Dotnet code style settings: +# C# files [*.cs] -# Sort using and Import directives with System.* appearing first -dotnet_sort_system_directives_first = true +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +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_switch_labels = true +csharp_indent_labels = one_less_than_current -# Specify validation methods -dotnet_code_quality.null_check_validation_methods = IsNotNull +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion -# Don't use this. qualifier +# avoid this. unless absolutely necessary dotnet_style_qualification_for_field = false:suggestion dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion -# use int x = .. over Int32 +# Types: use keywords instead of BCL types, and permit var only when the type is clear +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = false:suggestion dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion - -# use int.MaxValue over Int32.MaxValue dotnet_style_predefined_type_for_member_access = true:suggestion -# Require var all the time. -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = true:suggestion - -# Disallow throw expressions. -csharp_style_throw_expression = false:suggestion - -# Newline settings -csharp_new_line_before_open_brace = all -csharp_new_line_before_else = true -csharp_new_line_before_catch = true -csharp_new_line_before_finally = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_members_in_anonymous_types = true +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# static fields should have s_ prefix +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Code style defaults +csharp_using_directive_placement = outside_namespace:suggestion +dotnet_sort_system_directives_first = true +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true:none +csharp_preserve_single_line_statements = false:none +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = false:none +csharp_style_prefer_switch_expression = true:suggestion +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +csharp_prefer_simple_default_expression = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_constructors = true:silent +csharp_style_expression_bodied_operators = true:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = true:silent + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Other features +csharp_style_prefer_index_operator = false:none +csharp_style_prefer_range_operator = false:none +csharp_style_pattern_local_over_anonymous_function = false:none + +# 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 = do_not_ignore +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 + +# License header +file_header_template = This work is licensed under the terms of the MIT license.\nFor a copy, see . +csharp_style_namespace_declarations = file_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion + +# C++ Files +[*.{cpp,h,in}] +curly_bracket_next_line = true +indent_brace_style = Allman + +[*.{csproj,vbproj,proj,nativeproj,locproj}] +charset = utf-8 + +# YAML config files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 -# CA1707: Identifiers should not contain underscores -dotnet_diagnostic.CA1707.severity = silent \ No newline at end of file +# Shell scripts +[*.sh] +end_of_line = lf +[*.{cmd,bat}] +end_of_line = crlf diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..9dfcf41 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,22 @@ +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + with: + fetch-depth: 999 + fetch-tags: true + submodules: true + ssh-key: ${{ secrets.ACCESS_KEY }} + - name: Release Notes + run: | + git log --pretty=format:'%d %s' ${GITHUB_REF} | perl -pe 's| \(.*tag: (\d+.\d+.\d+(-preview\d{3})?)(, .*?)*\)|\n## \1\n|g' > RELEASE_NOTES.md + - name: Build + run: ./build.sh --target Publish --publish --source GitHUb --feed ${{ vars.ISE_NUGET_FEED }} --username ${{ vars.ISE_NUGET_USERNAME }} --token ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..07d9a54 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' + +permissions: + contents: write + +jobs: + build: + name: Build NuGet package + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + with: + fetch-depth: 999 + submodules: true + ssh-key: ${{ secrets.ACCESS_KEY }} + - name: Build + run: ./build.sh --target Publish --publish --nuget --token ${{ secrets.PUBLIC_NUGET_APIKEY }} + release: + name: Create GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Create release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ github.ref_name }} + run: | + gh release create "$tag" \ + --repo="$GITHUB_REPOSITORY" \ + --title="${GITHUB_REPOSITORY#*/} ${tag#v}" \ + --generate-notes diff --git a/.gitignore b/.gitignore index 45dd503..004c6d1 100644 --- a/.gitignore +++ b/.gitignore @@ -443,4 +443,4 @@ $RECYCLE.BIN/ ## Ingenium Defaults artefacts/* -**/appsettings.env.json +**/appsettings.env.json \ No newline at end of file diff --git a/Build b/Build index 2aa07e1..5e5853c 160000 --- a/Build +++ b/Build @@ -1 +1 @@ -Subproject commit 2aa07e15e8811ce1010e678a9633f07ebf8b7044 +Subproject commit 5e5853c8dfd272a2ce5b0609f102b62646ff77ce diff --git a/Directory.Build.props b/Directory.Build.props index 5337688..56fec90 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,17 @@ - - - 0.3 + + true + + + $([System.IO.File]::ReadAllText("$(MSBuildThisFileDirectory)RELEASE_NOTES.md")) + + + true + true - \ No newline at end of file + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets index 7947815..7233f9d 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,3 +1,10 @@ - - \ No newline at end of file + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..8836420 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,29 @@ + + + true + + + 8.0.0 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9ec12a7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Ingenium Software Engineering + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..19d85b7 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/README.md b/README.md index 74f08ad..9faf014 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ -# Ingenium Framework +# SailthruSDK -An optionated Sailthru SDK project. - -## Build Status +An opinionated .NET SDK built for the Sailthru API +[![.github/workflows/main.yml](https://github.com/IngeniumSE/SailthruSDK/actions/workflows/main.yml/badge.svg)](https://github.com/IngeniumSE/SailthruSDK/actions/workflows/main.yml) [![.github/workflows/release.yml](https://github.com/IngeniumSE/SailthruSDK/actions/workflows/release.yml/badge.svg)](https://github.com/IngeniumSE/SailthruSDK/actions/workflows/release.yml) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..e69de29 diff --git a/SailthruSDK.sln b/SailthruSDK.sln index fe58aa9..d82ba0c 100644 --- a/SailthruSDK.sln +++ b/SailthruSDK.sln @@ -48,7 +48,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "repo", "repo", "{D4D1448C-4 build.sh = build.sh Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets + Directory.Packages.props = Directory.Packages.props + LICENSE = LICENSE + NuGet.config = NuGet.config README.md = README.md + RELEASE_NOTES.md = RELEASE_NOTES.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{89340FB5-E457-4182-A510-7F1D8DD97371}" @@ -59,7 +63,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SailthruSDK.Extensions.Depe EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{0876C34F-5AAD-405F-9303-90145D7744CF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SailthruSDK.Samples.Console", "samples\SailthruSDK.Samples.Console\SailthruSDK.Samples.Console.csproj", "{5CEF974B-EAA0-4DB2-9812-C8EFB74ACC96}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SailthruSDK.Samples.Console", "samples\SailthruSDK.Samples.Console\SailthruSDK.Samples.Console.csproj", "{5CEF974B-EAA0-4DB2-9812-C8EFB74ACC96}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{243C0586-E944-42F3-818F-2C4C2526BEC4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{AC001CAE-9827-45E4-845D-F73C3A5AE5DB}" + ProjectSection(SolutionItems) = preProject + .github\workflows\main.yml = .github\workflows\main.yml + .github\workflows\release.yml = .github\workflows\release.yml + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -104,6 +116,7 @@ Global {2E0DE426-D135-4710-80B5-173338D364C6} = {89340FB5-E457-4182-A510-7F1D8DD97371} {806DCE4B-D0BD-4AA5-B8BC-502B69ABFAF3} = {3E233261-9EBB-4F79-B840-BD73052C8428} {5CEF974B-EAA0-4DB2-9812-C8EFB74ACC96} = {0876C34F-5AAD-405F-9303-90145D7744CF} + {AC001CAE-9827-45E4-845D-F73C3A5AE5DB} = {243C0586-E944-42F3-818F-2C4C2526BEC4} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B7D62062-4558-4545-9EE0-3A9B24CFD70D} diff --git a/build.cmd b/build.cmd index 14f711e..ac52167 100644 --- a/build.cmd +++ b/build.cmd @@ -5,6 +5,10 @@ set verbostity=%2 if "%target%" == "" set target=Default if "%verbostity%" == "" set verbostity=Minimal -cd build\apps\build +rem Install dotnet tools +cd build +dotnet tool restore -dotnet run --target %target% --verbostity %verbostity% \ No newline at end of file +rem Run our build app +cd apps\build +dotnet run --target %target% --verbostity %verbostity% diff --git a/build.sh b/build.sh index 152756c..e7bff91 100644 --- a/build.sh +++ b/build.sh @@ -1,5 +1,9 @@ #!/bin/bash -cd ./build/apps/build +# Install dotnet tools +cd ./build +dotnet tool restore -dotnet run --target ${1:-Default} \ No newline at end of file +# Run our build app +cd ./apps/Build +dotnet run $@ diff --git a/libs/SailthruSDK.Extensions.DependencyInjection/SailthruSDK.Extensions.DependencyInjection.csproj b/libs/SailthruSDK.Extensions.DependencyInjection/SailthruSDK.Extensions.DependencyInjection.csproj index aaad8f3..9308da8 100644 --- a/libs/SailthruSDK.Extensions.DependencyInjection/SailthruSDK.Extensions.DependencyInjection.csproj +++ b/libs/SailthruSDK.Extensions.DependencyInjection/SailthruSDK.Extensions.DependencyInjection.csproj @@ -2,12 +2,16 @@ netstandard2.0 - True - 0.1.0 + latest + enable + enable + Microsoft.Extensions.DependencyInjection + false - + + diff --git a/libs/SailthruSDK.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/libs/SailthruSDK.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index 886dee5..caba06c 100644 --- a/libs/SailthruSDK.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/libs/SailthruSDK.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Options; using SailthruSDK; + using SailthruSDK.Api; /// /// Provides extensions for the @@ -78,20 +79,44 @@ public static IServiceCollection AddSailthru( static void AddCoreServices(IServiceCollection services) { - services.AddHttpClient( - "Sailthru", - (sp, http) => ConfigureHttpClient(sp, http)); + services.AddSingleton(sp => + { + var settings = sp.GetRequiredService>().Value; - services.AddSingleton(sp => sp.GetRequiredService>().Value); + settings.Validate(); + + return settings; + }); + + services.AddScoped(); + services.AddScoped(); + + AddApiClient( + services, + SailthruApiConstants.DefaultSailthruApiClient, + (cf, settings) => cf.CreateApiClient(settings, SailthruApiConstants.DefaultSailthruApiClient)); } - static void ConfigureHttpClient(IServiceProvider services, HttpClient http) + static void AddApiClient( + IServiceCollection services, + string name, + Func factory) + where TClient : class { - var settings = services.GetRequiredService>().Value; - SailthruSettingsValidator.Instance.ValidateAndThrow(settings); + void ConfigureHttpDefaults(HttpClient http) + { + http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + services.AddHttpClient(name, ConfigureHttpDefaults); + + services.AddScoped(sp => + { + var settings = sp.GetRequiredService(); + var clientFactory = sp.GetRequiredService(); - http.BaseAddress = new Uri(settings.BaseUrl); - http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return factory(clientFactory, settings); + }); } } -} \ No newline at end of file +} diff --git a/libs/SailthruSDK/Api/Purchases/Purchase.cs b/libs/SailthruSDK/Api/Purchases/Purchase.cs new file mode 100644 index 0000000..b548954 --- /dev/null +++ b/libs/SailthruSDK/Api/Purchases/Purchase.cs @@ -0,0 +1,233 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Text.Json; +using System.Text.Json.Serialization; + +using SailthruSDK.Converters; + +namespace SailthruSDK.Api; + +/// +/// Represents a Sailthru purchase. +/// +public class Purchase +{ + /// + /// Gets the price of the item. + /// + public int Price { get; set; } + + /// + /// Gets the quantity. + /// + [JsonPropertyName("qty")] + public int Quantity { get; set; } + + /// + /// Gets the time. + /// + [JsonPropertyName("time")] + public DateTimeOffset Time { get; set; } + + /// + /// Gets the set of items. + /// + [JsonPropertyName("items")] + public PurchaseItem[] Items { get; set; } = default!; +} + +/// +/// Represents a sailthru purchase item. +/// +public class PurchaseItem +{ + /// + /// Gets the title of the purchase. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets the unique item ID. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = default!; + + /// + /// Gets the URL of the item that was purchased. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Gets the price of the item. + /// + public int Price { get; set; } + + /// + /// Gets the quantity. + /// + [JsonPropertyName("qty")] + public int Quantity { get; set; } + + /// + /// Gets the tags. + /// + [JsonPropertyName("tags")] + public string[]? Tags { get; set; } + + /// + /// Gets or sets the set of variables associated with the purchase item. + /// + [JsonPropertyName("vars")] + public Map? Vars { get; set; } + + /// + /// Gets or sets the set of images. + /// + [JsonPropertyName("images")] + public PurchaseImage[]? Images { get; set; } +} + +/// +/// Represents a purchase image. +/// +public class PurchaseImage +{ + [JsonPropertyName("full")] + public PurchaseImageUrl? Full { get; set; } + + [JsonPropertyName("thumb")] + public PurchaseImageUrl? Thumb { get; set; } +} + +/// +/// Represents a URL container. +/// +public class PurchaseImageUrl +{ + [JsonPropertyName("url")] + public string Url { get; set; } = null!; +} + +/// +/// Represents a request to create or update a purchase +/// +public class UpsertPurchaseRequest +{ + /// + /// Initialises a new instance of + /// + /// The user email address + /// The set of items. + /// Specifies whether the purchase is incomplete (e.g. an active cart, not an order) + /// The message ID representing the email campaign. This is usually stored in the sailthru_bid cookie. + public UpsertPurchaseRequest( + string email, + PurchaseItem[] items, + bool incomplete = false, + string? messageId = default) + { + Email = Ensure.IsNotNullOrEmpty(email, nameof(email)); + Items = Ensure.IsNotNull(items, nameof(items)); + Incomplete = incomplete; + MessageId = messageId; + } + + /// + /// Gets the email address. + /// + public string Email { get; } + + /// + /// Gets whether the purchase is incomplete. + /// + public bool Incomplete { get; } + + /// + /// Gets the set of purchase items. + /// + public PurchaseItem[] Items { get; } + + /// + /// Gets the message campaign ID. This is usually stored in the sailthru_bid cookie. + /// + public string? MessageId { get; } + + internal class Converter : ConverterBase + { + /// + public override UpsertPurchaseRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + /// + public override void Write(Utf8JsonWriter writer, UpsertPurchaseRequest value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStartObject(); + + writer.WriteStringProperty("email", value.Email, options); + writer.WriteBooleanProperty("incomplete", value.Incomplete, options); + writer.WriteStringProperty("message_id", value.MessageId ?? default, options); + + writer.WritePropertyName("items"); + writer.WriteStartArray(); + + foreach (var item in value.Items) + { + writer.WriteStartObject(); + + writer.WriteStringProperty("id", item.Id, options); + writer.WriteStringProperty("title", item.Title, options); + writer.WriteNumberProperty("price", (int)(item.Price * 100), options); + writer.WriteNumberProperty("qty", item.Quantity, options); + writer.WriteStringProperty("url", item.Url, options); + + if (item.Images is { Length: > 0 }) + { + writer.WritePropertyName("images"); + writer.WriteStartArray(); + + foreach (var image in item.Images) + { + writer.WriteStartObject(); + + if (image.Full != null) + { + writer.WritePropertyName("full"); + writer.WriteStartObject(); + writer.WriteStringProperty("url", image.Full.Url, options); + writer.WriteEndObject(); + } + + if (image.Thumb != null) + { + writer.WritePropertyName("thumb"); + writer.WriteStartObject(); + writer.WriteStringProperty("url", image.Thumb.Url, options); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + } + } +} diff --git a/libs/SailthruSDK/Api/Purchases/PurchaseOperations.cs b/libs/SailthruSDK/Api/Purchases/PurchaseOperations.cs new file mode 100644 index 0000000..bf0ff7f --- /dev/null +++ b/libs/SailthruSDK/Api/Purchases/PurchaseOperations.cs @@ -0,0 +1,53 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace SailthruSDK.Api; + +partial interface ISailthruApiClient +{ + /// + /// Gets the purchase operations. + /// + IPurchaseOperations Purchases { get; } +} + +public partial class SailthruApiClient +{ + Lazy? _purchases; + public IPurchaseOperations Purchases => (_purchases ??= Defer( + c => new PurchaseOperations(new("/purchase"), c))).Value; +} + +public partial interface IPurchaseOperations +{ + Task UpsertPurchaseAsync( + string email, + PurchaseItem[] items, + bool incomplete = false, + string? messageId = default, + CancellationToken cancellationToken = default); +} + +internal class PurchaseOperations( + PathString path, + ApiClient client) : IPurchaseOperations +{ + readonly PathString _path = path; + readonly ApiClient _client = client; + + public async Task UpsertPurchaseAsync( + string email, + PurchaseItem[] items, + bool incomplete = false, + string? messageId = default, + CancellationToken cancellationToken = default) + { + var model = new UpsertPurchaseRequest(email, items, incomplete, messageId); + var request = new SailthruRequest(HttpMethod.Post, _path, model); + + return await _client.SendAsync( + request, + cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/libs/SailthruSDK/Api/SailthruApiClient.cs b/libs/SailthruSDK/Api/SailthruApiClient.cs new file mode 100644 index 0000000..adb3b14 --- /dev/null +++ b/libs/SailthruSDK/Api/SailthruApiClient.cs @@ -0,0 +1,15 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace SailthruSDK.Api; + +public partial interface ISailthruApiClient +{ + +} + +public partial class SailthruApiClient : ApiClient, ISailthruApiClient +{ + public SailthruApiClient(HttpClient http, SailthruSettings settings) + : base(http, settings, settings.BaseUrl) { } +} diff --git a/libs/SailthruSDK/Api/Users/User.cs b/libs/SailthruSDK/Api/Users/User.cs new file mode 100644 index 0000000..62b00bf --- /dev/null +++ b/libs/SailthruSDK/Api/Users/User.cs @@ -0,0 +1,448 @@ +namespace SailthruSDK.Api +{ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + using SailthruSDK; + using SailthruSDK.Converters; + + /// + /// Represents a Sailthru user. + /// + public class User + { + /// + /// Gets recent user activity. + /// + [JsonPropertyName("activity")] + public UserActivity? Activity { get; set; } + + /// + /// Gets or sets the device. + /// + [JsonPropertyName("device")] + public UserDevice? Device { get; set; } + + /// + /// Gets or sets the engagement. + /// + [JsonPropertyName("engagement")] + public string? Engagement { get; set; } + + /// + /// Gets or sets the email opt-out status. + /// + [JsonPropertyName("optout_email")] + public OptOutStatus? OptOutStatus { get; set; } + + /// + /// Gets or sets the set of keys associated with the user. + /// + [JsonPropertyName("keys")] + public Map? Keys { get; set; } + + /// + /// Gets or sets lifetime stats about the user. + /// + [JsonPropertyName("lifetime")] + public UserLifetime? Lifetime { get; set; } + + /// + /// Gets or sets the set of lists the user has signed up for. + /// + [JsonPropertyName("lists")] + public Map? Lists { get; set; } + + /// + /// Gets or sets the set of smart lists the user is included in. + /// + [JsonPropertyName("smart-lists")] + public string[]? SmartLists { get; set; } + + /// + /// Gets the set of purchases. + /// + [JsonPropertyName("purchases")] + public Purchase[]? Purchases { get; set; } + + /// + /// Gets the set of purchases. + /// + [JsonPropertyName("purchase_incomplete")] + public Purchase[]? IncompletePurchases { get; set; } + + /// + /// Gets or sets the set of variables associated with the user. + /// + [JsonPropertyName("vars")] + public Map? Vars { get; set; } + } + + /// + /// Represents sailthru user activity. + /// + public class UserActivity + { + /// + /// The date and time the user's most recent click. + /// + [JsonPropertyName("click_time")] + public DateTimeOffset? ClickTime { get; set; } + + /// + /// The date and time of the user's profile creation. + /// + [JsonPropertyName("create_time")] + public DateTimeOffset? CreateTime { get; set; } + + /// + /// The date and time the user's most recent log in. + /// + [JsonPropertyName("login_time")] + public DateTimeOffset? LoginTime { get; set; } + + /// + /// The date and time the user's most recent email open. + /// + [JsonPropertyName("open_time")] + public DateTimeOffset? OpenTime { get; set; } + + /// + /// The date and time the user was added to their first list. + /// + [JsonPropertyName("signup_time")] + public DateTimeOffset? SignupTime { get; set; } + + /// + /// The date and time the user's most recent view. + /// + [JsonPropertyName("view_time")] + public DateTimeOffset? ViewTime { get; set; } + } + + /// + /// Represents information about a user's devices. + /// + public class UserDevice + { + /// + /// Gets the top device for reading emails. + /// + [JsonPropertyName("top_device_email")] + public string? Email { get; set; } + } + + /// + /// Represents lifetime information about the user's subscription. + /// + public class UserLifetime + { + /// + /// Gets the number of messages. + /// + [JsonPropertyName("lifetime_message")] + public int Messages { get; set; } + + /// + /// Gets the number of page views. + /// + [JsonPropertyName("lifetime_pv")] + public int PageViews { get; set; } + + /// + /// Gets the number of messages opened. + /// + [JsonPropertyName("lifetime_open")] + public int Opens { get; set; } + + /// + /// Gets the number of purchases. + /// + [JsonPropertyName("lifetime_purchase")] + public int Purchases { get; set; } + + /// + /// Gets the total purchase price of all of the user's purchases. + /// + [JsonPropertyName("lifetime_purchase_price"), JsonConverter(typeof(PriceConverter))] + public decimal TotalPurchasePrice { get; set; } + } + + /// + /// Defines the possbile Sailthru user key types. + /// + public sealed class UserKeyType + { + public const string Cookie = "cookie"; + public const string Email = "email"; + public const string ExternalId = "exid"; + public const string Facebook = "fb"; + public const string SailthruId = "sid"; + public const string Sms = "sms"; + public const string Twitter = "twitter"; + } + + /// + /// Represets the Sailthru user fields to return. + /// + public class UserFields + { + public bool Activity; + public bool Device; + public bool Engagement; + public bool Keys; + public bool Lifetime; + public bool Lists; + public bool OptOutStatus; + public int PurchaseIncomplete = 0; + public int Purchases = 0; + public bool SmartLists; + public bool Vars; + } + + /// + /// Represents a request to get a user. + /// + public class GetUserRequest( + string id, + string key = UserKeyType.Email, + UserFields? fields = default) + { + /// + /// Gets the user ID. + /// + public string Id { get; } = id; + + /// + /// Gets the key type. + /// + public string Key { get; } = key; + + /// + /// Gets the set of fields. + /// + public Map>? Fields { get; } = ToMap(fields); + + static Map>? ToMap(UserFields? fields) + { + if (fields is null) + { + return default; + } + + var map = new Map>(); + Map(map, "activity", fields.Activity); + Map(map, "device", fields.Device); + Map(map, "engagement", fields.Engagement); + Map(map, "keys", fields.Keys); + Map(map, "lifetime", fields.Lifetime); + Map(map, "lists", fields.Lists); + Map(map, "optout_email", fields.OptOutStatus); + Map(map, "purchase_incomplete", fields.PurchaseIncomplete); + Map(map, "purchases", fields.Purchases); + Map(map, "smart_lists", fields.SmartLists); + Map(map, "vars", fields.Vars); + + return map; + } + + static void Map(Map> map, string name, bool value) + { + if (value) + { + map[name] = new OneOf(value); + } + } + + static void Map(Map> map, string name, int value) + { + if (value > 0) + { + map[name] = new OneOf(value); + } + } + + /// + /// Provides custom serialization of a + /// + internal class Converter : ConverterBase + { + /// + public override GetUserRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + /// + public override void Write(Utf8JsonWriter writer, GetUserRequest value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStartObject(); + + writer.WriteStringProperty("id", value.Id, options); + writer.WriteStringProperty("key", value.Key, options); + writer.WriteUserFields(value.Fields, options); + + writer.WriteEndObject(); + } + } + } + } + + public class UpsertUserRequest( + string id, + string key = UserKeyType.Email, + Map? keys = default, + KeyConflict keyConflict = KeyConflict.Merge, + Map? cookies = default, + Map? lists = default, + Map? templates = default, + Map? vars = default, + OptOutStatus? optOutEmailStatus = default, + bool? optOutSms = default, + UserFields? fields = default) + { + + /// + /// Gets the user ID. + /// + public string Id { get; } = Ensure.IsNotNullOrEmpty(id, nameof(id)); + + /// + /// Gets the key type. + /// + public string Key { get; } = Ensure.IsNotNullOrEmpty(key, nameof(key)); + + /// + /// Gets the key conflict option. + /// + public KeyConflict KeyConflict { get; } = keyConflict; + + /// + /// Gets the set of cookies. + /// + public Map? Cookies { get; } = cookies; + + /// + /// Gets the set of alternate keys for the user. + /// + public Map? Keys { get; } = keys; + + /// + /// Gets he set of list registrations. + /// + public Map? Lists { get; } = lists; + + /// + /// Gets the opt-out status for emails. + /// + public OptOutStatus? OptOutEmailStatus { get; } = optOutEmailStatus; + + /// + /// Gets the out-out status for SMS. + /// + public bool? OptOutSmsStatus { get; } = optOutSms; + + /// + /// Gets the set of template opt-outs. + /// + public Map? Templates { get; } = templates; + + /// + /// Gets the set of variables. + /// + public Map? Vars { get; } = vars; + + /// + /// Gets the set of fields. + /// + public Map>? Fields { get; } = ToMap(fields); + + static Map>? ToMap(UserFields? fields) + { + if (fields is null) + { + return default; + } + + var map = new Map>(); + Map(map, "activity", fields.Activity); + Map(map, "device", fields.Device); + Map(map, "engagement", fields.Engagement); + Map(map, "keys", fields.Keys); + Map(map, "lifetime", fields.Lifetime); + Map(map, "lists", fields.Lists); + Map(map, "optout_email", fields.OptOutStatus); + Map(map, "purchase_incomplete", fields.PurchaseIncomplete); + Map(map, "purchases", fields.Purchases); + Map(map, "smart_lists", fields.SmartLists); + Map(map, "vars", fields.Vars); + + return map; + } + + static void Map(Map> map, string name, bool value) + { + if (value) + { + map[name] = new OneOf(value); + } + } + + static void Map(Map> map, string name, int value) + { + if (value > 0) + { + map[name] = new OneOf(value); + } + } + + /// + /// Provides custom serialization of a + /// + internal class Converter : ConverterBase + { + /// + public override UpsertUserRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + /// + public override void Write(Utf8JsonWriter writer, UpsertUserRequest value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStartObject(); + + writer.WriteStringProperty("id", value.Id, options); + writer.WriteStringProperty("key", value.Key, options); + writer.WriteMapProperty("keys", value.Keys, options); + writer.WriteEnumProperty("keyconflict", value.KeyConflict, options); + writer.WriteMapProperty("cookies", value.Cookies, options); + writer.WriteMapProperty("lists", value.Lists, options); + writer.WriteMapProperty("optout_templates", value.Templates, options); + writer.WriteMapProperty("vars", value.Vars, options); + writer.WriteEnumProperty("optout_email", value.OptOutEmailStatus, options); + if (value.OptOutSmsStatus.GetValueOrDefault(false)) + { + writer.WriteStringProperty("optout_sms_status", "opt-out", options); + } + + writer.WriteUserFields(value.Fields, options); + + writer.WriteEndObject(); + } + } + } + } +} diff --git a/libs/SailthruSDK/Api/Users/UserOperations.cs b/libs/SailthruSDK/Api/Users/UserOperations.cs new file mode 100644 index 0000000..2e3f72f --- /dev/null +++ b/libs/SailthruSDK/Api/Users/UserOperations.cs @@ -0,0 +1,88 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace SailthruSDK.Api; + +partial interface ISailthruApiClient +{ + /// + /// Gets the user operations. + /// + IUserOperations Users { get; } +} + +partial class SailthruApiClient +{ + Lazy? _users; + public IUserOperations Users => (_users ??= Defer( + c => new UserOperations(new("/user"), c))).Value; +} + +public partial interface IUserOperations +{ + Task> GetUserAsync( + string id, + string key = UserKeyType.Email, + UserFields? fields = default, + CancellationToken cancellationToken = default); + + Task UpsertUserAsync( + string id, + string key = UserKeyType.Email, + Map? keys = default, + KeyConflict keyConflict = KeyConflict.Merge, + Map? cookies = default, + Map? lists = default, + Map? templates = default, + Map? vars = default, + OptOutStatus? optOutEmailStatus = default, + bool? optOutSms = default, + UserFields? fields = default, + CancellationToken cancellationToken = default); +} + +public partial class UserOperations( + PathString path, + ApiClient client) : IUserOperations +{ + readonly PathString _path = path; + readonly ApiClient _client = client; + + public async Task> GetUserAsync( + string id, + string key = UserKeyType.Email, + UserFields? fields = null, + CancellationToken cancellationToken = default) + { + var model = new GetUserRequest(id, key, fields); + var request = new SailthruRequest(HttpMethod.Get, _path, model); + + return await _client.FetchAsync( + request, + cancellationToken) + .ConfigureAwait(false); + } + + public async Task UpsertUserAsync( + string id, + string key = UserKeyType.Email, + Map? keys = default, + KeyConflict keyConflict = KeyConflict.Merge, + Map? cookies = default, + Map? lists = default, + Map? templates = default, + Map? vars = default, + OptOutStatus? optOutEmailStatus = default, + bool? optOutSms = default, + UserFields? fields = default, + CancellationToken cancellationToken = default) + { + var model = new UpsertUserRequest(id, key, keys, keyConflict, cookies, lists, templates, vars, optOutEmailStatus, optOutSms, fields); + var request = new SailthruRequest(HttpMethod.Post, _path, model); + + return await _client.SendAsync( + request, + cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/libs/SailthruSDK/ApiClient.cs b/libs/SailthruSDK/ApiClient.cs new file mode 100644 index 0000000..8951be3 --- /dev/null +++ b/libs/SailthruSDK/ApiClient.cs @@ -0,0 +1,499 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SailthruSDK; + +/// +/// Provides a base implementation of an API client. +/// +public abstract class ApiClient +{ + readonly HttpClient _http; + readonly SailthruSettings _settings; + readonly JsonSerializerOptions _serializerOptions = JsonUtility.CreateSerializerOptions(); + readonly Uri _baseUrl; + + protected ApiClient(HttpClient http, SailthruSettings settings, string baseUrl) + { + _http = Ensure.IsNotNull(http, nameof(http)); + _settings = Ensure.IsNotNull(settings, nameof(settings)); + _baseUrl = new Uri(baseUrl); + } + + #region Send and Fetch + protected internal async Task SendAsync( + SailthruRequest request, + CancellationToken cancellationToken = default) + { + Ensure.IsNotNull(request, nameof(request)); + var httpReq = CreateHttpRequest(request); + HttpResponseMessage? httpResp = null; + + try + { + httpResp = await _http.SendAsync(httpReq, cancellationToken) + .ConfigureAwait(false); + + var transformedResponse = await TransformResponse( + httpReq.Method, + httpReq.RequestUri, + httpResp) + .ConfigureAwait(false); + + if (_settings.CaptureRequestContent && httpReq.Content is not null) + { + transformedResponse.RequestContent = await httpReq.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + if (_settings.CaptureResponseContent && httpResp.Content is not null) + { + transformedResponse.ResponseContent = await httpResp.Content.ReadAsStringAsync() + .ConfigureAwait(false); ; + } + + return transformedResponse; + } + catch (Exception ex) + { + var response = new SailthruResponse( + httpReq.Method, + httpReq.RequestUri, + false, + (HttpStatusCode)0, + error: new Error(ex.Message, exception: ex)); + + if (httpReq?.Content is not null) + { + response.RequestContent = await httpReq.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + if (httpResp?.Content is not null) + { + response.ResponseContent = await httpResp.Content.ReadAsStringAsync() + .ConfigureAwait(false); ; + } + + return response; + } + } + + protected internal async Task SendAsync( + SailthruRequest request, + CancellationToken cancellationToken = default) + where TRequest : notnull + { + Ensure.IsNotNull(request, nameof(request)); + var httpReq = CreateHttpRequest(request); + HttpResponseMessage? httpResp = null; + + try + { + httpResp = await _http.SendAsync(httpReq, cancellationToken); + + var transformedResponse = await TransformResponse( + httpReq.Method, + httpReq.RequestUri, + httpResp) + .ConfigureAwait(false); ; + + if (_settings.CaptureRequestContent && httpReq.Content is not null) + { + transformedResponse.RequestContent = await httpReq.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + if (_settings.CaptureResponseContent && httpResp.Content is not null) + { + transformedResponse.ResponseContent = await httpResp.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + return transformedResponse; + } + catch (Exception ex) + { + var response = new SailthruResponse( + httpReq.Method, + httpReq.RequestUri, + false, + (HttpStatusCode)0, + error: new Error(ex.Message, exception: ex)); + + if (httpReq?.Content is not null) + { + response.RequestContent = await httpReq.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + if (httpResp?.Content is not null) + { + response.ResponseContent = await httpResp.Content.ReadAsStringAsync() + .ConfigureAwait(false); ; + } + + return response; + } + } + + protected internal async Task> FetchAsync( + SailthruRequest request, + CancellationToken cancellationToken = default) + where TResponse : class + { + Ensure.IsNotNull(request, nameof(request)); + var httpReq = CreateHttpRequest(request); + HttpResponseMessage? httpResp = null; + + try + { + httpResp = await _http.SendAsync(httpReq, cancellationToken) + .ConfigureAwait(false); + + var transformedResponse = await TransformResponse( + httpReq.Method, + httpReq.RequestUri, + httpResp) + .ConfigureAwait(false); ; + + if (_settings.CaptureRequestContent && httpReq.Content is not null) + { + transformedResponse.RequestContent = await httpReq.Content.ReadAsStringAsync() + .ConfigureAwait(false); ; + } + + if (_settings.CaptureResponseContent && httpResp.Content is not null) + { + transformedResponse.ResponseContent = await httpResp.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + return transformedResponse; + } + catch (Exception ex) + { + var response = new SailthruResponse( + httpReq.Method, + httpReq.RequestUri, + false, + (HttpStatusCode)0, + error: new Error(ex.Message, exception: ex)); + + if (httpReq?.Content is not null) + { + response.RequestContent = await httpReq.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + if (httpResp?.Content is not null) + { + response.ResponseContent = await httpResp.Content.ReadAsStringAsync() + .ConfigureAwait(false); ; + } + + return response; + } + } + + protected internal async Task> FetchAsync( + SailthruRequest request, + CancellationToken cancellationToken = default) + where TRequest : notnull + where TResponse : class + { + Ensure.IsNotNull(request, nameof(request)); + var httpReq = CreateHttpRequest(request); + HttpResponseMessage? httpResp = null; + + try + { + httpResp = await _http.SendAsync(httpReq, cancellationToken) + .ConfigureAwait(false); + + var transformedResponse = await TransformResponse( + httpReq.Method, + httpReq.RequestUri, + httpResp) + .ConfigureAwait(false); ; + + if (_settings.CaptureRequestContent && httpReq.Content is not null) + { + transformedResponse.RequestContent = await httpReq.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + if (_settings.CaptureResponseContent && httpResp.Content is not null) + { + transformedResponse.ResponseContent = await httpResp.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + return transformedResponse; + } + catch (Exception ex) + { + var response = new SailthruResponse( + httpReq.Method, + httpReq.RequestUri, + false, + (HttpStatusCode)0, + error: new Error(ex.Message, exception: ex)); + + if (httpReq?.Content is not null) + { + response.RequestContent = await httpReq.Content.ReadAsStringAsync() + .ConfigureAwait(false); + } + + if (httpResp?.Content is not null) + { + response.ResponseContent = await httpResp.Content.ReadAsStringAsync() + .ConfigureAwait(false); ; + } + + return response; + } + } + #endregion + + #region Preprocessing + protected internal HttpRequestMessage CreateHttpRequest( + SailthruRequest request) + { + string pathAndQuery = request.Resource.ToUriComponent(); + var query = CreateQueryString(request.Method); + if (query != null) + { + pathAndQuery += query.Value.ToUriComponent(); + } + var uri = new Uri(_baseUrl, pathAndQuery); + + var message = new HttpRequestMessage(request.Method, uri); + + return message; + } + + protected internal HttpRequestMessage CreateHttpRequest( + SailthruRequest request) + where TRequest : notnull + { + string pathAndQuery = request.Resource.ToUriComponent(); + var query = CreateQueryString(request.Method, request.Data); + if (query != null) + { + pathAndQuery += query.Value.ToUriComponent(); + } + var uri = new Uri(_baseUrl, pathAndQuery); + + var message = new HttpRequestMessage(request.Method, uri); + + message.Content = CreateHttpContent(request.Method, request.Data); + + return message; + } + #endregion + + #region Postprocessing + protected internal async Task TransformResponse( + HttpMethod method, + Uri uri, + HttpResponseMessage response, + CancellationToken cancellationToken = default) + { + async Task GetSailthruError() + { + Error error; + if (response.Content is not null) + { + var result = await response.Content.ReadFromJsonAsync(cancellationToken) + .ConfigureAwait(false); + + if (result?.Message is not { Length: > 0 }) + { + error = new(Resources.ApiClient_UnknownResponse, result?.Errors); + } + else + { + error = new(result.Message, result.Errors); + } + } + else + { + error = new Error(Resources.ApiClient_NoErrorMessage); + } + + return error; + } + + if (response.IsSuccessStatusCode) + { + return new SailthruResponse( + method, + uri, + response.IsSuccessStatusCode, + response.StatusCode); + } + else + { + Error? error = await GetSailthruError(); + + return new SailthruResponse( + method, + uri, + response.IsSuccessStatusCode, + response.StatusCode, + error: error + ); + } + } + + protected internal async Task> TransformResponse( + HttpMethod method, + Uri uri, + HttpResponseMessage response, + CancellationToken cancellationToken = default) + where TResponse : class + { + async Task GetSailthruError() + { + Error error; + if (response.Content is not null) + { + var result = await response.Content.ReadFromJsonAsync( + (JsonSerializerOptions?)null, cancellationToken) + .ConfigureAwait(false); + + if (result?.Message is not { Length: > 0 }) + { + error = new(Resources.ApiClient_UnknownResponse, result?.Errors); + } + else + { + error = new(result.Message, result.Errors); + } + } + else + { + error = new Error(Resources.ApiClient_NoErrorMessage); + } + + return error; + } + + if (response.IsSuccessStatusCode) + { + TResponse? data = default; + if (response.Content is not null) + { + data = await response.Content.ReadFromJsonAsync( + _serializerOptions, cancellationToken) + .ConfigureAwait(false); + } + + return new SailthruResponse( + method, + uri, + response.IsSuccessStatusCode, + response.StatusCode, + data: data + ); + } + else + { + Error? error = await GetSailthruError(); + + return new SailthruResponse( + method, + uri, + response.IsSuccessStatusCode, + response.StatusCode, + error: error + ); + } + } + + string? GetHeader(string name, HttpHeaders headers) + => headers.TryGetValues(name, out var values) + ? values.First() + : null; + + class ErrorContainer + { + [JsonPropertyName("errors")] + public Dictionary? Errors { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } = default!; + } + #endregion + + protected internal Lazy Defer(Func factory) + => new Lazy(() => factory(this)); + + protected internal Uri Root(string resource) + => new Uri(resource, UriKind.Relative); + + QueryString? CreateQueryString(HttpMethod method, TData? data = default) + { + if (method != HttpMethod.Post) + { + string json = JsonSerializer.Serialize(data, _serializerOptions); + var signature = SignatureGenerator.Generate(_settings.ApiKey, _settings.ApiSecret, payload: json); + + var builder = new QueryStringBuilder(); + builder.AddParameter("api_key", _settings.ApiKey); + builder.AddParameter("sig", signature); + builder.AddParameter("format", "json"); + builder.AddParameter("json", json); + + return builder.Build(); + } + + return null; + } + + QueryString? CreateQueryString(HttpMethod method) + { + if (method != HttpMethod.Post) + { + var signature = SignatureGenerator.Generate(_settings.ApiKey, _settings.ApiSecret); + + var builder = new QueryStringBuilder(); + builder.AddParameter("api_key", _settings.ApiKey); + builder.AddParameter("sig", signature); + builder.AddParameter("format", "json"); + + return builder.Build(); + } + + return null; + } + + HttpContent? CreateHttpContent(HttpMethod method, TData? data = default) + { + if (method == HttpMethod.Post) + { + string json = JsonSerializer.Serialize(data, _serializerOptions); + var signature = SignatureGenerator.Generate(_settings.ApiKey, _settings.ApiSecret, payload: json); + + var content = new FormUrlEncodedContent(new KeyValuePair[] + { + new("api_key", _settings.ApiKey), + new("sig", signature), + new("format", "json"), + new("json", json) + }); + + return content; + } + + return null; + } +} diff --git a/libs/SailthruSDK/Converters/MapConverter.cs b/libs/SailthruSDK/Converters/MapConverter.cs index 07f4603..55c1a42 100644 --- a/libs/SailthruSDK/Converters/MapConverter.cs +++ b/libs/SailthruSDK/Converters/MapConverter.cs @@ -59,4 +59,33 @@ public override void Write(Utf8JsonWriter writer, Map value, JsonSerializerOp } } } + + internal class StringMapConverter : MapConverter + { + public override Map? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var map = new Map(); + var source = JsonSerializer.Deserialize>(ref reader, options); + if (source is { Count: > 0 }) + { + foreach (var pair in source) + { + if (pair.Value.ValueKind == JsonValueKind.Null) + { + map.Add(pair.Key, ""); + } + else if (pair.Value.ValueKind == JsonValueKind.String) + { + map.Add(pair.Key, pair.Value.GetString()); + } + else + { + map.Add(pair.Key, pair.Value.GetRawText()); + } + } + } + + return map; + } + } } diff --git a/libs/SailthruSDK/GlobalUsings.cs b/libs/SailthruSDK/GlobalUsings.cs new file mode 100644 index 0000000..13db1f7 --- /dev/null +++ b/libs/SailthruSDK/GlobalUsings.cs @@ -0,0 +1,3 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + diff --git a/libs/SailthruSDK/JsonUtility.cs b/libs/SailthruSDK/JsonUtility.cs index b1d4412..b829476 100644 --- a/libs/SailthruSDK/JsonUtility.cs +++ b/libs/SailthruSDK/JsonUtility.cs @@ -1,37 +1,34 @@ -namespace SailthruSDK -{ - using System; - using System.Text.Json; - using System.Text.Json.Serialization; +namespace SailthruSDK; - using SailthruSDK.Converters; - using SailthruSDK.Purchase; - using SailthruSDK.User; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; - static class JsonUtility +using SailthruSDK.Api; +using SailthruSDK.Converters; +static class JsonUtility +{ + public static JsonSerializerOptions CreateSerializerOptions() { - public static JsonSerializerOptions GetSerializerOptions() + var options = new JsonSerializerOptions() { - var options = new JsonSerializerOptions() - { - WriteIndented = false, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; - /** MODELS **/ - options.Converters.Add(new GetUserRequest.Converter()); - options.Converters.Add(new UpsertUserRequest.Converter()); - options.Converters.Add(new UpsertPurchaseRequest.Convereter()); - options.Converters.Add(new DateTimeOffsetConverter()); - options.Converters.Add(new NullableDateTimeOffsetConverter()); - options.Converters.Add(new OneOfConverter()); - options.Converters.Add(new NullableEnumConverter()); + /** MODELS **/ + options.Converters.Add(new GetUserRequest.Converter()); + options.Converters.Add(new UpsertUserRequest.Converter()); + options.Converters.Add(new UpsertPurchaseRequest.Converter()); + options.Converters.Add(new DateTimeOffsetConverter()); + options.Converters.Add(new NullableDateTimeOffsetConverter()); + options.Converters.Add(new OneOfConverter()); + options.Converters.Add(new NullableEnumConverter()); - /** MAP **/ - options.Converters.Add(new MapConverter()); - options.Converters.Add(new MapConverter()); + /** MAP **/ + options.Converters.Add(new StringMapConverter()); + options.Converters.Add(new MapConverter()); - return options; - } + return options; } } diff --git a/libs/SailthruSDK/KeyConflict.cs b/libs/SailthruSDK/KeyConflict.cs index 5e42aea..002c7d8 100644 --- a/libs/SailthruSDK/KeyConflict.cs +++ b/libs/SailthruSDK/KeyConflict.cs @@ -1,11 +1,10 @@ -namespace SailthruSDK +namespace SailthruSDK; + +/// +/// Gets or sets the conflict mode for conflicting keys. +/// +public enum KeyConflict { - /// - /// Gets or sets the conflict mode for conflicting keys. - /// - public enum KeyConflict - { - Merge, - Error - } + Merge, + Error } diff --git a/libs/SailthruSDK/Map.cs b/libs/SailthruSDK/Map.cs index 5890853..692656b 100644 --- a/libs/SailthruSDK/Map.cs +++ b/libs/SailthruSDK/Map.cs @@ -1,14 +1,13 @@ -namespace SailthruSDK -{ - using System; - using System.Collections.Generic; +namespace SailthruSDK; + +using System; +using System.Collections.Generic; - /// - /// Represents a map. - /// - /// The value type. - public class Map : Dictionary - { - public Map() : base(StringComparer.OrdinalIgnoreCase) { } - } +/// +/// Represents a map. +/// +/// The value type. +public class Map : Dictionary +{ + public Map() : base(StringComparer.OrdinalIgnoreCase) { } } diff --git a/libs/SailthruSDK/Model.cs b/libs/SailthruSDK/Model.cs new file mode 100644 index 0000000..37a94cf --- /dev/null +++ b/libs/SailthruSDK/Model.cs @@ -0,0 +1,41 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Text.Json; + +using SailthruSDK; + +namespace SailthruSDK; + +/// +/// Provides a base implementation of a model that is convertible to JSON. +/// +/// The model type. +public abstract class Model + where T : Model +{ + /// + /// Converts the given JSON string to an instance of . + /// + /// The JSON string. + /// The model instance. + public static T? FromJsonString(string json) + { + Ensure.IsNotNullOrEmpty(json, nameof(json)); + + var settings = JsonUtility.CreateSerializerOptions(); + + return JsonSerializer.Deserialize(json, settings); + } + + /// + /// Converts the current instance to a JSON string. + /// + /// The JSON string. + public string ToJsonString() + { + var settings = JsonUtility.CreateSerializerOptions(); + + return JsonSerializer.Serialize((T)this, settings); + } +} diff --git a/libs/SailthruSDK/OneOf.cs b/libs/SailthruSDK/OneOf.cs index 954d5e4..55ae8ca 100644 --- a/libs/SailthruSDK/OneOf.cs +++ b/libs/SailthruSDK/OneOf.cs @@ -1,54 +1,53 @@ -namespace SailthruSDK +namespace SailthruSDK; + +/// +/// Represents a value that can be one of the following types. +/// +/// The first type. +/// The second type. +public struct OneOf { /// - /// Represents a value that can be one of the following types. + /// Creates an instance of representing the first type. /// - /// The first type. - /// The second type. - public struct OneOf + /// The first value. + public OneOf(TFirst first) { - /// - /// Creates an instance of representing the first type. - /// - /// The first value. - public OneOf(TFirst first) - { - First = first; - Second = default!; - HasValue = true; - IsFirst = true; - } + First = first; + Second = default!; + HasValue = true; + IsFirst = true; + } - /// - /// Creates an instance of representing the second type. - /// - /// The second value. - public OneOf(TSecond second) - { - First = default!; - Second = second; - HasValue = true; - IsFirst = false; - } + /// + /// Creates an instance of representing the second type. + /// + /// The second value. + public OneOf(TSecond second) + { + First = default!; + Second = second; + HasValue = true; + IsFirst = false; + } - /// - /// Gets whether we have a value. - /// - public bool HasValue { get; } + /// + /// Gets whether we have a value. + /// + public bool HasValue { get; } - /// - /// Gets whether the value is the first value. - /// - public bool IsFirst { get; } + /// + /// Gets whether the value is the first value. + /// + public bool IsFirst { get; } - /// - /// Gets the first value. - /// - public TFirst First { get; } + /// + /// Gets the first value. + /// + public TFirst First { get; } - /// - /// Gets the second value. - /// - public TSecond Second { get; } - } + /// + /// Gets the second value. + /// + public TSecond Second { get; } } diff --git a/libs/SailthruSDK/User/OptOutStatus.cs b/libs/SailthruSDK/OptOutStatus.cs similarity index 82% rename from libs/SailthruSDK/User/OptOutStatus.cs rename to libs/SailthruSDK/OptOutStatus.cs index 7b79c34..e0b7c27 100644 --- a/libs/SailthruSDK/User/OptOutStatus.cs +++ b/libs/SailthruSDK/OptOutStatus.cs @@ -1,4 +1,4 @@ -namespace SailthruSDK.User +namespace SailthruSDK { /// /// Represents the possible opt-out status. diff --git a/libs/SailthruSDK/Primitives/PathString.cs b/libs/SailthruSDK/Primitives/PathString.cs new file mode 100644 index 0000000..7c109a7 --- /dev/null +++ b/libs/SailthruSDK/Primitives/PathString.cs @@ -0,0 +1,473 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.ComponentModel; +using System.Globalization; +using System.Text; + +namespace SailthruSDK; + +/// +/// Provides correct escaping for Path and PathBase values when needed to reconstruct a request or redirect URI string +/// +[TypeConverter(typeof(PathStringConverter))] +public struct PathString : IEquatable +{ + private static readonly char[] splitChar = { '/' }; + + /// + /// Represents the empty path. This field is read-only. + /// + public static readonly PathString Empty = new PathString(string.Empty); + + private readonly string _value; + + /// + /// Initalize the path string with a given value. This value must be in unescaped format. Use + /// PathString.FromUriComponent(value) if you have a path value which is in an escaped format. + /// + /// The unescaped path to be assigned to the Value property. + public PathString(string value) + { + if (!string.IsNullOrEmpty(value) && value[0] != '/') + { + throw new ArgumentException(Resources.PathString_MustStartWithSlash); + } + _value = value; + } + + /// + /// The unescaped path value + /// + public string Value + { + get { return _value; } + } + + /// + /// True if the path is not empty + /// + public bool HasValue + { + get { return !string.IsNullOrEmpty(_value); } + } + + /// + /// Provides the path string escaped in a way which is correct for combining into the URI representation. + /// + /// The escaped path value + public override string ToString() + { + return ToUriComponent(); + } + + /// + /// Provides the path string escaped in a way which is correct for combining into the URI representation. + /// + /// The escaped path value + public string ToUriComponent() + { + if (!HasValue) + { + return string.Empty; + } + + StringBuilder buffer = null; + + var start = 0; + var count = 0; + var requiresEscaping = false; + var i = 0; + + while (i < _value.Length) + { + var isPercentEncodedChar = PathStringHelper.IsPercentEncodedChar(_value, i); + if (PathStringHelper.IsValidPathChar(_value[i]) || isPercentEncodedChar) + { + if (requiresEscaping) + { + // the current segment requires escape + if (buffer == null) + { + buffer = new StringBuilder(_value.Length * 3); + } + + buffer.Append(Uri.EscapeDataString(_value.Substring(start, count))); + + requiresEscaping = false; + start = i; + count = 0; + } + + if (isPercentEncodedChar) + { + count += 3; + i += 3; + } + else + { + count++; + i++; + } + } + else + { + if (!requiresEscaping) + { + // the current segument doesn't require escape + if (buffer == null) + { + buffer = new StringBuilder(_value.Length * 3); + } + + buffer.Append(_value, start, count); + + requiresEscaping = true; + start = i; + count = 0; + } + + count++; + i++; + } + } + + if (count == _value.Length && !requiresEscaping) + { + return _value; + } + else + { + if (count > 0) + { + if (buffer == null) + { + buffer = new StringBuilder(_value.Length * 3); + } + + if (requiresEscaping) + { + buffer.Append(Uri.EscapeDataString(_value.Substring(start, count))); + } + else + { + buffer.Append(_value, start, count); + } + } + + return buffer.ToString(); + } + } + + + /// + /// Returns an PathString given the path as it is escaped in the URI format. The string MUST NOT contain any + /// value that is not a path. + /// + /// The escaped path as it appears in the URI format. + /// The resulting PathString + public static PathString FromUriComponent(string uriComponent) + { + // REVIEW: what is the exactly correct thing to do? + return new PathString(Uri.UnescapeDataString(uriComponent)); + } + + /// + /// Returns an PathString given the path as from a Uri object. Relative Uri objects are not supported. + /// + /// The Uri object + /// The resulting PathString + public static PathString FromUriComponent(Uri uri) + { + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + // REVIEW: what is the exactly correct thing to do? + return new PathString("/" + uri.GetComponents(UriComponents.Path, UriFormat.Unescaped)); + } + + /// + /// Determines whether the beginning of this instance matches the specified . + /// + /// The to compare. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(PathString other) + { + return StartsWithSegments(other, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether the beginning of this instance matches the specified when compared + /// using the specified comparison option. + /// + /// The to compare. + /// One of the enumeration values that determines how this and value are compared. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(PathString other, StringComparison comparisonType) + { + var value1 = Value ?? string.Empty; + var value2 = other.Value ?? string.Empty; + if (value1.StartsWith(value2, comparisonType)) + { + return value1.Length == value2.Length || value1[value2.Length] == '/'; + } + return false; + } + + /// + /// Determines whether the beginning of this instance matches the specified and returns + /// the remaining segments. + /// + /// The to compare. + /// The remaining segments after the match. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(PathString other, out PathString remaining) + { + return StartsWithSegments(other, StringComparison.OrdinalIgnoreCase, out remaining); + } + + /// + /// Determines whether the beginning of this instance matches the specified when compared + /// using the specified comparison option and returns the remaining segments. + /// + /// The to compare. + /// One of the enumeration values that determines how this and value are compared. + /// The remaining segments after the match. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(PathString other, StringComparison comparisonType, out PathString remaining) + { + var value1 = Value ?? string.Empty; + var value2 = other.Value ?? string.Empty; + if (value1.StartsWith(value2, comparisonType)) + { + if (value1.Length == value2.Length || value1[value2.Length] == '/') + { + remaining = new PathString(value1.Substring(value2.Length)); + return true; + } + } + remaining = Empty; + return false; + } + + /// + /// Determines whether the beginning of this instance matches the specified and returns + /// the matched and remaining segments. + /// + /// The to compare. + /// The matched segments with the original casing in the source value. + /// The remaining segments after the match. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(PathString other, out PathString matched, out PathString remaining) + { + return StartsWithSegments(other, StringComparison.OrdinalIgnoreCase, out matched, out remaining); + } + + /// + /// Determines whether the beginning of this instance matches the specified when compared + /// using the specified comparison option and returns the matched and remaining segments. + /// + /// The to compare. + /// One of the enumeration values that determines how this and value are compared. + /// The matched segments with the original casing in the source value. + /// The remaining segments after the match. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(PathString other, StringComparison comparisonType, out PathString matched, out PathString remaining) + { + var value1 = Value ?? string.Empty; + var value2 = other.Value ?? string.Empty; + if (value1.StartsWith(value2, comparisonType)) + { + if (value1.Length == value2.Length || value1[value2.Length] == '/') + { + matched = new PathString(value1.Substring(0, value2.Length)); + remaining = new PathString(value1.Substring(value2.Length)); + return true; + } + } + remaining = Empty; + matched = Empty; + return false; + } + + /// + /// Adds two PathString instances into a combined PathString value. + /// + /// The combined PathString value + public PathString Add(PathString other) + { + if (HasValue && + other.HasValue && + Value[Value.Length - 1] == '/') + { + // If the path string has a trailing slash and the other string has a leading slash, we need + // to trim one of them. + return new PathString(Value + other.Value.Substring(1)); + } + + return new PathString(Value + other.Value); + } + + /// + /// Combines a PathString and QueryString into the joined URI formatted string value. + /// + /// The joined URI formatted string value + public string Add(QueryString other) + { + return ToUriComponent() + other.ToUriComponent(); + } + + /// + /// Compares this PathString value to another value. The default comparison is StringComparison.OrdinalIgnoreCase. + /// + /// The second PathString for comparison. + /// True if both PathString values are equal + public bool Equals(PathString other) + { + return Equals(other, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Compares this PathString value to another value using a specific StringComparison type + /// + /// The second PathString for comparison + /// The StringComparison type to use + /// True if both PathString values are equal + public bool Equals(PathString other, StringComparison comparisonType) + { + if (!HasValue && !other.HasValue) + { + return true; + } + return string.Equals(_value, other._value, comparisonType); + } + + /// + /// Compares this PathString value to another value. The default comparison is StringComparison.OrdinalIgnoreCase. + /// + /// The second PathString for comparison. + /// True if both PathString values are equal + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return !HasValue; + } + return obj is PathString && Equals((PathString)obj); + } + + /// + /// Returns the hash code for the PathString value. The hash code is provided by the OrdinalIgnoreCase implementation. + /// + /// The hash code + public override int GetHashCode() + { + return (HasValue ? StringComparer.OrdinalIgnoreCase.GetHashCode(_value) : 0); + } + + /// + /// Operator call through to Equals + /// + /// The left parameter + /// The right parameter + /// True if both PathString values are equal + public static bool operator ==(PathString left, PathString right) + { + return left.Equals(right); + } + + /// + /// Operator call through to Equals + /// + /// The left parameter + /// The right parameter + /// True if both PathString values are not equal + public static bool operator !=(PathString left, PathString right) + { + return !left.Equals(right); + } + + /// + /// + /// The left parameter + /// The right parameter + /// The ToString combination of both values + public static string operator +(string left, PathString right) + { + // This overload exists to prevent the implicit string<->PathString converter from + // trying to call the PathString+PathString operator for things that are not path strings. + return string.Concat(left, right.ToString()); + } + + /// + /// + /// The left parameter + /// The right parameter + /// The ToString combination of both values + public static string operator +(PathString left, string right) + { + // This overload exists to prevent the implicit string<->PathString converter from + // trying to call the PathString+PathString operator for things that are not path strings. + return string.Concat(left.ToString(), right); + } + + /// + /// Operator call through to Add + /// + /// The left parameter + /// The right parameter + /// The PathString combination of both values + public static PathString operator +(PathString left, PathString right) + { + return left.Add(right); + } + + /// + /// Operator call through to Add + /// + /// The left parameter + /// The right parameter + /// The PathString combination of both values + public static string operator +(PathString left, QueryString right) + { + return left.Add(right); + } + + /// + /// Implicitly creates a new PathString from the given string. + /// + /// + public static implicit operator PathString(string s) + => ConvertFromString(s); + + /// + /// Implicitly calls ToString(). + /// + /// + public static implicit operator string(PathString path) + => path.ToString(); + + internal static PathString ConvertFromString(string s) + => string.IsNullOrEmpty(s) ? new PathString(s) : FromUriComponent(s); +} + +internal class PathStringConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + => sourceType == typeof(string) + ? true + : base.CanConvertFrom(context, sourceType); + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + => value is string + ? PathString.ConvertFromString((string)value) + : base.ConvertFrom(context, culture, value); + + public override object ConvertTo(ITypeDescriptorContext context, + CultureInfo culture, object value, Type destinationType) + => destinationType == typeof(string) + ? value.ToString() + : base.ConvertTo(context, culture, value, destinationType); +} diff --git a/libs/SailthruSDK/Primitives/PathStringHelper.cs b/libs/SailthruSDK/Primitives/PathStringHelper.cs new file mode 100644 index 0000000..2c10170 --- /dev/null +++ b/libs/SailthruSDK/Primitives/PathStringHelper.cs @@ -0,0 +1,46 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace SailthruSDK; + +internal class PathStringHelper +{ + private static bool[] ValidPathChars = { + false, false, false, false, false, false, false, false, // 0x00 - 0x07 + false, false, false, false, false, false, false, false, // 0x08 - 0x0F + false, false, false, false, false, false, false, false, // 0x10 - 0x17 + false, false, false, false, false, false, false, false, // 0x18 - 0x1F + false, true, false, false, true, false, true, true, // 0x20 - 0x27 + true, true, true, true, true, true, true, true, // 0x28 - 0x2F + true, true, true, true, true, true, true, true, // 0x30 - 0x37 + true, true, true, true, false, true, false, false, // 0x38 - 0x3F + true, true, true, true, true, true, true, true, // 0x40 - 0x47 + true, true, true, true, true, true, true, true, // 0x48 - 0x4F + true, true, true, true, true, true, true, true, // 0x50 - 0x57 + true, true, true, false, false, false, false, true, // 0x58 - 0x5F + false, true, true, true, true, true, true, true, // 0x60 - 0x67 + true, true, true, true, true, true, true, true, // 0x68 - 0x6F + true, true, true, true, true, true, true, true, // 0x70 - 0x77 + true, true, true, false, false, false, true, false, // 0x78 - 0x7F + }; + + public static bool IsValidPathChar(char c) + { + return c < ValidPathChars.Length && ValidPathChars[c]; + } + + public static bool IsPercentEncodedChar(string str, int index) + { + return index < str.Length - 2 + && str[index] == '%' + && IsHexadecimalChar(str[index + 1]) + && IsHexadecimalChar(str[index + 2]); + } + + public static bool IsHexadecimalChar(char c) + { + return ('0' <= c && c <= '9') + || ('A' <= c && c <= 'F') + || ('a' <= c && c <= 'f'); + } +} diff --git a/libs/SailthruSDK/Primitives/QueryString.cs b/libs/SailthruSDK/Primitives/QueryString.cs new file mode 100644 index 0000000..18d10e8 --- /dev/null +++ b/libs/SailthruSDK/Primitives/QueryString.cs @@ -0,0 +1,259 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using System.Text; +using System.Text.Encodings.Web; + +using Microsoft.Extensions.Primitives; + +namespace SailthruSDK; + +/// +/// Provides correct handling for QueryString value when needed to reconstruct a request or redirect URI string +/// +public struct QueryString : IEquatable +{ + /// + /// Represents the empty query string. This field is read-only. + /// + public static readonly QueryString Empty = new QueryString(string.Empty); + + private readonly string _value; + + /// + /// Initialize the query string with a given value. This value must be in escaped and delimited format with + /// a leading '?' character. + /// + /// The query string to be assigned to the Value property. + public QueryString(string value) + { + if (!string.IsNullOrEmpty(value) && value[0] != '?') + { + throw new ArgumentException("The leading '?' must be included for a non-empty query.", nameof(value)); + } + _value = value; + } + + /// + /// The escaped query string with the leading '?' character + /// + public string Value + { + get { return _value; } + } + + /// + /// True if the query string is not empty + /// + public bool HasValue + { + get { return !string.IsNullOrEmpty(_value); } + } + + /// + /// Provides the query string escaped in a way which is correct for combining into the URI representation. + /// A leading '?' character will be included unless the Value is null or empty. Characters which are potentially + /// dangerous are escaped. + /// + /// The query string value + public override string ToString() + { + return ToUriComponent(); + } + + /// + /// Provides the query string escaped in a way which is correct for combining into the URI representation. + /// A leading '?' character will be included unless the Value is null or empty. Characters which are potentially + /// dangerous are escaped. + /// + /// The query string value + public string ToUriComponent() + { + // Escape things properly so System.Uri doesn't mis-interpret the data. + return HasValue ? _value.Replace("#", "%23") : string.Empty; + } + + /// + /// Returns an QueryString given the query as it is escaped in the URI format. The string MUST NOT contain any + /// value that is not a query. + /// + /// The escaped query as it appears in the URI format. + /// The resulting QueryString + public static QueryString FromUriComponent(string uriComponent) + { + if (string.IsNullOrEmpty(uriComponent)) + { + return new QueryString(string.Empty); + } + return new QueryString(uriComponent); + } + + /// + /// Returns an QueryString given the query as from a Uri object. Relative Uri objects are not supported. + /// + /// The Uri object + /// The resulting QueryString + public static QueryString FromUriComponent(Uri uri) + { + if (uri == null) + { + throw new ArgumentNullException(nameof(uri)); + } + + string queryValue = uri.GetComponents(UriComponents.Query, UriFormat.UriEscaped); + if (!string.IsNullOrEmpty(queryValue)) + { + queryValue = "?" + queryValue; + } + return new QueryString(queryValue); + } + + /// + /// Create a query string with a single given parameter name and value. + /// + /// The un-encoded parameter name + /// The un-encoded parameter value + /// The resulting QueryString + public static QueryString Create(string name, string value) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (!string.IsNullOrEmpty(value)) + { + value = UrlEncoder.Default.Encode(value); + } + return new QueryString($"?{UrlEncoder.Default.Encode(name)}={value}"); + } + + /// + /// Creates a query string composed from the given name value pairs. + /// + /// + /// The resulting QueryString + public static QueryString Create(IEnumerable> parameters) + { + var builder = new StringBuilder(); + bool first = true; + foreach (var pair in parameters) + { + AppendKeyValuePair(builder, pair.Key, pair.Value, first); + first = false; + } + + return new QueryString(builder.ToString()); + } + + /// + /// Creates a query string composed from the given name value pairs. + /// + /// + /// The resulting QueryString + public static QueryString Create(IEnumerable> parameters) + { + var builder = new StringBuilder(); + bool first = true; + + foreach (var pair in parameters) + { + // If nothing in this pair.Values, append null value and continue + if (StringValues.IsNullOrEmpty(pair.Value)) + { + AppendKeyValuePair(builder, pair.Key, null, first); + first = false; + continue; + } + // Otherwise, loop through values in pair.Value + foreach (var value in pair.Value) + { + AppendKeyValuePair(builder, pair.Key, value, first); + first = false; + } + } + + return new QueryString(builder.ToString()); + } + + public QueryString Add(QueryString other) + { + if (!HasValue || Value.Equals("?", StringComparison.Ordinal)) + { + return other; + } + if (!other.HasValue || other.Value.Equals("?", StringComparison.Ordinal)) + { + return this; + } + + // ?name1=value1 Add ?name2=value2 returns ?name1=value1&name2=value2 + return new QueryString(_value + "&" + other.Value.Substring(1)); + } + + public QueryString Add(string name, string value) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (!HasValue || Value.Equals("?", StringComparison.Ordinal)) + { + return Create(name, value); + } + + var builder = new StringBuilder(Value); + AppendKeyValuePair(builder, name, value, first: false); + return new QueryString(builder.ToString()); + } + + public bool Equals(QueryString other) + { + if (!HasValue && !other.HasValue) + { + return true; + } + return string.Equals(_value, other._value, StringComparison.Ordinal); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return !HasValue; + } + return obj is QueryString && Equals((QueryString)obj); + } + + public override int GetHashCode() + { + return (HasValue ? _value.GetHashCode() : 0); + } + + public static bool operator ==(QueryString left, QueryString right) + { + return left.Equals(right); + } + + public static bool operator !=(QueryString left, QueryString right) + { + return !left.Equals(right); + } + + public static QueryString operator +(QueryString left, QueryString right) + { + return left.Add(right); + } + + private static void AppendKeyValuePair(StringBuilder builder, string key, string value, bool first) + { + builder.Append(first ? "?" : "&"); + builder.Append(UrlEncoder.Default.Encode(key)); + builder.Append("="); + if (!string.IsNullOrEmpty(value)) + { + builder.Append(UrlEncoder.Default.Encode(value)); + } + } +} diff --git a/libs/SailthruSDK/Primitives/QueryStringBuilder.cs b/libs/SailthruSDK/Primitives/QueryStringBuilder.cs new file mode 100644 index 0000000..2388778 --- /dev/null +++ b/libs/SailthruSDK/Primitives/QueryStringBuilder.cs @@ -0,0 +1,25 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace SailthruSDK; + +class QueryStringBuilder +{ + QueryString _qs = QueryString.Empty; + + public QueryStringBuilder AddParameter(string name, object? value) + { + Ensure.IsNotNullOrEmpty(name, nameof(name)); + + if (value is not null) + { +#pragma warning disable CS8604 // Possible null reference argument. + _qs += QueryString.Create(name, value?.ToString()); +#pragma warning restore CS8604 // Possible null reference argument. + } + + return this; + } + + public QueryString Build() => _qs; +} diff --git a/libs/SailthruSDK/Purchase/SailthruPurchaseExtensions.cs b/libs/SailthruSDK/Purchase/SailthruPurchaseExtensions.cs deleted file mode 100644 index 1028abc..0000000 --- a/libs/SailthruSDK/Purchase/SailthruPurchaseExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace SailthruSDK -{ - using System.Net.Http; - using System.Threading; - using System.Threading.Tasks; - - using SailthruSDK.Purchase; - - public static class SailthruPurchaseExtensions - { - public static async Task UpsertPurchaseAsync( - this SailthruClient client, - string email, - PurchaseItem[] items, - bool incomplete = false, - string? messageId = default, - CancellationToken cancellationToken = default) - { - Ensure.IsNotNull(client, nameof(client)); - - var model = new UpsertPurchaseRequest(email, items, incomplete, messageId); - var request = new SailthruRequest( - HttpMethod.Post, - SailthruEndpoints.Purchase, - model); - - var response = await client.SendAsync(request, cancellationToken) - .ConfigureAwait(false); - - return response; - } - } -} \ No newline at end of file diff --git a/libs/SailthruSDK/Purchase/UpsertPurchaseRequest.cs b/libs/SailthruSDK/Purchase/UpsertPurchaseRequest.cs deleted file mode 100644 index 7a5744d..0000000 --- a/libs/SailthruSDK/Purchase/UpsertPurchaseRequest.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Text.Json; - -using SailthruSDK.Converters; - -namespace SailthruSDK.Purchase -{ - /// - /// Represents a request to create or update a purchase - /// - public class UpsertPurchaseRequest - { - /// - /// Initialises a new instance of - /// - /// The user email address - /// The set of items. - /// Specifies whether the purchase is incomplete (e.g. an active cart, not an order) - /// The message ID representing the email campaign. This is usually stored in the sailthru_bid cookie. - public UpsertPurchaseRequest( - string email, - PurchaseItem[] items, - bool incomplete = false, - string? messageId = default) - { - Email = Ensure.IsNotNullOrEmpty(email, nameof(email)); - Items = Ensure.IsNotNull(items, nameof(items)); - Incomplete = incomplete; - MessageId = messageId; - } - - /// - /// Gets the email address. - /// - public string Email { get; } - - /// - /// Gets whether the purchase is incomplete. - /// - public bool Incomplete { get; } - - /// - /// Gets the set of purchase items. - /// - public PurchaseItem[] Items { get; } - - /// - /// Gets the message campaign ID. This is usually stored in the sailthru_bid cookie. - /// - public string? MessageId { get; } - - internal class Convereter : ConverterBase - { - /// - public override UpsertPurchaseRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotImplementedException(); - } - - /// - public override void Write(Utf8JsonWriter writer, UpsertPurchaseRequest value, JsonSerializerOptions options) - { - if (value is null) - { - writer.WriteNullValue(); - } - else - { - writer.WriteStartObject(); - - writer.WriteStringProperty("email", value.Email, options); - writer.WriteBooleanProperty("incomplete", value.Incomplete, options); - writer.WriteStringProperty("message_id", value.MessageId ?? default, options); - - writer.WritePropertyName("items"); - writer.WriteStartArray(); - - foreach (var item in value.Items) - { - writer.WriteStartObject(); - - writer.WriteStringProperty("id", item.Id, options); - writer.WriteStringProperty("title", item.Title, options); - writer.WriteNumberProperty("price", item.Price, options); - writer.WriteNumberProperty("qty", item.Quantity, options); - writer.WriteStringProperty("url", item.Url, options); - - writer.WriteEndObject(); - } - - writer.WriteEndArray(); - writer.WriteEndObject(); - } - } - } - } - - /// - /// Represents a purchase item. - /// - public class PurchaseItem - { - public PurchaseItem( - string id, - string title, - int price, - int quantity, - string url) - { - Id = Ensure.IsNotNullOrEmpty(id, nameof(id)); - Title = Ensure.IsNotNullOrEmpty(title, nameof(title)); - Price = price; - Quantity = quantity; - Url = Ensure.IsNotNullOrEmpty(url, nameof(url)); - } - - /// - /// Gets the ID. - /// - public string Id { get; } - - /// - /// Gets the item title. - /// - public string Title { get; } - - /// - /// Gets the price. - /// - public int Price { get; } - - /// - /// Gets the quantity. - /// - public int Quantity { get; } - - /// - /// Gets the URL. - /// - public string Url { get; } - } -} \ No newline at end of file diff --git a/libs/SailthruSDK/Resources.Designer.cs b/libs/SailthruSDK/Resources.Designer.cs new file mode 100644 index 0000000..da3e85e --- /dev/null +++ b/libs/SailthruSDK/Resources.Designer.cs @@ -0,0 +1,108 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace SailthruSDK { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SailthruSDK.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The Sailthru API did not return a successful response, but it also did not provide an error message.. + /// + internal static string ApiClient_NoErrorMessage { + get { + return ResourceManager.GetString("ApiClient_NoErrorMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An unexpected response was returned from the Sailthru API, and we could not determine the actual error in this case.. + /// + internal static string ApiClient_UnknownResponse { + get { + return ResourceManager.GetString("ApiClient_UnknownResponse", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The JSON provided does not represent a well-formed error collection.. + /// + internal static string ErrorJsonConverter_InvalidJson { + get { + return ResourceManager.GetString("ErrorJsonConverter_InvalidJson", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A path string must start with a slash '/'.. + /// + internal static string PathString_MustStartWithSlash { + get { + return ResourceManager.GetString("PathString_MustStartWithSlash", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A Sailthru API key must be provided.. + /// + internal static string TrybeSettingsValidator_ApiKey_ValidationMessage { + get { + return ResourceManager.GetString("TrybeSettingsValidator_ApiKey_ValidationMessage", resourceCulture); + } + } + } +} diff --git a/libs/SailthruSDK/Resources.resx b/libs/SailthruSDK/Resources.resx new file mode 100644 index 0000000..14d138c --- /dev/null +++ b/libs/SailthruSDK/Resources.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The Sailthru API did not return a successful response, but it also did not provide an error message. + + + An unexpected response was returned from the Sailthru API, and we could not determine the actual error in this case. + + + The JSON provided does not represent a well-formed error collection. + + + A path string must start with a slash '/'. + + + A Sailthru API key must be provided. + + \ No newline at end of file diff --git a/libs/SailthruSDK/SailthruApiClientFactory.cs b/libs/SailthruSDK/SailthruApiClientFactory.cs new file mode 100644 index 0000000..4102dbf --- /dev/null +++ b/libs/SailthruSDK/SailthruApiClientFactory.cs @@ -0,0 +1,31 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +using SailthruSDK.Api; + +namespace SailthruSDK; + +public interface ISailthruApiClientFactory +{ + ISailthruApiClient CreateApiClient( + SailthruSettings settings, + string name = SailthruApiConstants.DefaultSailthruApiClient); +} + +/// +/// Provides factory services for creating Sailthru client instances. +/// +public class SailthruApiClientFactory : ISailthruApiClientFactory +{ + readonly ISailthruHttpClientFactory _httpClientFactory; + + public SailthruApiClientFactory(ISailthruHttpClientFactory httpClientFactory) + { + _httpClientFactory = Ensure.IsNotNull(httpClientFactory, nameof(httpClientFactory)); + } + + public ISailthruApiClient CreateApiClient( + SailthruSettings settings, + string name = SailthruApiConstants.DefaultSailthruApiClient) + => new SailthruApiClient(_httpClientFactory.CreateHttpClient(name), settings); +} diff --git a/libs/SailthruSDK/SailthruApiConstants.cs b/libs/SailthruSDK/SailthruApiConstants.cs new file mode 100644 index 0000000..10f9917 --- /dev/null +++ b/libs/SailthruSDK/SailthruApiConstants.cs @@ -0,0 +1,9 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace SailthruSDK; + +public static class SailthruApiConstants +{ + public const string DefaultSailthruApiClient = "SailthruApi"; +} diff --git a/libs/SailthruSDK/SailthruClient.cs b/libs/SailthruSDK/SailthruClient.cs deleted file mode 100644 index 0eb7def..0000000 --- a/libs/SailthruSDK/SailthruClient.cs +++ /dev/null @@ -1,129 +0,0 @@ -namespace SailthruSDK -{ - using System; - using System.Collections.Generic; - using System.Net.Http; - using System.Text.Json; - using System.Text.Json.Serialization; - using System.Threading; - using System.Threading.Tasks; - - using static System.Net.WebUtility; - - /// - /// Provides a client for integrating the Sailthru API. - /// - public class SailthruClient - { - readonly HttpClient _http; - readonly SailthruSettings _settings; - static readonly JsonSerializerOptions _jsonOptions = JsonUtility.GetSerializerOptions(); - - /// - /// Initialises a new instance of - /// - /// The HTTP client. - /// The Sailthru settings. - public SailthruClient(HttpClient http, SailthruSettings settings) - { - _http = Ensure.IsNotNull(http, nameof(http)); - _settings = Ensure.IsNotNull(settings, nameof(settings)); - } - - internal async Task> SendAsync( - SailthruRequest request, - CancellationToken cancellationToken = default) - where TRequest : notnull - { - var httpResponse = await GetHttpResponseAsync(request, cancellationToken); - - if (httpResponse.IsSuccessStatusCode) - { - string content = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); - - return SailthruResponse.Success( - await JsonSerializer.DeserializeAsync( - await httpResponse.Content.ReadAsStreamAsync(), - _jsonOptions, - cancellationToken: cancellationToken - ) - ); - } - - var error = await JsonSerializer.DeserializeAsync( - await httpResponse.Content.ReadAsStreamAsync(), - _jsonOptions, - cancellationToken: cancellationToken) - ?? new Error { Code = -1, Message = "Unknown error response." }; - - return SailthruResponse.Failure(new SailthruError(error.Code, error.Message)); - } - - internal async Task SendAsync( - SailthruRequest request, - CancellationToken cancellationToken = default) - where TRequest : notnull - { - var httpResponse = await GetHttpResponseAsync(request, cancellationToken); - - if (httpResponse.IsSuccessStatusCode) - { - string content = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); - - return SailthruResponse.Success(); - } - - var error = await JsonSerializer.DeserializeAsync( - await httpResponse.Content.ReadAsStreamAsync(), - _jsonOptions, - cancellationToken: cancellationToken) - ?? new Error { Code = -1, Message = "Unknown error response." }; - - return SailthruResponse.Failure(new SailthruError(error.Code, error.Message)); - } - - async Task GetHttpResponseAsync( - SailthruRequest request, - CancellationToken cancellationToken) - where TRequest : notnull - { - Ensure.IsNotNull(request, nameof(request)); - - string json = JsonSerializer.Serialize(request.Model, _jsonOptions); - string signature = SignatureGenerator.Generate(_settings.ApiKey, _settings.ApiSecret, payload: json); - var requestUri = $"/{request.Endpoint}"; - if (request.Method != HttpMethod.Post) - { - requestUri += $"?api_key={UrlEncode(_settings.ApiKey)}&sig={UrlEncode(signature)}&format=json&json={UrlEncode(json)}"; - } - - var httpRequest = new HttpRequestMessage( - request.Method, - new Uri(_http.BaseAddress, requestUri)); - - if (request.Method == HttpMethod.Post) - { - httpRequest.Content = new FormUrlEncodedContent(new KeyValuePair[] - { - new("api_key", _settings.ApiKey), - new("sig", signature), - new("format", "json"), - new("json", json) - }); - } - - var httpResponse = await _http.SendAsync(httpRequest, cancellationToken) - .ConfigureAwait(false); - - return httpResponse; - } - - public class Error - { - [JsonPropertyName("error")] - public int Code { get; set; } - [JsonPropertyName("errormsg")] - public string Message { get; set; } = default!; - } - } -} \ No newline at end of file diff --git a/libs/SailthruSDK/SailthruHttpClientFactory.cs b/libs/SailthruSDK/SailthruHttpClientFactory.cs new file mode 100644 index 0000000..790dc3c --- /dev/null +++ b/libs/SailthruSDK/SailthruHttpClientFactory.cs @@ -0,0 +1,23 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace SailthruSDK; + +/// +/// Provides factory methods for creating a HTTP client. +/// +public interface ISailthruHttpClientFactory +{ + /// + /// Creates a HTTP client. + /// + /// The HTTP client name. + /// The HTTP client. + HttpClient CreateHttpClient(string name); +} + +public class SailthruHttpClientFactory(IHttpClientFactory clientFactory) : ISailthruHttpClientFactory +{ + public HttpClient CreateHttpClient(string name) + => Ensure.IsNotNull(clientFactory, nameof(clientFactory)).CreateClient(name); +} diff --git a/libs/SailthruSDK/SailthruRequest.cs b/libs/SailthruSDK/SailthruRequest.cs index 0228533..9d6958d 100644 --- a/libs/SailthruSDK/SailthruRequest.cs +++ b/libs/SailthruSDK/SailthruRequest.cs @@ -1,37 +1,51 @@ -namespace SailthruSDK -{ - using System.Net.Http; +namespace SailthruSDK; + +using System.Net.Http; +/// +/// Represents a request to a Sailthru API resource. +/// +/// The HTTP method. +/// The relative resource. +/// The query string. +public class SailthruRequest( + HttpMethod method, + PathString resource, + QueryString? query = null) +{ /// - /// Represents a Sailthru request. + /// Gets the HTTP method for the request. /// - /// The request type. - public class SailthruRequest - where TRequest : notnull - { - public SailthruRequest( - HttpMethod method, - string endpoint, - TRequest model) - { - Method = method; - Endpoint = Ensure.IsNotNullOrEmpty(endpoint, nameof(endpoint)); - Model = Ensure.IsNotNull(model, nameof(model)); - } + public HttpMethod Method => method; - /// - /// Gets the Sailthru endpoint. - /// - public string Endpoint { get; } + /// + /// Gets the relative resource for the request. + /// + public PathString Resource => resource; - /// - /// Gets the HTTP method. - /// - public HttpMethod Method { get; } + /// + /// Gets the query string. + /// + public QueryString? Query => query; +} - /// - /// Gets the model. - /// - public TRequest Model { get; } - } +/// +/// Represents a request to a Sailthru API resource. +/// +/// The HTTP method. +/// The relative resource. +/// The data. +/// The data type. +public class SailthruRequest( + HttpMethod method, + PathString resource, + TData data, + QueryString? query = null) : SailthruRequest(method, resource, query) + where TData : notnull +{ + /// + /// Gets the model for the request. + /// + public TData Data => data; } + diff --git a/libs/SailthruSDK/SailthruResponse.cs b/libs/SailthruSDK/SailthruResponse.cs index 85a7ad2..8b661c9 100644 --- a/libs/SailthruSDK/SailthruResponse.cs +++ b/libs/SailthruSDK/SailthruResponse.cs @@ -1,96 +1,129 @@ -namespace SailthruSDK +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Text; + +namespace SailthruSDK; + +/// +/// Represents a Sailthru response with payload data. +/// +/// The HTTP method requested. +/// The URI requested. +/// States whether the status code is a success HTTP status code. +/// The HTTP status code. +/// The API error, if available. +/// The data type. +[DebuggerDisplay("{ToDebuggerString(),nq}")] +public class SailthruResponse( +HttpMethod method, +Uri uri, +bool isSuccess, +HttpStatusCode statusCode, +Error? error = default) { - public class SailthruError - { - public SailthruError( - int code, - string message) - { - Code = code; - Message = message; - } + /// + /// Gets whether the status code represents a success HTTP status code. + /// + public bool IsSuccess => isSuccess; - /// - /// Gets the error code. - /// - public int Code { get; } + /// + /// Gets the error. + /// + public Error? Error => error; - /// - /// Gets the error message. - /// - public string Message { get; } - } + /// + /// Gets the HTTP status code of the response. + /// + public HttpStatusCode StatusCode => statusCode; /// - /// Represents a Sailthru response. + /// Gets or sets the request HTTP method. /// - public class SailthruResponse - { - protected SailthruResponse( - bool isError = false, - SailthruError? error = default) - { - IsError = isError; - Error = error; - } + public HttpMethod RequestMethod => method; - /// - /// Gets whether the response was an error. - /// - public bool IsError { get; } + /// + /// Gets or sets the request URI. + /// + public Uri RequestUri => uri; - /// - /// Gets the error. - /// - public SailthruError? Error { get; } + /// + /// Gets or sets the request content, when logging is enabled. + /// + public string? RequestContent { get; set; } + + /// + /// Gets or sets the response content, when logging is enabled. + /// + public string? ResponseContent { get; set; } - /// - /// Creates a success response. - /// - /// The Sailthru response. - public static SailthruResponse Success() - => new SailthruResponse(isError: false); + /// + /// Provides a string representation for debugging. + /// + /// + public virtual string ToDebuggerString() + { + var builder = new StringBuilder(); + builder.Append($"{StatusCode}: {RequestMethod} {RequestUri.PathAndQuery}"); + if (Error is not null) + { + builder.Append($" - {Error.Message}"); + } - /// - /// Creates an error response. - /// - /// The Sailthru response. - public static SailthruResponse Failure(SailthruError error) - => new SailthruResponse(isError: true, error: error); + return builder.ToString(); } +} +/// +/// Represents a Sailthru response with payload data. +/// +/// The HTTP method requested. +/// The URI requested. +/// States whether the status code is a success HTTP status code. +/// The HTTP status code. +/// The API response data, if available. +/// The API error, if available. +/// The data type. +public class SailthruResponse( +HttpMethod method, +Uri uri, +bool isSuccess, +HttpStatusCode statusCode, +TData? data = default, +Error? error = default) : SailthruResponse(method, uri, isSuccess, statusCode, error) +{ /// - /// Represents a Sailthru response. + /// Gets the response data. /// - /// The response type. - public class SailthruResponse : SailthruResponse - { - SailthruResponse( - TResponse? result = default, - bool isError = false, - SailthruError? error = default) - : base(isError, error) - { - Result = result; - } + public TData? Data => data; + + /// + /// Gets whether the response has data. + /// + public bool HasData => data is not null; +} - /// - /// Gets the Sailthru response result. - /// - public TResponse? Result { get; } +/// +/// Represents a Sailthru error response. +/// +/// The error message. +/// The set of additional error messages, these may be field specific. +/// The exception that was caught. +public class Error(string message, Dictionary? errors = null, Exception? exception = null) +{ + /// + /// Gets the set of additional error messages, these may be field specific. + /// + public Dictionary? Errors => errors; - /// - /// Creates a success response. - /// - /// The Sailthru response. - public static SailthruResponse Success(TResponse? response) - => new SailthruResponse(response); + /// + /// Gets the exception that was caught. + /// + public Exception? Exception => exception; - /// - /// Creates an error response. - /// - /// The Sailthru response. - public static new SailthruResponse Failure(SailthruError error) - => new SailthruResponse(isError: true, error: error); - } + /// + /// Gets the error message. + /// + public string Message => message; } diff --git a/libs/SailthruSDK/SailthruSDK.csproj b/libs/SailthruSDK/SailthruSDK.csproj index 4488d51..1bfa6f3 100644 --- a/libs/SailthruSDK/SailthruSDK.csproj +++ b/libs/SailthruSDK/SailthruSDK.csproj @@ -1,14 +1,47 @@ - + netstandard2.0 - true + latest + enable + enable + SailthruSDK + false + + + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + \ No newline at end of file diff --git a/libs/SailthruSDK/SailthruSettings.cs b/libs/SailthruSDK/SailthruSettings.cs index ddc1481..888cbba 100644 --- a/libs/SailthruSDK/SailthruSettings.cs +++ b/libs/SailthruSDK/SailthruSettings.cs @@ -26,6 +26,16 @@ public class SailthruSettings /// public string BaseUrl { get; set; } = "https://api.sailthru.com"; + /// + /// Gets or sets whether to capture request content. + /// + public bool CaptureRequestContent { get; set; } + + /// + /// Gets or sets whether to capture response content. + /// + public bool CaptureResponseContent { get; set; } + /// /// Returns the settings as an options instance. /// diff --git a/libs/SailthruSDK/User/GetUserRequest.cs b/libs/SailthruSDK/User/GetUserRequest.cs deleted file mode 100644 index 4952d9c..0000000 --- a/libs/SailthruSDK/User/GetUserRequest.cs +++ /dev/null @@ -1,115 +0,0 @@ -namespace SailthruSDK.User -{ - using System; - using System.Text.Json; - using System.Text.Json.Serialization; - - using SailthruSDK.Converters; - - /// - /// Represenst a request to get a user. - /// - public class GetUserRequest - { - /// - /// Initialises a new instance of - /// - /// The user ID. - /// The key type. - /// The set of fields to return. - public GetUserRequest( - string id, - string key = SailthruUserKeyType.Email, - SailthruUserFields? fields = default) - { - Id = Ensure.IsNotNull(id, nameof(id)); - Key = Ensure.IsNotNullOrEmpty(key, nameof(key)); - Fields = ToMap(fields); - } - - /// - /// Gets the user ID. - /// - public string Id { get; } - - /// - /// Gets the key type. - /// - public string Key { get; } - - /// - /// Gets the set of fields. - /// - public Map>? Fields { get; } - - Map>? ToMap(SailthruUserFields? fields) - { - if (fields is null) - { - return default; - } - - var map = new Map>(); - Map(map, "activity", fields.Activity); - Map(map, "device", fields.Device); - Map(map, "engagement", fields.Engagement); - Map(map, "keys", fields.Keys); - Map(map, "lifetime", fields.Lifetime); - Map(map, "lists", fields.Lists); - Map(map, "optout_email", fields.OptOutStatus); - Map(map, "purchase_incomplete", fields.PurchaseIncomplete); - Map(map, "purchases", fields.Purchases); - Map(map, "smart_lists", fields.SmartLists); - Map(map, "vars", fields.Vars); - - return map; - } - - void Map(Map> map, string name, bool value) - { - if (value) - { - map[name] = new OneOf(value); - } - } - - void Map(Map> map, string name, int value) - { - if (value > 0) - { - map[name] = new OneOf(value); - } - } - - /// - /// Provides custom serialization of a - /// - internal class Converter : ConverterBase - { - /// - public override GetUserRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotImplementedException(); - } - - /// - public override void Write(Utf8JsonWriter writer, GetUserRequest value, JsonSerializerOptions options) - { - if (value is null) - { - writer.WriteNullValue(); - } - else - { - writer.WriteStartObject(); - - writer.WriteStringProperty("id", value.Id, options); - writer.WriteStringProperty("key", value.Key, options); - writer.WriteUserFields(value.Fields, options); - - writer.WriteEndObject(); - } - } - } - } -} diff --git a/libs/SailthruSDK/User/SailthruUser.cs b/libs/SailthruSDK/User/SailthruUser.cs deleted file mode 100644 index 7ad328e..0000000 --- a/libs/SailthruSDK/User/SailthruUser.cs +++ /dev/null @@ -1,279 +0,0 @@ -namespace SailthruSDK.User -{ - using System; - using System.Text.Json.Serialization; - - using SailthruSDK.Converters; - - /// - /// Represents a Sailthru user. - /// - public class SailthruUser - { - /// - /// Gets recent user activity. - /// - [JsonPropertyName("activity")] - public SailthruUserActivity? Activity { get; set; } - - /// - /// Gets or sets the device. - /// - [JsonPropertyName("device")] - public SailthruUserDevice? Device { get; set; } - - /// - /// Gets or sets the engagement. - /// - [JsonPropertyName("engagement")] - public string? Engagement { get; set; } - - /// - /// Gets or sets the email opt-out status. - /// - [JsonPropertyName("optout_email")] - public OptOutStatus? OptOutStatus { get; set; } - - /// - /// Gets or sets the set of keys associated with the user. - /// - [JsonPropertyName("keys")] - public Map? Keys { get; set; } - - /// - /// Gets or sets lifetime stats about the user. - /// - [JsonPropertyName("lifetime")] - public SailthruUserLifetime? Lifetime { get; set; } - - /// - /// Gets or sets the set of lists the user has signed up for. - /// - [JsonPropertyName("lists")] - public Map? Lists { get; set; } - - /// - /// Gets or sets the set of smart lists the user is included in. - /// - [JsonPropertyName("smart-lists")] - public string[]? SmartLists { get; set; } - - /// - /// Gets the set of purchases. - /// - [JsonPropertyName("purchases")] - public SailthruUserPurchase[]? Purchases { get; set; } - - /// - /// Gets the set of purchases. - /// - [JsonPropertyName("purchase_incomplete")] - public SailthruUserPurchase[]? IncompletePurchases { get; set; } - - /// - /// Gets or sets the set of variables associated with the user. - /// - [JsonPropertyName("vars")] - public Map? Vars { get; set; } - } - - /// - /// Represents sailthru user activity. - /// - public class SailthruUserActivity - { - /// - /// The date and time the user's most recent click. - /// - [JsonPropertyName("click_time")] - public DateTimeOffset? ClickTime { get; set; } - - /// - /// The date and time of the user's profile creation. - /// - [JsonPropertyName("create_time")] - public DateTimeOffset? CreateTime { get; set; } - - /// - /// The date and time the user's most recent log in. - /// - [JsonPropertyName("login_time")] - public DateTimeOffset? LoginTime { get; set; } - - /// - /// The date and time the user's most recent email open. - /// - [JsonPropertyName("open_time")] - public DateTimeOffset? OpenTime { get; set; } - - /// - /// The date and time the user was added to their first list. - /// - [JsonPropertyName("signup_time")] - public DateTimeOffset? SignupTime { get; set; } - - /// - /// The date and time the user's most recent view. - /// - [JsonPropertyName("view_time")] - public DateTimeOffset? ViewTime { get; set; } - } - - /// - /// Represents information about a user's devices. - /// - public class SailthruUserDevice - { - /// - /// Gets the top device for reading emails. - /// - [JsonPropertyName("top_device_email")] - public string? Email { get; set; } - } - - /// - /// Represents lifetime information about the user's subscription. - /// - public class SailthruUserLifetime - { - /// - /// Gets the number of messages. - /// - [JsonPropertyName("lifetime_message")] - public int Messages { get; set; } - - /// - /// Gets the number of page views. - /// - [JsonPropertyName("lifetime_pv")] - public int PageViews { get; set; } - - /// - /// Gets the number of messages opened. - /// - [JsonPropertyName("lifetime_open")] - public int Opens { get; set; } - - /// - /// Gets the number of purchases. - /// - [JsonPropertyName("lifetime_purchase")] - public int Purchases { get; set; } - - /// - /// Gets the total purchase price of all of the user's purchases. - /// - [JsonPropertyName("lifetime_purchase_price"), JsonConverter(typeof(PriceConverter))] - public decimal TotalPurchasePrice { get; set; } - } - - /// - /// Represents a Sailthru purchase. - /// - public class SailthruUserPurchase - { - /// - /// Gets the price of the item. - /// - [JsonPropertyName("price"), JsonConverter(typeof(PriceConverter))] - public decimal Price { get; set; } - - /// - /// Gets the quantity. - /// - [JsonPropertyName("qty")] - public int Quantity { get; set; } - - /// - /// Gets the time. - /// - [JsonPropertyName("time")] - public DateTimeOffset Time { get; set; } - - /// - /// Gets the set of items. - /// - [JsonPropertyName("items")] - public SailthruUserPurchaseItem[] Items { get; set; } = default!; - } - - /// - /// Represents a sailthru purchase item. - /// - public class SailthruUserPurchaseItem - { - /// - /// Gets the title of the purchase. - /// - [JsonPropertyName("title")] - public string? Title { get; set; } - - /// - /// Gets the unique item ID. - /// - [JsonPropertyName("id")] - public string Id { get; set; } = default!; - - /// - /// Gets the URL of the item that was purchased. - /// - [JsonPropertyName("url")] - public string? Url { get; set; } - - /// - /// Gets the price of the item. - /// - [JsonPropertyName("price"), JsonConverter(typeof(PriceConverter))] - public decimal Price { get; set; } - - /// - /// Gets the quantity. - /// - [JsonPropertyName("qty")] - public int Quantity { get; set; } - - /// - /// Gets the tags. - /// - [JsonPropertyName("tags")] - public string[]? Tags { get; set; } - - /// - /// Gets or sets the set of variables associated with the purchase item. - /// - [JsonPropertyName("vars")] - public Map? Vars { get; set; } - } - - /// - /// Defines the possbile Sailthru user key types. - /// - public sealed class SailthruUserKeyType - { - public const string Cookie = "cookie"; - public const string Email = "email"; - public const string ExternalId = "exid"; - public const string Facebook = "fb"; - public const string SailthruId = "sid"; - public const string Sms = "sms"; - public const string Twitter = "twitter"; - } - - /// - /// Represets the Sailthru user fields to return. - /// - public class SailthruUserFields - { - public bool Activity; - public bool Device; - public bool Engagement; - public bool Keys; - public bool Lifetime; - public bool Lists; - public bool OptOutStatus; - public int PurchaseIncomplete = 0; - public int Purchases = 0; - public bool SmartLists; - public bool Vars; - } -} diff --git a/libs/SailthruSDK/User/SailthruUserExtensions.cs b/libs/SailthruSDK/User/SailthruUserExtensions.cs deleted file mode 100644 index 8eab1cd..0000000 --- a/libs/SailthruSDK/User/SailthruUserExtensions.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace SailthruSDK -{ - using System.Net.Http; - using System.Threading; - using System.Threading.Tasks; - - using SailthruSDK.User; - - /// - /// Provides extensions for managing Sailthru users. - /// - public static class SailthruUserExtensions - { - /// - /// Gets the user with the given ID. - /// - /// The Sailthru client. - /// The user ID. - /// The key type. - /// The cancellation token. - /// The user instance, or null if it does not match a valid Sailthru user. - public static async Task GetUserAsync( - this SailthruClient client, - string id, - string key = SailthruUserKeyType.Email, - SailthruUserFields? fields = default, - CancellationToken cancellationToken = default) - { - Ensure.IsNotNull(client, nameof(client)); - Ensure.IsNotNullOrEmpty(id, nameof(id)); - - var model = new GetUserRequest(id, key, fields); - var request = new SailthruRequest( - HttpMethod.Get, - SailthruEndpoints.User, - model); - - var response = await client.SendAsync( - request, - cancellationToken) - .ConfigureAwait(false); - - if (response is { IsError: true }) - { - return default; - } - - return response.Result; - } - - /// - /// Creates or updates an existing user. - /// - /// The Sailthru client. - /// The user ID. - /// The key type. - /// The cancellation token. - /// The user instance, or null if it does not match a valid Sailthru user. - public static async Task UpsertUserAsync( - this SailthruClient client, - string id, - string key = SailthruUserKeyType.Email, - Map? keys = default, - KeyConflict keyConflict = KeyConflict.Merge, - Map? cookies = default, - Map? lists = default, - Map? templates = default, - Map? vars = default, - OptOutStatus? optOutEmailStatus = default, - bool? optOutSms = default, - SailthruUserFields? fields = default, - CancellationToken cancellationToken = default) - { - Ensure.IsNotNull(client, nameof(client)); - Ensure.IsNotNullOrEmpty(id, nameof(id)); - - var model = new UpsertUserRequest(id, key, keys, keyConflict, cookies, lists, templates, vars, optOutEmailStatus, optOutSms, fields); - var request = new SailthruRequest( - HttpMethod.Post, - SailthruEndpoints.User, - model); - - var response = await client.SendAsync( - request, - cancellationToken) - .ConfigureAwait(false); - - if (response is { IsError: true }) - { - return default; - } - - return response.Result; - } - } -} diff --git a/libs/SailthruSDK/User/UpsertUserRequest.cs b/libs/SailthruSDK/User/UpsertUserRequest.cs deleted file mode 100644 index 093a7b1..0000000 --- a/libs/SailthruSDK/User/UpsertUserRequest.cs +++ /dev/null @@ -1,182 +0,0 @@ -namespace SailthruSDK.User -{ - using System; - using System.Text.Json; - - using SailthruSDK.Converters; - - /// - /// Represents a request to create or update a user. - /// - public class UpsertUserRequest - { - /// - /// Initialises a new instance of - /// - /// The user ID. - /// The key type. - /// The set of fields to return. - public UpsertUserRequest( - string id, - string key = SailthruUserKeyType.Email, - Map? keys = default, - KeyConflict keyConflict = KeyConflict.Merge, - Map? cookies = default, - Map? lists = default, - Map? templates = default, - Map? vars = default, - OptOutStatus? optOutEmailStatus = default, - bool? optOutSms = default, - SailthruUserFields? fields = default) - { - Id = Ensure.IsNotNull(id, nameof(id)); - Key = Ensure.IsNotNullOrEmpty(key, nameof(key)); - KeyConflict = keyConflict; - Keys = keys; - Cookies = cookies; - Lists = lists; - Templates = templates; - Vars = vars; - OptOutEmailStatus = optOutEmailStatus; - OptOutSmsStatus = optOutSms; - Fields = ToMap(fields); - } - - /// - /// Gets the user ID. - /// - public string Id { get; } - - /// - /// Gets the key type. - /// - public string Key { get; } - - /// - /// Gets the key conflict option. - /// - public KeyConflict KeyConflict { get; } - - /// - /// Gets the set of cookies. - /// - public Map? Cookies { get; } - - /// - /// Gets the set of alternate keys for the user. - /// - public Map? Keys { get; } - - /// - /// Gets he set of list registrations. - /// - public Map? Lists { get; } - - /// - /// Gets the opt-out status for emails. - /// - public OptOutStatus? OptOutEmailStatus { get; } - - /// - /// Gets the out-out status for SMS. - /// - public bool? OptOutSmsStatus { get; } - - /// - /// Gets the set of template opt-outs. - /// - public Map? Templates { get; } - - /// - /// Gets the set of variables. - /// - public Map? Vars { get; } - - /// - /// Gets the set of fields. - /// - public Map>? Fields { get; } - - Map>? ToMap(SailthruUserFields? fields) - { - if (fields is null) - { - return default; - } - - var map = new Map>(); - Map(map, "activity", fields.Activity); - Map(map, "device", fields.Device); - Map(map, "engagement", fields.Engagement); - Map(map, "keys", fields.Keys); - Map(map, "lifetime", fields.Lifetime); - Map(map, "lists", fields.Lists); - Map(map, "optout_email", fields.OptOutStatus); - Map(map, "purchase_incomplete", fields.PurchaseIncomplete); - Map(map, "purchases", fields.Purchases); - Map(map, "smart_lists", fields.SmartLists); - Map(map, "vars", fields.Vars); - - return map; - } - - void Map(Map> map, string name, bool value) - { - if (value) - { - map[name] = new OneOf(value); - } - } - - void Map(Map> map, string name, int value) - { - if (value > 0) - { - map[name] = new OneOf(value); - } - } - - /// - /// Provides custom serialization of a - /// - internal class Converter : ConverterBase - { - /// - public override UpsertUserRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - throw new NotImplementedException(); - } - - /// - public override void Write(Utf8JsonWriter writer, UpsertUserRequest value, JsonSerializerOptions options) - { - if (value is null) - { - writer.WriteNullValue(); - } - else - { - writer.WriteStartObject(); - - writer.WriteStringProperty("id", value.Id, options); - writer.WriteStringProperty("key", value.Key, options); - writer.WriteMapProperty("keys", value.Keys, options); - writer.WriteEnumProperty("keyconflict", value.KeyConflict, options); - writer.WriteMapProperty("cookies", value.Cookies, options); - writer.WriteMapProperty("lists", value.Lists, options); - writer.WriteMapProperty("optout_templates", value.Templates, options); - writer.WriteMapProperty("vars", value.Vars, options); - writer.WriteEnumProperty("optout_email", value.OptOutEmailStatus, options); - if (value.OptOutSmsStatus.GetValueOrDefault(false)) - { - writer.WriteStringProperty("optout_sms_status", "opt-out", options); - } - - writer.WriteUserFields(value.Fields, options); - - writer.WriteEndObject(); - } - } - } - } -} diff --git a/samples/SailthruSDK.Samples.Console/Program.cs b/samples/SailthruSDK.Samples.Console/Program.cs index 56e3329..59ba47f 100644 --- a/samples/SailthruSDK.Samples.Console/Program.cs +++ b/samples/SailthruSDK.Samples.Console/Program.cs @@ -6,8 +6,7 @@ using Microsoft.Extensions.Configuration; - using SailthruSDK.Purchase; - using SailthruSDK.User; + using SailthruSDK.Api; class Program { @@ -15,11 +14,12 @@ static async Task Main() { var settings = GetSettings(); var http = GetHttpClient(settings); - var client = new SailthruClient(http, settings); + var client = new SailthruApiClient(http, settings); - //var user = await client.GetUserAsync( + //var response = await client.Users.GetUserAsync( // "me+spaseekers@matthewabbott.dev", - // fields: new SailthruUserFields + // key: UserKeyType.Email, + // fields: new UserFields // { // Activity = true, // Engagement = true, @@ -32,29 +32,44 @@ static async Task Main() // Purchases = 10, // SmartLists = true, // Vars = true - // } - //); - - //var user = await client.UpsertUserAsync( - // "me@matthewabbott.dev", - // keys: new Map - // { - // [SailthruUserKeyType.Email] = "me@matthewabbott.dev" - // }, - // keyConflict: KeyConflict.Merge, - // cookies: new Map - // { - // ["sailthru_bid"] = "28309054.31001" // }); - var response = await client.UpsertPurchaseAsync( - "me+sailthru@matthewabbott.dev", - new[] { - new PurchaseItem("MonetaryVoucher-50", "£50.00 - Voucher", 5000, 1, "https://www.spaseekers.com/spa-vouchers?value=50.00") + var response = await client.Users.UpsertUserAsync( + "me@matthewabbott.dev", + keys: new Map + { + [UserKeyType.Email] = "me@matthewabbott.dev" }, - incomplete: false, - messageId: "28309054.31001" - ); + keyConflict: KeyConflict.Merge, + cookies: new Map + { + ["sailthru_bid"] = "28309054.31001" + }); + + //var response = await client.Purchases.UpsertPurchaseAsync( + // "me+sailthru@matthewabbott.dev", + // [ + // new PurchaseItem + // { + // Id = "MonetaryVoucher-50", + // Title = "£50.00 - Voucher", + // Price = 5000, + // Quantity = 1, + // Url = "https://www.spaseekers.com/spa-vouchers?value=50.00", + // Images = new[] + // { + // new PurchaseImage + // { + // Full = new PurchaseImageUrl + // { + // Url = "https://spaseekers.imgix.net/m/0/spaseekers-gift-vouchers-2022.jpg" + // } + // } + // } + // } + // ], + // incomplete: true, + // messageId: "28309054.31001"); } static SailthruSettings GetSettings() diff --git a/samples/SailthruSDK.Samples.Console/SailthruSDK.Samples.Console.csproj b/samples/SailthruSDK.Samples.Console/SailthruSDK.Samples.Console.csproj index 953c183..308f7ae 100644 --- a/samples/SailthruSDK.Samples.Console/SailthruSDK.Samples.Console.csproj +++ b/samples/SailthruSDK.Samples.Console/SailthruSDK.Samples.Console.csproj @@ -1,9 +1,13 @@ - + - - Exe - net5.0 - + + Exe + net8.0 + enable + enable + SailthruSDK + false + @@ -15,9 +19,10 @@ - - - - + + + + +