diff --git a/.editorconfig b/.editorconfig index d8898a08..c05bd66d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,19 +1,289 @@ -# EditorConfig (http://editorconfig.org/) +# EditorConfig is awesome: http://EditorConfig.org + +# Create portable, custom editor settings with EditorConfig +# https://docs.microsoft.com/en-us/visualstudio/ide/create-portable-custom-editor-options + +# .NET coding convention settings for EditorConfig +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference + +# Language conventions +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions + +# Formatting conventions +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-formatting-conventions + +# .NET naming conventions for EditorConfig +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-naming-conventions + +# Top-most EditorConfig file root = true -# Default Code Style +# Editor default newlines with a newline ending every file [*] -charset = utf-8 -end_of_line = lf -indent_size = 4 -indent_style = tab insert_final_newline = true -tab_width = 4 +charset = utf-8 +indent_style = space +indent_size = 2 trim_trailing_whitespace = true +[*.json] +insert_final_newline = false + [*.cs] -csharp_new_line_before_open_brace = false +indent_size = 4 -[*.{yaml,yml}] -indent_size = 2 -indent_style = space +# Do not insert newline for ApiApprovalTests +[*.txt] +insert_final_newline = false + +# Code files +[*.{cs,vb}] + +# .NET code style settings - "This." and "Me." qualifiers +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#this-and-me +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +# .NET code style settings - Language keywords instead of framework type names for type references +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#language-keywords +dotnet_style_predefined_type_for_locals_parameters_members = true:error +dotnet_style_predefined_type_for_member_access = true:error + +# .NET code style settings - Modifier preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#normalize-modifiers +dotnet_style_require_accessibility_modifiers = always:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:warning +dotnet_style_readonly_field = true:warning + +# .NET code style settings - Parentheses preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#parentheses-preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:suggestion +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:suggestion +dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:suggestion +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion + +# .NET code style settings - Expression-level preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#expression-level-preferences +dotnet_style_object_initializer = true:error +dotnet_style_collection_initializer = true:error +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_compound_assignment = true:warning + +# .NET code style settings - Null-checking preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#null-checking-preferences +dotnet_style_coalesce_expression = true:warning +dotnet_style_null_propagation = true:error + +# .NET code quality settings - Parameter preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#parameter-preferences +dotnet_code_quality_unused_parameters = all:warning + +# C# code style settings - Implicit and explicit types +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#implicit-and-explicit-types +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = true:suggestion + +# C# code style settings - Expression-bodied members +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#expression-bodied-members +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_operators = when_on_single_line:warning +csharp_style_expression_bodied_properties = when_on_single_line:warning +csharp_style_expression_bodied_indexers = when_on_single_line:warning +csharp_style_expression_bodied_accessors = when_on_single_line:warning +csharp_style_expression_bodied_lambdas = when_on_single_line:warning +csharp_style_expression_bodied_local_functions = when_on_single_line:warning + +# C# code style settings - Pattern matching +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#pattern-matching +csharp_style_pattern_matching_over_is_with_cast_check = true:error +csharp_style_pattern_matching_over_as_with_null_check = true:error + +# C# code style settings - Inlined variable declaration +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#inlined-variable-declarations +csharp_style_inlined_variable_declaration = true:error + +# C# code style settings - C# expression-level preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#c-expression-level-preferences +csharp_prefer_simple_default_expression = true:suggestion + +# C# code style settings - C# null-checking preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#c-null-checking-preferences +csharp_style_throw_expression = true:warning +csharp_style_conditional_delegate_call = true:warning + +# C# code style settings - Code block preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#code-block-preferences +csharp_prefer_braces = when_multiline:suggestion + +# C# code style - Unused value preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#unused-value-preferences +csharp_style_unused_value_expression_statement_preference = discard_variable:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion + +# C# code style - Index and range preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#index-and-range-preferences +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion + +# C# code style - Miscellaneous preferences +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-language-conventions?view=vs-2019#miscellaneous-preferences +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_using_directive_placement = outside_namespace:warning +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = true:warning +csharp_style_prefer_switch_expression = true:warning + +# .NET formatting settings - Organize using directives +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-formatting-conventions?view=vs-2019#organize-using-directives +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# C# formatting settings - New-line options +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-formatting-conventions?view=vs-2019#new-line-options +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 + +# C# formatting settings - Indentation options +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-formatting-conventions?view=vs-2019#indentation-options +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents_when_block = false + +# C# formatting settings - Spacing options +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_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_call_parameter_list_parentheses = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_after_comma = true +csharp_space_before_comma = false +csharp_space_after_dot = false +csharp_space_before_dot = false +csharp_space_after_semicolon_in_for_statement = true +csharp_space_before_semicolon_in_for_statement = false +csharp_space_around_declaration_statements = false +csharp_space_before_open_square_brackets = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_square_brackets = false + +# C# formatting settings - Wrap options +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-formatting-conventions?view=vs-2019#wrap-options +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +# C# formatting settings - Namespace options +csharp_style_namespace_declarations = file_scoped:suggestion + +########## name all private fields using camelCase with underscore prefix ########## +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-naming-conventions?view=vs-2019 +# dotnet_naming_rule..symbols = +dotnet_naming_rule.private_fields_with_underscore.symbols = private_fields + +# dotnet_naming_symbols.. = +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +# dotnet_naming_rule..style = +dotnet_naming_rule.private_fields_with_underscore.style = prefix_underscore + +# dotnet_naming_style.. = +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ + +# dotnet_naming_rule..severity = +dotnet_naming_rule.private_fields_with_underscore.severity = warning + +########## name all constant fields using UPPER_CASE ########## +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-naming-conventions?view=vs-2019 +# dotnet_naming_rule..symbols = +dotnet_naming_rule.constant_fields_should_be_upper_case.symbols = constant_fields + +# dotnet_naming_symbols.. = +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const + +# dotnet_naming_rule..style = +dotnet_naming_rule.constant_fields_should_be_upper_case.style = upper_case_style + +# dotnet_naming_style.. = +dotnet_naming_style.upper_case_style.capitalization = all_upper +dotnet_naming_style.upper_case_style.word_separator = _ + +# dotnet_naming_rule..severity = +dotnet_naming_rule.constant_fields_should_be_upper_case.severity = warning + +########## Async methods should have "Async" suffix ########## +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-naming-conventions?view=vs-2019 +# dotnet_naming_rule..symbols = +dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods + +# dotnet_naming_symbols.. = +dotnet_naming_symbols.any_async_methods.applicable_kinds = method +dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * +dotnet_naming_symbols.any_async_methods.required_modifiers = async + +# dotnet_naming_rule..style = +dotnet_naming_rule.async_methods_end_in_async.style = end_in_async_style + +# dotnet_naming_style.. = +dotnet_naming_style.end_in_async_style.capitalization = pascal_case +dotnet_naming_style.end_in_async_style.word_separator = +dotnet_naming_style.end_in_async_style.required_prefix = +dotnet_naming_style.end_in_async_style.required_suffix = Async + +# dotnet_naming_rule..severity = +dotnet_naming_rule.async_methods_end_in_async.severity = warning + +# ReSharper: Configure await +configure_await_analysis_mode = library + +# Remove unnecessary import https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005 +dotnet_diagnostic.IDE0005.severity = error + +# Enforce formatting https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/formatting-rules#rule-id-ide0055-fix-formatting +dotnet_diagnostic.IDE0055.severity = error + +# https://github.com/JosefPihrt/Roslynator/blob/master/docs/analyzers/RCS0060.md +dotnet_diagnostic.RCS0060.severity = warning +roslynator_blank_line_after_file_scoped_namespace_declaration = true + +# https://github.com/JosefPihrt/Roslynator/blob/main/docs/analyzers/RCS1080.md +dotnet_diagnostic.RCS1080.severity = warning + +# ConfigureAwait https://github.com/JosefPihrt/Roslynator/blob/master/docs/analyzers/RCS1090.md +dotnet_diagnostic.RCS1090.severity = warning +roslynator_configure_await = true + +# https://github.com/JosefPihrt/Roslynator/blob/master/docs/analyzers/RCS1102.md +# TODO: NullabilityInfo issue in Patching.cs in internal class SR +dotnet_diagnostic.RCS1102.severity = suggestion + +# https://github.com/JosefPihrt/Roslynator/blob/master/docs/analyzers/RCS1194.md +dotnet_diagnostic.RCS1194.severity = suggestion diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 18cff76a..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: deinok diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a4ab6454 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: +- package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "daily" +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/branches.yml b/.github/workflows/branches.yml new file mode 100644 index 00000000..8ae6f0f8 --- /dev/null +++ b/.github/workflows/branches.yml @@ -0,0 +1,54 @@ +name: Branch workflow +on: + push: + branches-ignore: + - master + - 'release/**' + - 'releases/**' + pull_request: + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + MSBUILDSINGLELOADCONTEXT: 1 + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + - name: Restore dotnet tools + run: dotnet tool restore + - name: Fetch complete repository including tags + run: git fetch --tags --force --prune && git describe + - name: Generate version info from git history + run: dotnet gitversion /output json | jq -r 'to_entries|map("GitVersion_\(.key)=\(.value|tostring)")|.[]' >> $GITHUB_ENV + - name: Fail if the version number has not been resolved + if: ${{ !env.GitVersion_SemVer }} + run: | + echo Error! Version number not resolved! + exit 1 + - name: Print current version + run: echo "Current version is \"$GitVersion_SemVer\"" + - name: Install dependencies + run: dotnet restore + - name: Build solution + run: dotnet build --no-restore -c Release + - name: Run Tests + run: dotnet test -c Release --no-restore --no-build + - name: Create NuGet packages + run: dotnet pack -c Release --no-restore --no-build -o nupkg + - name: Upload nuget packages + uses: actions/upload-artifact@v4 + with: + name: nupkg + path: nupkg diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 00000000..b26156bb --- /dev/null +++ b/.github/workflows/master.yml @@ -0,0 +1,57 @@ +name: Master workflow +on: + push: + branches: + - master + - 'release/**' + - 'releases/**' + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + MSBUILDSINGLELOADCONTEXT: 1 + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + source-url: https://nuget.pkg.github.com/graphql-dotnet/index.json + env: + NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Restore dotnet tools + run: dotnet tool restore + - name: Fetch complete repository including tags + run: git fetch --tags --force --prune && git describe + - name: Generate version info from git history + run: dotnet gitversion /output json | jq -r 'to_entries|map("GitVersion_\(.key)=\(.value|tostring)")|.[]' >> $GITHUB_ENV + - name: Fail if the version number has not been resolved + if: ${{ !env.GitVersion_SemVer }} + run: | + echo Error! Version number not resolved! + exit 1 + - name: Print current version + run: echo "Current version is \"$GitVersion_SemVer\"" + - name: Install dependencies + run: dotnet restore + - name: Build solution + run: dotnet build --no-restore -c Release + - name: Run Tests + run: dotnet test -c Release --no-restore --no-build + - name: Create NuGet packages + run: dotnet pack -c Release --no-restore --no-build -o nupkg + - name: Upload nuget packages as artifacts + uses: actions/upload-artifact@v4 + with: + name: nupkg + path: nupkg + - name: Publish Nuget packages to GitHub registry + run: dotnet nuget push "nupkg/*" -k ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..bbb89933 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,77 @@ +name: Publish +on: + release: + types: + - published + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + MSBUILDSINGLELOADCONTEXT: 1 + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check github.ref starts with 'refs/tags/' + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + run: | + echo Error! github.ref does not start with 'refs/tags' + echo github.ref: ${{ github.ref }} + exit 1 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + source-url: https://api.nuget.org/v3/index.json + env: + NUGET_AUTH_TOKEN: ${{secrets.NUGET_API_KEY}} + - name: Restore dotnet tools + run: dotnet tool restore + - name: Fetch complete repository including tags + run: git fetch --tags --force --prune && git describe + - name: Generate version info from git history + run: dotnet gitversion /output json | jq -r 'to_entries|map("GitVersion_\(.key)=\(.value|tostring)")|.[]' >> $GITHUB_ENV + - name: Fail if the version number has not been resolved + if: ${{ !env.GitVersion_SemVer }} + run: | + echo Error! Version number not resolved! + exit 1 + - name: Print current version + run: echo "Current version is \"$GitVersion_SemVer\"" + - name: Install dependencies + run: dotnet restore + - name: Build solution + run: dotnet build --no-restore -c Release + - name: Create NuGet packages + run: dotnet pack -c Release --no-restore --no-build -o nupkg + - name: Upload nuget packages as artifacts + uses: actions/upload-artifact@v4 + with: + name: nupkg + path: nupkg + - name: Publish Nuget packages to Nuget registry + run: dotnet nuget push "nupkg/*" -k ${{secrets.NUGET_API_KEY}} + - name: Upload Nuget packages as release artifacts + uses: actions/github-script@v7 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + console.log('environment', process.versions); + const fs = require('fs').promises; + const { repo: { owner, repo }, sha } = context; + for (let file of await fs.readdir('nupkg')) { + console.log('uploading', file); + await github.rest.repos.uploadReleaseAsset({ + owner, + repo, + release_id: ${{ github.event.release.id }}, + name: file, + data: await fs.readFile(`nupkg/${file}`) + }); + } diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml deleted file mode 100644 index 268c5532..00000000 --- a/.github/workflows/workflow.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Main workflow -on: [push] -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - uses: actions/setup-dotnet@master - with: - dotnet-version: '3.1.100' - - run: dotnet tool restore - - run: dotnet format --check --dry-run - - run: dotnet restore - - run: dotnet build - - run: dotnet pack - test: - name: Test - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - uses: actions/setup-dotnet@master - with: - dotnet-version: '3.1.100' - - run: dotnet restore - - run: dotnet build - - run: dotnet test - publish: - name: Publish - needs: test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - - uses: actions/setup-dotnet@master - with: - dotnet-version: '3.1.100' - - run: dotnet restore - - run: dotnet build --configuration Release - - run: dotnet pack --configuration Release - - run: dotnet nuget push ./src/GraphQL.Client/bin/Release/GraphQL.Client.2.0.0-alpha.4.nupkg --api-key $GITHUB_TOKEN --source https://nuget.pkg.github.com/graphql-dotnet/graphql-client/index.json - - run: dotnet nuget push ./src/GraphQL.Client.Http/bin/Release/GraphQL.Client.Http.2.0.0-alpha.4.nupkg --api-key $GITHUB_TOKEN --source https://nuget.pkg.github.com/graphql-dotnet/graphql-client/index.json diff --git a/.gitignore b/.gitignore index f173174b..5c6b0a7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .idea/ .vs/ +.vscode/ bin/ obj/ *.user +nuget/ \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..3b54328d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,33 @@ + + + + Deinok,Alexander Rose,graphql-dotnet + A GraphQL Client for .NET Standard + true + true + latest + en-US + $(NoWarn);NU5105 + $(NoWarn);1591 + annotations + logo.64x64.png + MIT + https://github.com/graphql-dotnet/graphql-client + true + GraphQL + git + true + true + + + true + embedded + enable + true + true + True + 4 + true + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..f09b31ba --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,43 @@ + + + + README.md + + true + + + + + + $(NoWarn);1591 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 00000000..030bf738 --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,4 @@ +mode: ContinuousDeployment +branches: + master: + tag: alpha diff --git a/GraphQL.Client.sln b/GraphQL.Client.sln index 203aae32..af2fd372 100644 --- a/GraphQL.Client.sln +++ b/GraphQL.Client.sln @@ -1,31 +1,24 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28407.52 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.32228.430 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{47C98B55-08F1-4428-863E-2C5C876DEEFE}" - ProjectSection(SolutionItems) = preProject - src\src.props = src\src.props - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client", "src\GraphQL.Client\GraphQL.Client.csproj", "{42BEFACE-39F9-4FE4-B725-15CD2B16292E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{63F75859-4698-4EDE-8B70-4ACBB8BC425A}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets + dotnet-tools.json = dotnet-tools.json LICENSE.txt = LICENSE.txt + examples\GraphQL.Client.Example\Program.cs = examples\GraphQL.Client.Example\Program.cs README.md = README.md - root.props = root.props - dotnet-tools.json = dotnet-tools.json - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C}" - ProjectSection(SolutionItems) = preProject tests\tests.props = tests\tests.props EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Tests", "tests\GraphQL.Client.Tests\GraphQL.Client.Tests.csproj", "{FEDAE425-B505-4DD6-98ED-3F8593358FC8}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assets", "assets", "{6326E0E2-3F48-4BAF-80D3-47AED5EB647C}" ProjectSection(SolutionItems) = preProject @@ -35,27 +28,45 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assets", "assets", "{6326E0 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Server.Test", "tests\GraphQL.Server.Test\GraphQL.Server.Test.csproj", "{E95A1258-F666-4D4E-9101-E0C46F6A3CB3}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{C42106CF-F685-4F29-BC18-A70616BD68A0}" - ProjectSection(SolutionItems) = preProject - .github\FUNDING.yml = .github\FUNDING.yml - EndProjectSection -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{05CAF9B2-981E-40C0-AE31-5FA56E351F12}" ProjectSection(SolutionItems) = preProject - .github\workflows\workflow.yml = .github\workflows\workflow.yml + .github\workflows\branches.yml = .github\workflows\branches.yml + .github\workflows\master.yml = .github\workflows\master.yml + .github\workflows\publish.yml = .github\workflows\publish.yml EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Http", "src\GraphQL.Client.Http\GraphQL.Client.Http.csproj", "{FA10201B-AE2A-4BFC-8E8F-6F944680974D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Primitives", "src\GraphQL.Primitives\GraphQL.Primitives.csproj", "{87FC440E-6A4D-47D8-9EB2-416FC31CC4A6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Primitives.Tests", "tests\GraphQL.Primitives.Tests\GraphQL.Primitives.Tests.csproj", "{C212983F-67DB-44EB-BFB0-5DA75A86DF55}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestServer", "tests\IntegrationTestServer\IntegrationTestServer.csproj", "{92107DF5-73DF-4371-8EB1-6734FED704AD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Integration.Tests", "tests\GraphQL.Integration.Tests\GraphQL.Integration.Tests.csproj", "{C68C26EB-7659-402A-93D1-E6E248DA5427}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Abstractions", "src\GraphQL.Client.Abstractions\GraphQL.Client.Abstractions.csproj", "{76E622F6-7CDD-4B1F-AD06-FFABF37C55E5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client", "src\GraphQL.Client\GraphQL.Client.csproj", "{ED3541C9-D2B2-4D06-A464-38E404A3919A}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Http.Tests", "tests\GraphQL.Client.Http.Tests\GraphQL.Client.Http.Tests.csproj", "{8F5BBBDA-B3DD-458B-B97F-F67531D323E0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Abstractions.Websocket", "src\GraphQL.Client.Abstractions.Websocket\GraphQL.Client.Abstractions.Websocket.csproj", "{4D581CE1-523D-46BF-BAA5-F7D79A1B7654}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{D61415CA-D822-43DD-9AE7-993B8B60E855}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Serializer.Newtonsoft", "src\GraphQL.Client.Serializer.Newtonsoft\GraphQL.Client.Serializer.Newtonsoft.csproj", "{11F28E78-ADE4-4153-B97C-56136EB7BD5B}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Http.Examples", "examples\GraphQL.Client.Http.Examples\GraphQL.Client.Http.Examples.csproj", "{95D78D57-3232-491D-BAD6-F373D76EA34D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.LocalExecution", "src\GraphQL.Client.LocalExecution\GraphQL.Client.LocalExecution.csproj", "{2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphQL.Primitives", "src\GraphQL.Primitives\GraphQL.Primitives.csproj", "{87FC440E-6A4D-47D8-9EB2-416FC31CC4A6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Serializer.Tests", "tests\GraphQL.Client.Serializer.Tests\GraphQL.Client.Serializer.Tests.csproj", "{CA842D18-FC4A-4281-B1FF-080FA91887B8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphQL.Primitives.Tests", "tests\GraphQL.Primitives.Tests\GraphQL.Primitives.Tests.csproj", "{C212983F-67DB-44EB-BFB0-5DA75A86DF55}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Tests.Common", "tests\GraphQL.Client.Tests.Common\GraphQL.Client.Tests.Common.csproj", "{0D307BAD-27AE-4A5D-8764-4AA2620B01E9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Serializer.SystemTextJson", "src\GraphQL.Client.Serializer.SystemTextJson\GraphQL.Client.Serializer.SystemTextJson.csproj", "{7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{89AD33AB-64F6-4F82-822F-21DF7A10CEC0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GraphQL.Client.Example", "examples\GraphQL.Client.Example\GraphQL.Client.Example.csproj", "{6B13B87D-1EF4-485F-BC5D-891E2F4DA6CD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{98D4DDDD-DE15-4997-B888-9BC806C7416C}" + ProjectSection(SolutionItems) = preProject + .github\dependabot.yml = .github\dependabot.yml + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -63,30 +74,10 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {42BEFACE-39F9-4FE4-B725-15CD2B16292E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {42BEFACE-39F9-4FE4-B725-15CD2B16292E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {42BEFACE-39F9-4FE4-B725-15CD2B16292E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {42BEFACE-39F9-4FE4-B725-15CD2B16292E}.Release|Any CPU.Build.0 = Release|Any CPU - {FEDAE425-B505-4DD6-98ED-3F8593358FC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FEDAE425-B505-4DD6-98ED-3F8593358FC8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FEDAE425-B505-4DD6-98ED-3F8593358FC8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FEDAE425-B505-4DD6-98ED-3F8593358FC8}.Release|Any CPU.Build.0 = Release|Any CPU {E95A1258-F666-4D4E-9101-E0C46F6A3CB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E95A1258-F666-4D4E-9101-E0C46F6A3CB3}.Debug|Any CPU.Build.0 = Debug|Any CPU {E95A1258-F666-4D4E-9101-E0C46F6A3CB3}.Release|Any CPU.ActiveCfg = Release|Any CPU {E95A1258-F666-4D4E-9101-E0C46F6A3CB3}.Release|Any CPU.Build.0 = Release|Any CPU - {FA10201B-AE2A-4BFC-8E8F-6F944680974D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA10201B-AE2A-4BFC-8E8F-6F944680974D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA10201B-AE2A-4BFC-8E8F-6F944680974D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA10201B-AE2A-4BFC-8E8F-6F944680974D}.Release|Any CPU.Build.0 = Release|Any CPU - {8F5BBBDA-B3DD-458B-B97F-F67531D323E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8F5BBBDA-B3DD-458B-B97F-F67531D323E0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8F5BBBDA-B3DD-458B-B97F-F67531D323E0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8F5BBBDA-B3DD-458B-B97F-F67531D323E0}.Release|Any CPU.Build.0 = Release|Any CPU - {95D78D57-3232-491D-BAD6-F373D76EA34D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {95D78D57-3232-491D-BAD6-F373D76EA34D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {95D78D57-3232-491D-BAD6-F373D76EA34D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {95D78D57-3232-491D-BAD6-F373D76EA34D}.Release|Any CPU.Build.0 = Release|Any CPU {87FC440E-6A4D-47D8-9EB2-416FC31CC4A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {87FC440E-6A4D-47D8-9EB2-416FC31CC4A6}.Debug|Any CPU.Build.0 = Debug|Any CPU {87FC440E-6A4D-47D8-9EB2-416FC31CC4A6}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -95,22 +86,71 @@ Global {C212983F-67DB-44EB-BFB0-5DA75A86DF55}.Debug|Any CPU.Build.0 = Debug|Any CPU {C212983F-67DB-44EB-BFB0-5DA75A86DF55}.Release|Any CPU.ActiveCfg = Release|Any CPU {C212983F-67DB-44EB-BFB0-5DA75A86DF55}.Release|Any CPU.Build.0 = Release|Any CPU + {92107DF5-73DF-4371-8EB1-6734FED704AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92107DF5-73DF-4371-8EB1-6734FED704AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92107DF5-73DF-4371-8EB1-6734FED704AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92107DF5-73DF-4371-8EB1-6734FED704AD}.Release|Any CPU.Build.0 = Release|Any CPU + {C68C26EB-7659-402A-93D1-E6E248DA5427}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C68C26EB-7659-402A-93D1-E6E248DA5427}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C68C26EB-7659-402A-93D1-E6E248DA5427}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C68C26EB-7659-402A-93D1-E6E248DA5427}.Release|Any CPU.Build.0 = Release|Any CPU + {76E622F6-7CDD-4B1F-AD06-FFABF37C55E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {76E622F6-7CDD-4B1F-AD06-FFABF37C55E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {76E622F6-7CDD-4B1F-AD06-FFABF37C55E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {76E622F6-7CDD-4B1F-AD06-FFABF37C55E5}.Release|Any CPU.Build.0 = Release|Any CPU + {ED3541C9-D2B2-4D06-A464-38E404A3919A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED3541C9-D2B2-4D06-A464-38E404A3919A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED3541C9-D2B2-4D06-A464-38E404A3919A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED3541C9-D2B2-4D06-A464-38E404A3919A}.Release|Any CPU.Build.0 = Release|Any CPU + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654}.Release|Any CPU.Build.0 = Release|Any CPU + {11F28E78-ADE4-4153-B97C-56136EB7BD5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11F28E78-ADE4-4153-B97C-56136EB7BD5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11F28E78-ADE4-4153-B97C-56136EB7BD5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11F28E78-ADE4-4153-B97C-56136EB7BD5B}.Release|Any CPU.Build.0 = Release|Any CPU + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2}.Release|Any CPU.Build.0 = Release|Any CPU + {CA842D18-FC4A-4281-B1FF-080FA91887B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA842D18-FC4A-4281-B1FF-080FA91887B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA842D18-FC4A-4281-B1FF-080FA91887B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA842D18-FC4A-4281-B1FF-080FA91887B8}.Release|Any CPU.Build.0 = Release|Any CPU + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9}.Release|Any CPU.Build.0 = Release|Any CPU + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5}.Release|Any CPU.Build.0 = Release|Any CPU + {6B13B87D-1EF4-485F-BC5D-891E2F4DA6CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B13B87D-1EF4-485F-BC5D-891E2F4DA6CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B13B87D-1EF4-485F-BC5D-891E2F4DA6CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B13B87D-1EF4-485F-BC5D-891E2F4DA6CD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {42BEFACE-39F9-4FE4-B725-15CD2B16292E} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} - {FEDAE425-B505-4DD6-98ED-3F8593358FC8} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} - {6326E0E2-3F48-4BAF-80D3-47AED5EB647C} = {63F75859-4698-4EDE-8B70-4ACBB8BC425A} {E95A1258-F666-4D4E-9101-E0C46F6A3CB3} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} - {C42106CF-F685-4F29-BC18-A70616BD68A0} = {63F75859-4698-4EDE-8B70-4ACBB8BC425A} - {05CAF9B2-981E-40C0-AE31-5FA56E351F12} = {C42106CF-F685-4F29-BC18-A70616BD68A0} - {FA10201B-AE2A-4BFC-8E8F-6F944680974D} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} - {8F5BBBDA-B3DD-458B-B97F-F67531D323E0} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} - {95D78D57-3232-491D-BAD6-F373D76EA34D} = {D61415CA-D822-43DD-9AE7-993B8B60E855} + {05CAF9B2-981E-40C0-AE31-5FA56E351F12} = {98D4DDDD-DE15-4997-B888-9BC806C7416C} {87FC440E-6A4D-47D8-9EB2-416FC31CC4A6} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} {C212983F-67DB-44EB-BFB0-5DA75A86DF55} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} + {92107DF5-73DF-4371-8EB1-6734FED704AD} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} + {C68C26EB-7659-402A-93D1-E6E248DA5427} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} + {76E622F6-7CDD-4B1F-AD06-FFABF37C55E5} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {ED3541C9-D2B2-4D06-A464-38E404A3919A} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {4D581CE1-523D-46BF-BAA5-F7D79A1B7654} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {11F28E78-ADE4-4153-B97C-56136EB7BD5B} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {2BEC821C-E405-43CB-9BC9-A6BB0322F6C2} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {CA842D18-FC4A-4281-B1FF-080FA91887B8} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} + {0D307BAD-27AE-4A5D-8764-4AA2620B01E9} = {0B0EDB0F-FF67-4B78-A8DB-B5C23E1FEE8C} + {7FFFEC00-D751-4FFC-9FD4-E91858F9A1C5} = {47C98B55-08F1-4428-863E-2C5C876DEEFE} + {6B13B87D-1EF4-485F-BC5D-891E2F4DA6CD} = {89AD33AB-64F6-4F82-822F-21DF7A10CEC0} + {98D4DDDD-DE15-4997-B888-9BC806C7416C} = {63F75859-4698-4EDE-8B70-4ACBB8BC425A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {387AC1AC-F90C-4EF8-955A-04D495C75AF4} diff --git a/GraphQL.Client.sln.DotSettings b/GraphQL.Client.sln.DotSettings new file mode 100644 index 00000000..230ed27f --- /dev/null +++ b/GraphQL.Client.sln.DotSettings @@ -0,0 +1,3 @@ + + APQ + QL \ No newline at end of file diff --git a/README.md b/README.md index 21f7fefe..52ed68d7 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,228 @@ # GraphQL.Client -[![NuGet](https://img.shields.io/nuget/v/GraphQL.Client.svg)](https://www.nuget.org/packages/GraphQL.Client) -[![MyGet](https://img.shields.io/myget/graphql-dotnet/v/GraphQL.Client.svg)](https://www.myget.org/feed/graphql-dotnet/package/nuget/GraphQL.Client) A GraphQL Client for .NET Standard over HTTP. -## Specification: +Provides the following packages: + +| Package | Downloads | Nuget Latest | +|---------|-----------|--------------| +| GraphQL.Client | [![Nuget](https://img.shields.io/nuget/dt/GraphQL.Client)](https://www.nuget.org/packages/GraphQL.Client/) | [![Nuget](https://img.shields.io/nuget/vpre/GraphQL.Client)](https://www.nuget.org/packages/GraphQL.Client) | +| GraphQL.Client.Abstractions | [![Nuget](https://img.shields.io/nuget/dt/GraphQL.Client.Abstractions)](https://www.nuget.org/packages/GraphQL.Client.Abstractions) | [![Nuget](https://img.shields.io/nuget/vpre/GraphQL.Client.Abstractions)](https://www.nuget.org/packages/GraphQL.Client.Abstractions) | +| GraphQL.Client.Abstractions.Websocket | [![Nuget](https://img.shields.io/nuget/dt/GraphQL.Client.Abstractions.Websocket)](https://www.nuget.org/packages/GraphQL.Client.Abstractions.Websocket) | [![Nuget](https://img.shields.io/nuget/vpre/GraphQL.Client.Abstractions.Websocket)](https://www.nuget.org/packages/GraphQL.Client.Abstractions.Websocket) | +| GraphQL.Client.LocalExecution | [![Nuget](https://img.shields.io/nuget/dt/GraphQL.Client.LocalExecution)](https://www.nuget.org/packages/GraphQL.Client.LocalExecution) | [![Nuget](https://img.shields.io/nuget/vpre/GraphQL.Client.LocalExecution)](https://www.nuget.org/packages/GraphQL.Client.LocalExecution) | +| GraphQL.Client.Serializer.Newtonsoft | [![Nuget](https://img.shields.io/nuget/dt/GraphQL.Client.Serializer.Newtonsoft)](https://www.nuget.org/packages/GraphQL.Client.Serializer.Newtonsoft) | [![Nuget](https://img.shields.io/nuget/vpre/GraphQL.Client.Serializer.Newtonsoft)](https://www.nuget.org/packages/GraphQL.Client.Serializer.Newtonsoft) | +| GraphQL.Client.Serializer.SystemTextJson | [![Nuget](https://img.shields.io/nuget/dt/GraphQL.Client.Serializer.SystemTextJson)](https://www.nuget.org/packages/GraphQL.Client.Serializer.SystemTextJson) | [![Nuget](https://img.shields.io/nuget/vpre/GraphQL.Client.Serializer.SystemTextJson)](https://www.nuget.org/packages/GraphQL.Client.Serializer.SystemTextJson) | +| GraphQL.Primitives | [![Nuget](https://img.shields.io/nuget/dt/GraphQL.Primitives)](https://www.nuget.org/packages/GraphQL.Primitives/) | [![Nuget](https://img.shields.io/nuget/vpre/GraphQL.Primitives)](https://www.nuget.org/packages/GraphQL.Primitives) | + +## Specification The Library will try to follow the following standards and documents: -[GraphQL Specification](https://facebook.github.io/graphql/June2018) -[GraphQL HomePage](http://graphql.org/learn) -## Usage: +* [GraphQL Specification](https://spec.graphql.org/June2018/) +* [GraphQL HomePage](https://graphql.org/learn) + +## Usage + +The intended use of `GraphQLHttpClient` is to keep one instance alive per endpoint (obvious in case you're +operating full websocket, but also true for regular requests) and is built with thread-safety in mind. + +### Create a GraphQLHttpClient + +```csharp +// To use NewtonsoftJsonSerializer, add a reference to +// NuGet package GraphQL.Client.Serializer.Newtonsoft +var graphQLClient = new GraphQLHttpClient( + "https://api.example.com/graphql", + new NewtonsoftJsonSerializer()); +``` + +> [!NOTE] +> *GraphQLHttpClient* is meant to be used as a single long-lived instance per endpoint (i.e. register as singleton in a DI system), which should be reused for multiple requests. ### Create a GraphQLRequest: #### Simple Request: ```csharp var heroRequest = new GraphQLRequest { - Query = @" - { - hero { - name - } - }" + Query = """ + { + hero { + name + } + } + """ }; ``` #### OperationName and Variables Request: + ```csharp -var heroAndFriendsRequest = new GraphQLRequest { - Query =@" - query HeroNameAndFriends($episode: Episode) { - hero(episode: $episode) { - name - friends { - name - } - } - }", - OperationName = "HeroNameAndFriends", - Variables = new { - episode = "JEDI" - } +var personAndFilmsRequest = new GraphQLRequest { + Query =""" + query PersonAndFilms($id: ID) { + person(id: $id) { + name + filmConnection { + films { + title + } + } + } + } + """, + OperationName = "PersonAndFilms", + Variables = new { + id = "cGVvcGxlOjE=" + } }; ``` -### Send Request: +> [!WARNING] +> Be careful when using `byte[]` in your variables object, as most JSON serializers will treat that as binary data. +> +> If you really need to send a *list of bytes* with a `byte[]` as a source, then convert it to a `List` first, which will tell the serializer to output a list of numbers instead of a base64-encoded string. + +### Execute Query/Mutation + ```csharp -var graphQLClient = new GraphQLClient("https://swapi.apis.guru/"); -var graphQLResponse = await graphQLClient.PostAsync(heroRequest); +public class ResponseType +{ + public PersonType Person { get; set; } +} + +public class PersonType +{ + public string Name { get; set; } + public FilmConnectionType FilmConnection { get; set; } +} + +public class FilmConnectionType { + public List Films { get; set; } +} + +public class FilmContentType { + public string Title { get; set; } +} + +var graphQLResponse = await graphQLClient.SendQueryAsync(personAndFilmsRequest); + +var personName = graphQLResponse.Data.Person.Name; ``` -### Read GraphQLResponse: +Using the extension method for anonymously typed responses (namespace `GraphQL.Client.Abstractions`) you could achieve the same result with the following code: -#### Dynamic: ```csharp -var graphQLResponse = await graphQLClient.PostAsync(heroRequest); -var dynamicHeroName = graphQLResponse.Data.hero.name.Value; //Value of data->hero->name +var graphQLResponse = await graphQLClient.SendQueryAsync( + personAndFilmsRequest, + () => new { person = new PersonType()}); +var personName = graphQLResponse.Data.person.Name; ``` -#### Typed: +> [!IMPORTANT] +> Note that the field in the GraphQL response which gets deserialized into the response object is the `data` field. +> +> A common mistake is to try to directly use the `PersonType` class as response type (because thats the *thing* you actually want to query), but the returned response object contains a property `person` containing a `PersonType` object (like the `ResponseType` modelled above). + +### Use Subscriptions + ```csharp -var graphQLResponse = await graphQLClient.PostAsync(heroRequest); -var personType = graphQLResponse.GetDataFieldAs("hero"); //data->hero is casted as Person -var name = personType.Name; +public class UserJoinedSubscriptionResult { + public ChatUser UserJoined { get; set; } + + public class ChatUser { + public string DisplayName { get; set; } + public string Id { get; set; } + } +} ``` -## Useful Links: -[StarWars Example Server (GitHub)](https://github.com/graphql/swapi-graphql) -[StarWars Example Server (EndPoint)](https://swapi.apis.guru/) +#### Create subscription + +```csharp +var userJoinedRequest = new GraphQLRequest { + Query = @" + subscription { + userJoined{ + displayName + id + } + }" +}; + +IObservable> subscriptionStream + = client.CreateSubscriptionStream(userJoinedRequest); + +var subscription = subscriptionStream.Subscribe(response => + { + Console.WriteLine($"user '{response.Data.UserJoined.DisplayName}' joined") + }); +``` + +#### End Subscription + +```csharp +subscription.Dispose(); +``` + +### Automatic persisted queries (APQ) + +[Automatic persisted queries (APQ)](https://www.apollographql.com/docs/apollo-server/performance/apq/) are supported since client version 6.1.0. + +APQ can be enabled by configuring `GraphQLHttpClientOptions.EnableAutomaticPersistedQueries` to resolve to `true`. + +By default, the client will automatically disable APQ for the current session if the server responds with a `PersistedQueryNotSupported` error or a 400 or 600 HTTP status code. +This can be customized by configuring `GraphQLHttpClientOptions.DisableAPQ`. + +To re-enable APQ after it has been automatically disabled, `GraphQLHttpClient` needs to be disposed an recreated. + +APQ works by first sending a hash of the query string to the server, and only sending the full query string if the server has not yet cached a query with a matching hash. +With queries supplied as a string parameter to `GraphQLRequest`, the hash gets computed each time the request is sent. + +When you want to reuse a query string (propably to leverage APQ :wink:), declare the query using the `GraphQLQuery` class. This way, the hash gets computed once on construction +of the `GraphQLQuery` object and handed down to each `GraphQLRequest` using the query. + +```csharp +GraphQLQuery query = new(""" + query PersonAndFilms($id: ID) { + person(id: $id) { + name + filmConnection { + films { + title + } + } + } + } + """); + +var graphQLResponse = await graphQLClient.SendQueryAsync( + query, + "PersonAndFilms", + new { id = "cGVvcGxlOjE=" }); +``` + +### Syntax Highlighting for GraphQL strings in IDEs + +.NET 7.0 introduced the [StringSyntaxAttribute](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.stringsyntaxattribute?view=net-8.0) to have a unified way of telling what data is expected in a given `string` or `ReadOnlySpan`. IDEs like Visual Studio and Rider can then use this to provide syntax highlighting and checking. + +From v6.0.4 on all GraphQL string parameters in this library are decorated with the `[StringSyntax("GraphQL")]` attribute. + +Currently, there is no native support for GraphQL formatting and syntax highlighting in Visual Studio, but the [GraphQLTools Extension](https://marketplace.visualstudio.com/items?itemName=codearchitects-research.GraphQLTools) provides that for you. + +For Rider, JetBrains provides a [Plugin](https://plugins.jetbrains.com/plugin/8097-graphql), too. + +To leverage syntax highlighting in variable declarations, use the `GraphQLQuery` class. + + +## Useful Links + +* [StarWars Example Server (GitHub)](https://github.com/graphql/swapi-graphql) +* [StarWars Example Server (EndPoint)](https://swapi.apis.guru/) + +* [GitHub GraphQL API Docs](https://developer.github.com/v4/guides/forming-calls/) +* [GitHub GraphQL Explorer](https://developer.github.com/v4/explorer/) +* [GitHub GraphQL Endpoint](https://api.github.com/graphql) + +## Blazor WebAssembly Limitations -[GitHub GraphQL API Docs](https://developer.github.com/v4/guides/forming-calls/) -[GitHub GraphQL Explorer](https://developer.github.com/v4/explorer/) -[GitHub GraphQL Endpoint](https://api.github.com/graphql) +Blazor WebAssembly differs from other platforms as it does not support all features of other .NET runtime implementations. For instance, the following WebSocket options properties are not supported and will not be set: +* [ClientCertificates](https://docs.microsoft.com/en-us/dotnet/api/system.net.websockets.clientwebsocketoptions.clientcertificates?view=netcore-3.1#System_Net_WebSockets_ClientWebSocketOptions_ClientCertificates) +* [UseDefaultCredentials](https://docs.microsoft.com/en-us/dotnet/api/system.net.websockets.clientwebsocketoptions.usedefaultcredentials?view=netcore-3.1) diff --git a/SubscriptionIntegrationTest.ConsoleClient/Program.cs b/SubscriptionIntegrationTest.ConsoleClient/Program.cs new file mode 100644 index 00000000..95000512 --- /dev/null +++ b/SubscriptionIntegrationTest.ConsoleClient/Program.cs @@ -0,0 +1,135 @@ +using System; +using System.Net.WebSockets; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GraphQL.Client.Http; +using GraphQL.Common.Request; + +namespace SubsccriptionIntegrationTest.ConsoleClient +{ + class Program + { + static async Task Main(string[] args) + { + Console.WriteLine("configuring client ..."); + using (var client = new GraphQLHttpClient("http://localhost:5000/graphql/", new GraphQLHttpClientOptions{ UseWebSocketForQueriesAndMutations = true })) + { + + Console.WriteLine("subscribing to message stream ..."); + + var subscriptions = new CompositeDisposable(); + + subscriptions.Add(client.WebSocketReceiveErrors.Subscribe(e => { + if(e is WebSocketException we) + Console.WriteLine($"WebSocketException: {we.Message} (WebSocketError {we.WebSocketErrorCode}, ErrorCode {we.ErrorCode}, NativeErrorCode {we.NativeErrorCode}"); + else + Console.WriteLine($"Exception in websocket receive stream: {e.ToString()}"); + })); + + subscriptions.Add(CreateSubscription("1", client)); + await Task.Delay(200); + subscriptions.Add(CreateSubscription2("2", client)); + await Task.Delay(200); + subscriptions.Add(CreateSubscription("3", client)); + await Task.Delay(200); + subscriptions.Add(CreateSubscription("4", client)); + await Task.Delay(200); + subscriptions.Add(CreateSubscription("5", client)); + await Task.Delay(200); + subscriptions.Add(CreateSubscription("6", client)); + await Task.Delay(200); + subscriptions.Add(CreateSubscription("7", client)); + + using (subscriptions) + { + Console.WriteLine("client setup complete"); + var quit = false; + do + { + Console.WriteLine("write message and press enter..."); + var message = Console.ReadLine(); + var graphQLRequest = new GraphQLRequest(@" + mutation($input: MessageInputType){ + addMessage(message: $input){ + content + } + }") + { + Variables = new + { + input = new + { + fromId = "2", + content = message, + sentAt = DateTime.Now + } + } + }; + var result = await client.SendMutationAsync(graphQLRequest).ConfigureAwait(false); + + if(result.Errors != null && result.Errors.Length > 0) + { + Console.WriteLine($"request returned {result.Errors.Length} errors:"); + foreach (var item in result.Errors) + { + Console.WriteLine($"{item.Message}"); + } + } + } + while(!quit); + Console.WriteLine("shutting down ..."); + } + Console.WriteLine("subscriptions disposed ..."); + } + Console.WriteLine("client disposed ..."); + } + + private static IDisposable CreateSubscription(string id, GraphQLHttpClient client) + { +#pragma warning disable 618 + var stream = client.CreateSubscriptionStream(new GraphQLRequest(@" + subscription { + messageAdded{ + content + from { + displayName + } + } + }" + ) + { Variables = new { id } }); +#pragma warning restore 618 + + return stream.Subscribe( + response => Console.WriteLine($"{id}: new message from \"{response.Data.messageAdded.from.displayName.Value}\": {response.Data.messageAdded.content.Value}"), + exception => Console.WriteLine($"{id}: message subscription stream failed: {exception}"), + () => Console.WriteLine($"{id}: message subscription stream completed")); + + } + + + private static IDisposable CreateSubscription2(string id, GraphQLHttpClient client) + { +#pragma warning disable 618 + var stream = client.CreateSubscriptionStream(new GraphQLRequest(@" + subscription { + contentAdded{ + content + from { + displayName + } + } + }" + ) + { Variables = new { id } }); +#pragma warning restore 618 + + return stream.Subscribe( + response => Console.WriteLine($"{id}: new content from \"{response.Data.contentAdded.from.displayName.Value}\": {response.Data.contentAdded.content.Value}"), + exception => Console.WriteLine($"{id}: content subscription stream failed: {exception}"), + () => Console.WriteLine($"{id}: content subscription stream completed")); + + } + } +} diff --git a/SubscriptionIntegrationTest.ConsoleClient/SubscriptionIntegrationTest.ConsoleClient.csproj b/SubscriptionIntegrationTest.ConsoleClient/SubscriptionIntegrationTest.ConsoleClient.csproj new file mode 100644 index 00000000..83d4884b --- /dev/null +++ b/SubscriptionIntegrationTest.ConsoleClient/SubscriptionIntegrationTest.ConsoleClient.csproj @@ -0,0 +1,18 @@ + + + + Exe + netcoreapp3.0;net461 + 8.0 + + + + + + + + + + + + diff --git a/dotnet-tools.json b/dotnet-tools.json index 80b96672..e5146732 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -1,11 +1,17 @@ { - "isRoot": true, - "tools": { - "dotnet-format": { - "commands": [ - "dotnet-format" - ], - "version": "3.1.37601" - } - } -} + "isRoot": true, + "tools": { + "dotnet-format": { + "version": "3.2.107702", + "commands": [ + "dotnet-format" + ] + }, + "gitversion.tool": { + "version": "5.12.0", + "commands": [ + "dotnet-gitversion" + ] + } + } +} \ No newline at end of file diff --git a/examples/.editorconfig b/examples/.editorconfig new file mode 100644 index 00000000..d1655ff8 --- /dev/null +++ b/examples/.editorconfig @@ -0,0 +1,2 @@ +# Configure await +configure_await_analysis_mode = disabled diff --git a/examples/GraphQL.Client.Example/GraphQL.Client.Example.csproj b/examples/GraphQL.Client.Example/GraphQL.Client.Example.csproj new file mode 100644 index 00000000..07866dfc --- /dev/null +++ b/examples/GraphQL.Client.Example/GraphQL.Client.Example.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8 + false + + + + + + + + diff --git a/examples/GraphQL.Client.Example/PersonAndFilmsResponse.cs b/examples/GraphQL.Client.Example/PersonAndFilmsResponse.cs new file mode 100644 index 00000000..3a66edc7 --- /dev/null +++ b/examples/GraphQL.Client.Example/PersonAndFilmsResponse.cs @@ -0,0 +1,23 @@ +namespace GraphQL.Client.Example; + +public class PersonAndFilmsResponse +{ + public PersonContent Person { get; set; } + + public class PersonContent + { + public string Name { get; set; } + + public FilmConnectionContent FilmConnection { get; set; } + + public class FilmConnectionContent + { + public List Films { get; set; } + + public class FilmContent + { + public string Title { get; set; } + } + } + } +} diff --git a/examples/GraphQL.Client.Example/Program.cs b/examples/GraphQL.Client.Example/Program.cs new file mode 100644 index 00000000..83f8da0a --- /dev/null +++ b/examples/GraphQL.Client.Example/Program.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using GraphQL.Client.Http; +using GraphQL.Client.Serializer.Newtonsoft; + +namespace GraphQL.Client.Example; + +public static class Program +{ + public static async Task Main() + { + using var graphQLClient = new GraphQLHttpClient("https://swapi.apis.guru/", new NewtonsoftJsonSerializer()); + + var personAndFilmsRequest = new GraphQLRequest + { + Query = @" + query PersonAndFilms($id: ID) { + person(id: $id) { + name + filmConnection { + films { + title + } + } + } + }", + OperationName = "PersonAndFilms", + Variables = new + { + id = "cGVvcGxlOjE=" + } + }; + + var graphQLResponse = await graphQLClient.SendQueryAsync(personAndFilmsRequest); + Console.WriteLine("raw response:"); + Console.WriteLine(JsonSerializer.Serialize(graphQLResponse, new JsonSerializerOptions { WriteIndented = true })); + + Console.WriteLine(); + Console.WriteLine($"Name: {graphQLResponse.Data.Person.Name}"); + var films = string.Join(", ", graphQLResponse.Data.Person.FilmConnection.Films.Select(f => f.Title)); + Console.WriteLine($"Films: {films}"); + + Console.WriteLine(); + Console.WriteLine("Press any key to quit..."); + Console.ReadKey(); + } +} diff --git a/examples/GraphQL.Client.Http.Examples/GraphQL.Client.Http.Examples.csproj b/examples/GraphQL.Client.Http.Examples/GraphQL.Client.Http.Examples.csproj deleted file mode 100644 index 959525f5..00000000 --- a/examples/GraphQL.Client.Http.Examples/GraphQL.Client.Http.Examples.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - Exe - netcoreapp3.1 - - - - - - - - - - - - diff --git a/examples/GraphQL.Client.Http.Examples/Program.cs b/examples/GraphQL.Client.Http.Examples/Program.cs deleted file mode 100644 index 50618a1d..00000000 --- a/examples/GraphQL.Client.Http.Examples/Program.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using GraphQL.Server.Test.GraphQL.Models; -using Microsoft.AspNetCore.TestHost; - -namespace GraphQL.Client.Http.Examples { - - public class Program { - - private static readonly TestServer testServer = new TestServer(Server.Test.Program.CreateHostBuilder()) { - AllowSynchronousIO = true - }; - - public async static Task Main(string[] args) { - using var httpClient = testServer.CreateClient(); - using var graphqlClient = httpClient.AsGraphQLClient($"{testServer.BaseAddress}graphql"); - var graphQLHttpRequest = new GraphQLHttpRequest { - Query = @" - { - repository(owner: ""graphql-dotnet"", name: ""graphql-client"") { - databaseId, - id, - name, - url - } - }" - }; - var graphQLHttpResponse = await graphqlClient.SendHttpQueryAsync(graphQLHttpRequest); - Console.WriteLine(JsonSerializer.Serialize(graphQLHttpResponse, new JsonSerializerOptions { WriteIndented = true })); - } - - private class Schema { - - public Repository Repository { get; set; } - - } - - } - -} diff --git a/root.props b/root.props deleted file mode 100644 index 8725d564..00000000 --- a/root.props +++ /dev/null @@ -1,40 +0,0 @@ - - - - - Deinok,graphql-dotnet - A GraphQL Client for .NET Standard - True - True - 8.0 - en-US - CS0618;CS1591;CS1701;CS8618;NU5048;NU5105;NU5125 - annotations - icon.png - LICENSE.txt - https://github.com/graphql-dotnet/graphql-client - true - GraphQL - git - https://github.com/graphql-dotnet/graphql-client.git - True - 2.0.0-alpha.4 - 4 - - - - - PreserveNewest - true - LICENSE.txt - false - - - PreserveNewest - true - icon.png - false - - - - diff --git a/src/GraphQL.Client.Abstractions.Websocket/GraphQL.Client.Abstractions.Websocket.csproj b/src/GraphQL.Client.Abstractions.Websocket/GraphQL.Client.Abstractions.Websocket.csproj new file mode 100644 index 00000000..5960fbf8 --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQL.Client.Abstractions.Websocket.csproj @@ -0,0 +1,12 @@ + + + + Abstractions for the Websocket transport used in GraphQL.Client + netstandard2.0 + + + + + + + diff --git a/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketMessageType.cs b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketMessageType.cs new file mode 100644 index 00000000..fbc3a5f5 --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketMessageType.cs @@ -0,0 +1,137 @@ +namespace GraphQL.Client.Abstractions.Websocket; + +public static class GraphQLWebSocketMessageType +{ + + /// + /// Client sends this message after plain websocket connection to start the communication with the server + /// The server will response only with GQL_CONNECTION_ACK + GQL_CONNECTION_KEEP_ALIVE(if used) or GQL_CONNECTION_ERROR + /// to this message. + /// payload: Object : optional parameters that the client specifies in connectionParams + /// + public const string GQL_CONNECTION_INIT = "connection_init"; + + /// + /// The server may responses with this message to the GQL_CONNECTION_INIT from client, indicates the server accepted + /// the connection. + /// + public const string GQL_CONNECTION_ACK = "connection_ack"; // Server -> Client + + /// + /// The server may responses with this message to the GQL_CONNECTION_INIT from client, indicates the server rejected + /// the connection. + /// It server also respond with this message in case of a parsing errors of the message (which does not disconnect the + /// client, just ignore the message). + /// payload: Object: the server side error + /// + public const string GQL_CONNECTION_ERROR = "connection_error"; // Server -> Client + + /// + /// Server message that should be sent right after each GQL_CONNECTION_ACK processed and then periodically to keep the + /// client connection alive. + /// The client starts to consider the keep alive message only upon the first received keep alive message from the + /// server. + /// + /// NOTE: This one here don't follow the standard due to connection optimization + /// + /// + public const string GQL_CONNECTION_KEEP_ALIVE = "ka"; // Server -> Client + + /// + /// Client sends this message to terminate the connection. + /// + public const string GQL_CONNECTION_TERMINATE = "connection_terminate"; // Client -> Server + + /// + /// Client sends this message to execute GraphQL operation + /// id: string : The id of the GraphQL operation to start + /// payload: Object: + /// query: string : GraphQL operation as string or parsed GraphQL document node + /// variables?: Object : Object with GraphQL variables + /// operationName?: string : GraphQL operation name + /// + public const string GQL_START = "start"; + + /// + /// The server sends this message to transfer the GraphQL execution result from the server to the client, this message + /// is a response for GQL_START message. + /// For each GraphQL operation send with GQL_START, the server will respond with at least one GQL_DATA message. + /// id: string : ID of the operation that was successfully set up + /// payload: Object : + /// data: any: Execution result + /// errors?: Error[] : Array of resolvers errors + /// + public const string GQL_DATA = "data"; // Server -> Client + + /// + /// Server sends this message upon a failing operation, before the GraphQL execution, usually due to GraphQL validation + /// errors (resolver errors are part of GQL_DATA message, and will be added as errors array) + /// payload: Error : payload with the error attributed to the operation failing on the server + /// id: string : operation ID of the operation that failed on the server + /// + public const string GQL_ERROR = "error"; // Server -> Client + + /// + /// Server -> Client: Server sends this message to indicate that a GraphQL operation is done, and no more data will arrive + /// for the specific operation. + /// Client -> Server: "indicates that the client has stopped listening and wants to complete the subscription. No further + /// events, relevant to the original subscription, should be sent through. Even if the client sent a Complete message for + /// a single-result-operation before it resolved, the result should not be sent through once it does." + /// Replaces the GQL_STOP in graphql-transport-ws + /// id: string : operation ID of the operation that completed + /// + public const string GQL_COMPLETE = "complete"; // Server -> Client and Client -> Server + + /// + /// Client sends this message in order to stop a running GraphQL operation execution (for example: unsubscribe) + /// id: string : operation id + /// + public const string GQL_STOP = "stop"; // Client -> Server + + + // Additional types for graphql-transport-ws, as described in https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md + + /// + /// Bidirectional. "Useful for detecting failed connections, displaying latency metrics or other types of network probing. + /// A Pong must be sent in response from the receiving party as soon as possible. The Ping message can be sent at any time + /// within the established socket. The optional payload field can be used to transfer additional details about the ping." + /// payload: Object: ping details + /// + public const string GQL_PING = "ping"; // Bidirectional + + /// + /// Bidirectional. "The response to the Ping message. Must be sent as soon as the Ping message is received. + /// The Pong message can be sent at any time within the established socket. Furthermore, the Pong message + /// may even be sent unsolicited as an unidirectional heartbeat. The optional payload field can be used to + /// transfer additional details about the pong." + /// payload: Object: pong details + /// + public const string GQL_PONG = "pong"; // Bidirectional + + /// + /// Client-> Server. "Requests an operation specified in the message payload. This message provides a unique + /// ID field to connect published messages to the operation requested by this message. If there is already an + /// active subscriber for an operation matching the provided ID, regardless of the operation type, the server + /// must close the socket immediately with the event 4409: Subscriber for *unique-operation-id* already exists. + /// The server needs only keep track of IDs for as long as the subscription is active. Once a client completes + /// an operation, it is free to re-use that ID." + /// id: string : operation id + /// payload: Object: + /// operationName : string : subscribe + /// query : string : the subscription query + /// variables : Dictionary(string, string) : a dictionary with variables and their values + /// extensions : Dictionary(string, string) : a dictionary of extensions + /// + public const string GQL_SUBSCRIBE = "subscribe"; // Client -> Server + + + /// + /// Server -> Client. "Operation execution result(s) from the source stream created by the binding Subscribe + /// message. After all results have been emitted, the Complete message will follow indicating stream + /// completion." + /// id: string : operation id + /// payload: Object: ExecutionResult + /// + + public const string GQL_NEXT = "next"; // Server -> Client +} diff --git a/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketRequest.cs b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketRequest.cs new file mode 100644 index 00000000..69c8593e --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketRequest.cs @@ -0,0 +1,107 @@ +namespace GraphQL.Client.Abstractions.Websocket; + +/// +/// A Subscription Request +/// +public class GraphQLWebSocketRequest : Dictionary, IEquatable +{ + public const string ID_KEY = "id"; + public const string TYPE_KEY = "type"; + public const string PAYLOAD_KEY = "payload"; + + /// + /// The Identifier of the request + /// + public string Id + { + get => TryGetValue(ID_KEY, out object value) ? (string)value : null; + set => this[ID_KEY] = value; + } + + /// + /// The Type of the Request + /// + public string Type + { + get => TryGetValue(TYPE_KEY, out object value) ? (string)value : null; + set => this[TYPE_KEY] = value; + } + + /// + /// The payload of the websocket request + /// + public object? Payload + { + get => TryGetValue(PAYLOAD_KEY, out object value) ? value : null; + set => this[PAYLOAD_KEY] = value; + } + + private readonly TaskCompletionSource _tcs = new TaskCompletionSource(); + + /// + /// Task used to await the actual send operation and to convey potential exceptions + /// + /// + public Task SendTask() => _tcs.Task; + + /// + /// gets called when the send operation for this request has completed successfully + /// + public void SendCompleted() => _tcs.SetResult(true); + + /// + /// gets called when an exception occurs during the send operation + /// + /// + public void SendFailed(Exception e) => _tcs.SetException(e); + + /// + /// gets called when the GraphQLHttpWebSocket has been disposed before the send operation for this request has started + /// + public void SendCanceled() => _tcs.SetCanceled(); + + /// + public override bool Equals(object obj) => Equals(obj as GraphQLWebSocketRequest); + + /// + public bool Equals(GraphQLWebSocketRequest other) + { + if (other == null) + { + return false; + } + if (ReferenceEquals(this, other)) + { + return true; + } + if (!Equals(Id, other.Id)) + { + return false; + } + if (!Equals(Type, other.Type)) + { + return false; + } + if (!Equals(Payload, other.Payload)) + { + return false; + } + return true; + } + + /// + public override int GetHashCode() + { + var hashCode = 9958074; + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Id); + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Type); + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Payload); + return hashCode; + } + + /// + public static bool operator ==(GraphQLWebSocketRequest request1, GraphQLWebSocketRequest request2) => EqualityComparer.Default.Equals(request1, request2); + + /// + public static bool operator !=(GraphQLWebSocketRequest request1, GraphQLWebSocketRequest request2) => !(request1 == request2); +} diff --git a/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketResponse.cs b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketResponse.cs new file mode 100644 index 00000000..f1241fbc --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebSocketResponse.cs @@ -0,0 +1,96 @@ +namespace GraphQL.Client.Abstractions.Websocket; + +/// +/// A Subscription Response +/// +public class GraphQLWebSocketResponse : IEquatable +{ + /// + /// The Identifier of the Response + /// + public string Id { get; set; } + + /// + /// The Type of the Response + /// + public string Type { get; set; } + + /// + public override bool Equals(object obj) => Equals(obj as GraphQLWebSocketResponse); + + /// + public bool Equals(GraphQLWebSocketResponse other) + { + if (other == null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (!Equals(Id, other.Id)) + { + return false; + } + + if (!Equals(Type, other.Type)) + { + return false; + } + + return true; + } + + /// + public override int GetHashCode() + { + var hashCode = 9958074; + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Id); + hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(Type); + return hashCode; + } + + /// + public static bool operator ==(GraphQLWebSocketResponse response1, GraphQLWebSocketResponse response2) => + EqualityComparer.Default.Equals(response1, response2); + + /// + public static bool operator !=(GraphQLWebSocketResponse response1, GraphQLWebSocketResponse response2) => + !(response1 == response2); +} + +public class GraphQLWebSocketResponse : GraphQLWebSocketResponse, IEquatable> +{ + public TPayload Payload { get; set; } + + public bool Equals(GraphQLWebSocketResponse? other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + return base.Equals(other) && Payload.Equals(other.Payload); + } + + public override bool Equals(object? obj) + { + if (obj is null) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (obj.GetType() != GetType()) + return false; + return Equals((GraphQLWebSocketResponse)obj); + } + + public override int GetHashCode() + { + unchecked + { + return (base.GetHashCode() * 397) ^ Payload.GetHashCode(); + } + } +} diff --git a/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebsocketConnectionState.cs b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebsocketConnectionState.cs new file mode 100644 index 00000000..e12957a1 --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/GraphQLWebsocketConnectionState.cs @@ -0,0 +1,10 @@ +namespace GraphQL.Client.Abstractions.Websocket; + +public enum GraphQLWebsocketConnectionState +{ + Disconnected, + + Connecting, + + Connected +} diff --git a/src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebSocketClient.cs b/src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebSocketClient.cs new file mode 100644 index 00000000..63c92362 --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebSocketClient.cs @@ -0,0 +1,45 @@ +namespace GraphQL.Client.Abstractions.Websocket; + +public interface IGraphQLWebSocketClient : IGraphQLClient +{ + /// + /// The negotiated websocket sub-protocol. Will be while no websocket connection is established. + /// + string? WebSocketSubProtocol { get; } + + /// + /// Publishes all exceptions which occur inside the websocket receive stream (i.e. for logging purposes) + /// + IObservable WebSocketReceiveErrors { get; } + + /// + /// Publishes the websocket connection state + /// + IObservable WebsocketConnectionState { get; } + + /// + /// Explicitly opens the websocket connection. Will be closed again on disposing the last subscription. + /// + Task InitializeWebsocketConnection(); + + /// + /// Publishes the payload of all received pong messages (which may be ). Subscribing initiates the websocket connection.
+ /// Ping/Pong is only supported when using the "graphql-transport-ws" websocket sub-protocol. + ///
+ /// the negotiated websocket sub-protocol does not support ping/pong + IObservable PongStream { get; } + + /// + /// Sends a ping to the server.
+ /// Ping/Pong is only supported when using the "graphql-transport-ws" websocket sub-protocol. + ///
+ /// the negotiated websocket sub-protocol does not support ping/pong + Task SendPingAsync(object? payload); + + /// + /// Sends a pong to the server. This can be used for keep-alive scenarios (the client will automatically respond to pings received from the server).
+ /// Ping/Pong is only supported when using the "graphql-transport-ws" websocket sub-protocol. + ///
+ /// the negotiated websocket sub-protocol does not support ping/pong + Task SendPongAsync(object? payload); +} diff --git a/src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebsocketJsonSerializer.cs b/src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebsocketJsonSerializer.cs new file mode 100644 index 00000000..e171fe8a --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/IGraphQLWebsocketJsonSerializer.cs @@ -0,0 +1,14 @@ +namespace GraphQL.Client.Abstractions.Websocket; + +/// +/// The json serializer interface for the graphql-dotnet http client. +/// Implementations should provide a parameterless constructor for convenient usage +/// +public interface IGraphQLWebsocketJsonSerializer : IGraphQLJsonSerializer +{ + byte[] SerializeToBytes(GraphQLWebSocketRequest request); + + Task DeserializeToWebsocketResponseWrapperAsync(Stream stream); + + GraphQLWebSocketResponse DeserializeToWebsocketResponse(byte[] bytes); +} diff --git a/src/GraphQL.Client.Abstractions.Websocket/WebsocketMessageWrapper.cs b/src/GraphQL.Client.Abstractions.Websocket/WebsocketMessageWrapper.cs new file mode 100644 index 00000000..a94cb8a6 --- /dev/null +++ b/src/GraphQL.Client.Abstractions.Websocket/WebsocketMessageWrapper.cs @@ -0,0 +1,10 @@ +using System.Runtime.Serialization; + +namespace GraphQL.Client.Abstractions.Websocket; + +public class WebsocketMessageWrapper : GraphQLWebSocketResponse +{ + + [IgnoreDataMember] + public byte[] MessageBytes { get; set; } +} diff --git a/src/GraphQL.Client.Abstractions/GraphQL.Client.Abstractions.csproj b/src/GraphQL.Client.Abstractions/GraphQL.Client.Abstractions.csproj new file mode 100644 index 00000000..4fa8f98f --- /dev/null +++ b/src/GraphQL.Client.Abstractions/GraphQL.Client.Abstractions.csproj @@ -0,0 +1,12 @@ + + + + Abstractions for GraphQL.Client + netstandard2.0;net6.0;net7.0;net8.0 + + + + + + + diff --git a/src/GraphQL.Client.Abstractions/GraphQLClientExtensions.cs b/src/GraphQL.Client.Abstractions/GraphQLClientExtensions.cs new file mode 100644 index 00000000..c2e4bb7c --- /dev/null +++ b/src/GraphQL.Client.Abstractions/GraphQLClientExtensions.cs @@ -0,0 +1,65 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GraphQL.Client.Abstractions; + +public static class GraphQLClientExtensions +{ + public static Task> SendQueryAsync(this IGraphQLClient client, + [StringSyntax("GraphQL")] string query, object? variables = null, + string? operationName = null, Func? defineResponseType = null, CancellationToken cancellationToken = default) + { + _ = defineResponseType; + return client.SendQueryAsync(new GraphQLRequest(query, variables, operationName), + cancellationToken: cancellationToken); + } + + public static Task> SendQueryAsync(this IGraphQLClient client, + GraphQLQuery query, object? variables = null, + string? operationName = null, Func? defineResponseType = null, + CancellationToken cancellationToken = default) + => SendQueryAsync(client, query.Text, variables, operationName, defineResponseType, + cancellationToken); + + public static Task> SendMutationAsync(this IGraphQLClient client, + [StringSyntax("GraphQL")] string query, object? variables = null, + string? operationName = null, Func? defineResponseType = null, CancellationToken cancellationToken = default) + { + _ = defineResponseType; + return client.SendMutationAsync(new GraphQLRequest(query, variables, operationName), + cancellationToken: cancellationToken); + } + + public static Task> SendMutationAsync(this IGraphQLClient client, + GraphQLQuery query, object? variables = null, string? operationName = null, Func? defineResponseType = null, + CancellationToken cancellationToken = default) + => SendMutationAsync(client, query.Text, variables, operationName, defineResponseType, + cancellationToken); + + public static Task> SendQueryAsync(this IGraphQLClient client, + GraphQLRequest request, Func defineResponseType, CancellationToken cancellationToken = default) + { + _ = defineResponseType; + return client.SendQueryAsync(request, cancellationToken); + } + + public static Task> SendMutationAsync(this IGraphQLClient client, + GraphQLRequest request, Func defineResponseType, CancellationToken cancellationToken = default) + { + _ = defineResponseType; + return client.SendMutationAsync(request, cancellationToken); + } + + public static IObservable> CreateSubscriptionStream( + this IGraphQLClient client, GraphQLRequest request, Func defineResponseType) + { + _ = defineResponseType; + return client.CreateSubscriptionStream(request); + } + + public static IObservable> CreateSubscriptionStream( + this IGraphQLClient client, GraphQLRequest request, Func defineResponseType, Action exceptionHandler) + { + _ = defineResponseType; + return client.CreateSubscriptionStream(request, exceptionHandler); + } +} diff --git a/src/GraphQL.Client.Abstractions/GraphQLJsonSerializerExtensions.cs b/src/GraphQL.Client.Abstractions/GraphQLJsonSerializerExtensions.cs new file mode 100644 index 00000000..248f594a --- /dev/null +++ b/src/GraphQL.Client.Abstractions/GraphQLJsonSerializerExtensions.cs @@ -0,0 +1,13 @@ +namespace GraphQL.Client.Abstractions; + +public static class GraphQLJsonSerializerExtensions +{ + public static TOptions New(this Action configure) => + configure.AndReturn(Activator.CreateInstance()); + + public static TOptions AndReturn(this Action configure, TOptions options) + { + configure(options); + return options; + } +} diff --git a/src/GraphQL.Client.Abstractions/IGraphQLClient.cs b/src/GraphQL.Client.Abstractions/IGraphQLClient.cs new file mode 100644 index 00000000..e868a20e --- /dev/null +++ b/src/GraphQL.Client.Abstractions/IGraphQLClient.cs @@ -0,0 +1,31 @@ +using System.Net.WebSockets; + +namespace GraphQL.Client.Abstractions; + +public interface IGraphQLClient +{ + Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default); + + Task> SendMutationAsync(GraphQLRequest request, CancellationToken cancellationToken = default); + + /// + /// Creates a subscription to a GraphQL server. The connection is not established until the first actual subscription is made.
+ /// All subscriptions made to this stream share the same hot observable.
+ /// The stream must be recreated completely after an error has occurred within its logic (i.e. a ) + ///
+ /// the GraphQL request for this subscription + /// an observable stream for the specified subscription + IObservable> CreateSubscriptionStream(GraphQLRequest request); + + /// + /// Creates a subscription to a GraphQL server. The connection is not established until the first actual subscription is made.
+ /// All subscriptions made to this stream share the same hot observable.
+ /// All s are passed to the to be handled externally.
+ /// If the completes normally, the subscription is recreated with a new connection attempt.
+ /// Any exception thrown by will cause the sequence to fail. + ///
+ /// the GraphQL request for this subscription + /// an external handler for all s occurring within the sequence + /// an observable stream for the specified subscription + IObservable> CreateSubscriptionStream(GraphQLRequest request, Action exceptionHandler); +} diff --git a/src/GraphQL.Client.Abstractions/IGraphQLJsonSerializer.cs b/src/GraphQL.Client.Abstractions/IGraphQLJsonSerializer.cs new file mode 100644 index 00000000..28690947 --- /dev/null +++ b/src/GraphQL.Client.Abstractions/IGraphQLJsonSerializer.cs @@ -0,0 +1,8 @@ +namespace GraphQL.Client.Abstractions; + +public interface IGraphQLJsonSerializer +{ + string SerializeToString(GraphQLRequest request); + + Task> DeserializeFromUtf8StreamAsync(Stream stream, CancellationToken cancellationToken); +} diff --git a/src/GraphQL.Client.Abstractions/Utilities/StringExtensions.cs b/src/GraphQL.Client.Abstractions/Utilities/StringExtensions.cs new file mode 100644 index 00000000..cc99a85e --- /dev/null +++ b/src/GraphQL.Client.Abstractions/Utilities/StringExtensions.cs @@ -0,0 +1,34 @@ +namespace GraphQL.Client.Abstractions.Utilities; + +/// +/// Copied from https://github.com/jquense/StringUtils +/// +public static class StringExtensions +{ + public static string StripIndent(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.StripIndent(str); + + public static IEnumerable ToWords(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToWords(str); + + public static string ToUpperFirst(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToUpperFirst(str); + + public static string ToLowerFirst(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToLowerFirst(str); + + public static string Capitalize(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.Capitalize(str); + + public static string ToCamelCase(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToCamelCase(str); + + public static string ToConstantCase(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToConstantCase(str); + + public static string ToUpperCase(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToUpperCase(str); + + public static string ToLowerCase(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToLowerCase(str); + + + public static string ToPascalCase(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToPascalCase(str); + + + public static string ToKebabCase(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToKebabCase(str); + + + public static string ToSnakeCase(this string str) => GraphQL.Client.Abstractions.Utilities.StringUtils.ToSnakeCase(str); +} diff --git a/src/GraphQL.Client.Abstractions/Utilities/StringUtils.cs b/src/GraphQL.Client.Abstractions/Utilities/StringUtils.cs new file mode 100644 index 00000000..87047f79 --- /dev/null +++ b/src/GraphQL.Client.Abstractions/Utilities/StringUtils.cs @@ -0,0 +1,189 @@ +using System.Text.RegularExpressions; + +namespace GraphQL.Client.Abstractions.Utilities; + +/// +/// Copied from https://github.com/jquense/StringUtils +/// +public static class StringUtils +{ + private static readonly Regex _reWords = new Regex(@"[A-Z\xc0-\xd6\xd8-\xde]?[a-z\xdf-\xf6\xf8-\xff]+(?:['’](?:d|ll|m|re|s|t|ve))?(?=[\xac\xb1\xd7\xf7\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\xbf\u2000-\u206f \t\x0b\f\xa0\ufeff\n\r\u2028\u2029\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000]|[A-Z\xc0-\xd6\xd8-\xde]|$)|(?:[A-Z\xc0-\xd6\xd8-\xde]|[^\ud800-\udfff\xac\xb1\xd7\xf7\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\xbf\u2000-\u206f \t\x0b\f\xa0\ufeff\n\r\u2028\u2029\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\d+\u2700-\u27bfa-z\xdf-\xf6\xf8-\xffA-Z\xc0-\xd6\xd8-\xde])+(?:['’](?:D|LL|M|RE|S|T|VE))?(?=[\xac\xb1\xd7\xf7\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\xbf\u2000-\u206f \t\x0b\f\xa0\ufeff\n\r\u2028\u2029\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000]|[A-Z\xc0-\xd6\xd8-\xde](?:[a-z\xdf-\xf6\xf8-\xff]|[^\ud800-\udfff\xac\xb1\xd7\xf7\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\xbf\u2000-\u206f \t\x0b\f\xa0\ufeff\n\r\u2028\u2029\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\d+\u2700-\u27bfa-z\xdf-\xf6\xf8-\xffA-Z\xc0-\xd6\xd8-\xde])|$)|[A-Z\xc0-\xd6\xd8-\xde]?(?:[a-z\xdf-\xf6\xf8-\xff]|[^\ud800-\udfff\xac\xb1\xd7\xf7\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\xbf\u2000-\u206f \t\x0b\f\xa0\ufeff\n\r\u2028\u2029\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\d+\u2700-\u27bfa-z\xdf-\xf6\xf8-\xffA-Z\xc0-\xd6\xd8-\xde])+(?:['’](?:d|ll|m|re|s|t|ve))?|[A-Z\xc0-\xd6\xd8-\xde]+(?:['’](?:D|LL|M|RE|S|T|VE))?|\d+|(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff])[\ufe0e\ufe0f]?(?:[\u0300-\u036f\ufe20-\ufe23\u20d0-\u20f0]|\ud83c[\udffb-\udfff])?(?:\u200d(?:[^\ud800-\udfff]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff])[\ufe0e\ufe0f]?(?:[\u0300-\u036f\ufe20-\ufe23\u20d0-\u20f0]|\ud83c[\udffb-\udfff])?)*"); + private static readonly Regex _reIndent = new Regex(@"^[ \t]*(?=\S)", RegexOptions.Multiline); + + /// + /// Removes the leading indent from a multi-line string + /// + /// String + /// + public static string StripIndent(string str) + { + int indent = _reIndent.Matches(str).Cast().Select(m => m.Value.Length).Min(); + return new Regex(@"^[ \t]{" + indent + "}", RegexOptions.Multiline).Replace(str, ""); + } + + /// + /// Split a cased string into a series of "words" excluding the seperator. + /// + /// + /// + public static IEnumerable ToWords(string str) + { + foreach (Match match in _reWords.Matches(str)) + { + yield return match.Value; + } + } + + /// + /// Uppercase the first character in a string, leaving the rest of the string as is + /// + /// + /// a string with the first character uppercased + public static string ToUpperFirst(string str) => ChangeCaseFirst(str, c => c.ToUpperInvariant()); + + /// + /// Lowercase the first character in a string, leaving the rest of the string as is + /// + /// + /// a string with the first character lowercased + public static string ToLowerFirst(string str) => ChangeCaseFirst(str, c => c.ToLowerInvariant()); + + /// + /// Capitalizes a string, lowercasing the entire string and uppercasing the first character + /// + /// + /// a capitalized string + public static string Capitalize(string str) => ToUpperFirst(str.ToLowerInvariant()); + + /// + /// Converts a string to camelCase. + /// + /// + /// StringUtils.ToCamelCase("FOOBAR") // "foobar" + /// StringUtils.ToCamelCase("FOO_BAR") // "fooBar" + /// StringUtils.ToCamelCase("FooBar") // "fooBar" + /// StringUtils.ToCamelCase("foo bar") // "fooBar" + /// + /// + /// + public static string ToCamelCase(string str) => + ChangeCase(str, (word, index) => + (index == 0 ? word.ToLowerInvariant() : Capitalize(word))); + + /// + /// Convert a string to CONSTANT_CASE + /// + /// + /// StringUtils.ToConstantCase("fOo BaR") // "FOO_BAR" + /// StringUtils.ToConstantCase("FooBar") // "FOO_BAR" + /// StringUtils.ToConstantCase("Foo Bar") // "FOO_BAR" + /// + /// + /// + public static string ToConstantCase(string str) => ChangeCase(str, "_", w => w.ToUpperInvariant()); + + /// + /// Convert a string to UPPERCASE + /// + /// + /// StringUtils.ToUpperCase("foobar") // "FOOBAR" + /// StringUtils.ToUpperCase("FOO_BAR") // "FOO BAR" + /// StringUtils.ToUpperCase("FooBar") // "FOO BAR" + /// StringUtils.ToUpperCase("Foo Bar") // "FOO BAR" + /// + /// + /// + public static string ToUpperCase(string str) => ChangeCase(str, " ", (word) => word.ToUpperInvariant()); + + /// + /// Convert a string to lowercase + /// + /// + /// StringUtils.ToLowerCase("FOOBAR") // "foobar" + /// StringUtils.ToLowerCase("FOO_BAR") // "foo bar" + /// StringUtils.ToLowerCase("FooBar") // "foo bar" + /// StringUtils.ToLowerCase("Foo Bar") // "foo bar" + /// + /// + /// + public static string ToLowerCase(string str) => ChangeCase(str, " ", word => word.ToLowerInvariant()); + + /// + /// convert a string to PascalCase + /// + /// + /// StringUtils.ToPascalCase("FOOBAR") // "FooBar" + /// StringUtils.ToPascalCase("FOO_BAR") // "FooBar" + /// StringUtils.ToPascalCase("fooBar") // "FooBar" + /// StringUtils.ToPascalCase("Foo Bar") // "FooBar" + /// + /// + /// + public static string ToPascalCase(string str) => ChangeCase(str, Capitalize); + + /// + /// convert a string to kebab-case + /// + /// + /// StringUtils.ToKebabCase("FOOBAR") // "foo-bar" + /// StringUtils.ToKebabCase("FOO_BAR") // "foo-bar" + /// StringUtils.ToKebabCase("fooBar") // "foo-bar" + /// StringUtils.ToKebabCase("Foo Bar") // "foo-bar" + /// + /// + /// + public static string ToKebabCase(string str) => ChangeCase(str, "-", word => word.ToLowerInvariant()); + + /// + /// convert a string to snake_case + /// + /// + /// StringUtils.ToSnakeCase("FOOBAR") // "foo_bar" + /// StringUtils.ToSnakeCase("FOO_BAR") // "foo_bar" + /// StringUtils.ToSnakeCase("fooBar") // "foo_bar" + /// StringUtils.ToSnakeCase("Foo Bar") // "foo_bar" + /// + /// + /// + public static string ToSnakeCase(string str) => ChangeCase(str, "_", word => word.ToLowerInvariant()); + + public static string ChangeCase(string str, Func composer) => ChangeCase(str, "", composer); + + public static string ChangeCase(string str, string sep, Func composer) => ChangeCase(str, sep, (w, i) => composer(w)); + + public static string ChangeCase(string str, Func composer) => ChangeCase(str, "", composer); + + /// + /// Convert a string to a new case + /// + /// + /// Convert a string to inverse camelCase: CAMELcASE + /// + /// StringUtils.ChangeCase("my string", "", (word, index) => { + /// word = word.ToUpperInvariant(); + /// if (index > 0) + /// word = StringUtils.toLowerFirst(word); + /// return word + /// }); + /// // "MYsTRING" + /// + /// + /// an input string + /// a seperator string used between "words" in the string + /// a function that converts individual words to a new case + /// + public static string ChangeCase(string str, string sep, Func composer) + { + string result = ""; + int index = 0; + + foreach (string word in ToWords(str)) + { + result += ((index == 0 ? "" : sep) + composer(word, index++)); + } + + return result; + } + + private static string ChangeCaseFirst(string str, Func change) => change(str.Substring(0, 1)) + str.Substring(1); +} diff --git a/src/GraphQL.Client.Http/GraphQL.Client.Http.csproj b/src/GraphQL.Client.Http/GraphQL.Client.Http.csproj deleted file mode 100644 index 05900a76..00000000 --- a/src/GraphQL.Client.Http/GraphQL.Client.Http.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - netstandard2.0 - - - - - - - - - - - - - - diff --git a/src/GraphQL.Client.Http/GraphQLHttpClient.cs b/src/GraphQL.Client.Http/GraphQLHttpClient.cs deleted file mode 100644 index 634bcd2b..00000000 --- a/src/GraphQL.Client.Http/GraphQLHttpClient.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace GraphQL.Client.Http { - - public class GraphQLHttpClient : IDisposable, IGraphQLClient { - - public Uri EndPoint { get; set; } - - public JsonSerializerOptions JsonSerializerOptions { get; set; } = new JsonSerializerOptions { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - private readonly HttpClient httpClient; - - public GraphQLHttpClient(string endPoint) { - this.EndPoint = new Uri(endPoint); - this.httpClient = new HttpClient(); - } - - public GraphQLHttpClient(Uri endPoint) { - this.EndPoint = endPoint; - this.httpClient = new HttpClient(); - } - - public GraphQLHttpClient(string endPoint, GraphQLHttpClientOptions options) { - this.EndPoint = new Uri(endPoint); - this.httpClient = new HttpClient(); - } - - public GraphQLHttpClient(Uri endPoint, GraphQLHttpClientOptions options) { - this.EndPoint = endPoint; - this.httpClient = new HttpClient(); - } - - public GraphQLHttpClient(string endPoint, HttpClient httpClient) { - this.EndPoint = new Uri(endPoint); - this.httpClient = httpClient; - } - - public GraphQLHttpClient(Uri endPoint, HttpClient httpClient) { - this.EndPoint = endPoint; - this.httpClient = httpClient; - } - - public GraphQLHttpClient(string endPoint, GraphQLHttpClientOptions options, HttpClient httpClient) { - this.EndPoint = new Uri(endPoint); - this.httpClient = httpClient; - } - - public GraphQLHttpClient(Uri endPoint, GraphQLHttpClientOptions options, HttpClient httpClient) { - this.EndPoint = endPoint; - this.httpClient = httpClient; - } - - public void Dispose() => this.httpClient.Dispose(); - - public async Task> SendHttpQueryAsync(GraphQLHttpRequest request, CancellationToken cancellationToken = default) { - using var httpRequestMessage = this.GenerateHttpRequestMessage(request); - using var httpResponseMessage = await this.httpClient.SendAsync(httpRequestMessage, cancellationToken); - if (!httpResponseMessage.IsSuccessStatusCode) { - throw new GraphQLHttpException(httpResponseMessage); - } - - var bodyStream = await httpResponseMessage.Content.ReadAsStreamAsync(); - return await JsonSerializer.DeserializeAsync>(bodyStream, this.JsonSerializerOptions, cancellationToken); - } - - public async Task> SendHttpQueryAsync(GraphQLHttpRequest request, CancellationToken cancellationToken = default) => - await this.SendHttpQueryAsync(request, cancellationToken); - - public async Task> SendHttpMutationAsync(GraphQLHttpRequest request, CancellationToken cancellationToken = default) { - await Task.CompletedTask; - throw new NotImplementedException(); - } - - public async Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { - await Task.CompletedTask; - throw new NotImplementedException(); - } - - public async Task> SendMutationAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { - await Task.CompletedTask; - throw new NotImplementedException(); - } - - private HttpRequestMessage GenerateHttpRequestMessage(GraphQLRequest request) { - return new HttpRequestMessage(HttpMethod.Post, this.EndPoint) { - Content = new StringContent(JsonSerializer.Serialize(request, this.JsonSerializerOptions), Encoding.UTF8, "application/json") - }; - } - - public async Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { - await Task.CompletedTask; - throw new NotImplementedException(); - } - - public async Task> SendMutationAsync(GraphQLRequest request, CancellationToken cancellationToken = default) { - await Task.CompletedTask; - throw new NotImplementedException(); - } - } - -} diff --git a/src/GraphQL.Client.Http/GraphQLHttpClientOptions.cs b/src/GraphQL.Client.Http/GraphQLHttpClientOptions.cs deleted file mode 100644 index 73a00430..00000000 --- a/src/GraphQL.Client.Http/GraphQLHttpClientOptions.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Net.Http.Headers; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Serialization; - -namespace GraphQL.Client.Http { - - /// - /// The Options that the will use - /// - public class GraphQLHttpClientOptions { - - /// - /// The GraphQL EndPoint to be used - /// - public Uri EndPoint { get; set; } - - /// - /// The that is going to be used - /// - public JsonSerializerSettings JsonSerializerSettings { get; set; } = new JsonSerializerSettings { - ContractResolver = new CamelCasePropertyNamesContractResolver(), - Converters = new List - { - new StringEnumConverter() - } - }; - - /// - /// The that is going to be used - /// - public HttpMessageHandler HttpMessageHandler { get; set; } = new HttpClientHandler(); - - /// - /// The that will be send on POST - /// - public MediaTypeHeaderValue MediaType { get; set; } = MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); // This should be "application/graphql" also "application/x-www-form-urlencoded" is Accepted - - } - -} diff --git a/src/GraphQL.Client.Http/GraphQLHttpException.cs b/src/GraphQL.Client.Http/GraphQLHttpException.cs deleted file mode 100644 index b40807ca..00000000 --- a/src/GraphQL.Client.Http/GraphQLHttpException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Net.Http; - -namespace GraphQL.Client.Http { - - /// - /// An exception thrown on unexpected - /// - public class GraphQLHttpException : Exception { - - /// - /// The - /// - public HttpResponseMessage HttpResponseMessage { get; } - - /// - /// Creates a new instance of - /// - /// The unexpected - public GraphQLHttpException(HttpResponseMessage httpResponseMessage) : base($"Unexpected {nameof(System.Net.Http.HttpResponseMessage)} with code: {httpResponseMessage?.StatusCode}") { - this.HttpResponseMessage = httpResponseMessage ?? throw new ArgumentNullException(nameof(httpResponseMessage)); - } - - } - -} diff --git a/src/GraphQL.Client.Http/GraphQLHttpRequest.cs b/src/GraphQL.Client.Http/GraphQLHttpRequest.cs deleted file mode 100644 index 86c0b211..00000000 --- a/src/GraphQL.Client.Http/GraphQLHttpRequest.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace GraphQL.Client.Http { - - public class GraphQLHttpRequest : GraphQLRequest { - } - - public class GraphQLHttpRequest : GraphQLHttpRequest { - } - -} diff --git a/src/GraphQL.Client.Http/GraphQLHttpResponse.cs b/src/GraphQL.Client.Http/GraphQLHttpResponse.cs deleted file mode 100644 index 1e7c8424..00000000 --- a/src/GraphQL.Client.Http/GraphQLHttpResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace GraphQL.Client.Http { - - public class GraphQLHttpResponse : GraphQLResponse { - } - - public class GraphQLHttpResponse : GraphQLHttpResponse { - } - -} diff --git a/src/GraphQL.Client.Http/HttpClientExtensions.cs b/src/GraphQL.Client.Http/HttpClientExtensions.cs deleted file mode 100644 index 4f511101..00000000 --- a/src/GraphQL.Client.Http/HttpClientExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Net.Http; - -namespace GraphQL.Client.Http { - - public static class HttpClientExtensions { - - public static GraphQLHttpClient AsGraphQLClient(this HttpClient httpClient, string endPoint) => - new GraphQLHttpClient(endPoint, httpClient); - - public static GraphQLHttpClient AsGraphQLClient(this HttpClient httpClient, Uri endPoint) => - new GraphQLHttpClient(endPoint, httpClient); - - public static GraphQLHttpClient AsGraphQLClient(this HttpClient httpClient, string endPoint, GraphQLHttpClientOptions graphQLHttpClientOptions) => - new GraphQLHttpClient(endPoint, graphQLHttpClientOptions, httpClient); - - public static GraphQLHttpClient AsGraphQLClient(this HttpClient httpClient, Uri endPoint, GraphQLHttpClientOptions graphQLHttpClientOptions) => - new GraphQLHttpClient(endPoint, graphQLHttpClientOptions, httpClient); - - } - -} diff --git a/src/GraphQL.Client.LocalExecution/GraphQL.Client.LocalExecution.csproj b/src/GraphQL.Client.LocalExecution/GraphQL.Client.LocalExecution.csproj new file mode 100644 index 00000000..687cc656 --- /dev/null +++ b/src/GraphQL.Client.LocalExecution/GraphQL.Client.LocalExecution.csproj @@ -0,0 +1,17 @@ + + + + A GraphQL Client which executes the queries directly on a provided GraphQL schema using graphql-dotnet + netstandard2.0 + + + + + + + + + + + + diff --git a/src/GraphQL.Client.LocalExecution/GraphQLLocalExecutionClient.cs b/src/GraphQL.Client.LocalExecution/GraphQLLocalExecutionClient.cs new file mode 100644 index 00000000..0bd3a270 --- /dev/null +++ b/src/GraphQL.Client.LocalExecution/GraphQLLocalExecutionClient.cs @@ -0,0 +1,97 @@ +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using GraphQL.Client.Abstractions; +using GraphQL.Types; + +namespace GraphQL.Client.LocalExecution; + +public static class GraphQLLocalExecutionClient +{ + public static GraphQLLocalExecutionClient New(TSchema schema, IGraphQLJsonSerializer clientSerializer, IGraphQLTextSerializer serverSerializer) + where TSchema : ISchema + => new(schema, new DocumentExecuter(), clientSerializer, serverSerializer); +} + +public class GraphQLLocalExecutionClient : IGraphQLClient where TSchema : ISchema +{ + public TSchema Schema { get; } + + public IGraphQLJsonSerializer Serializer { get; } + + private readonly IDocumentExecuter _documentExecuter; + private readonly IGraphQLTextSerializer _documentSerializer; + + public GraphQLLocalExecutionClient(TSchema schema, IDocumentExecuter documentExecuter, IGraphQLJsonSerializer serializer, IGraphQLTextSerializer documentSerializer) + { + Schema = schema ?? throw new ArgumentNullException(nameof(schema), "no schema configured"); + _documentExecuter = documentExecuter; + Serializer = serializer ?? throw new ArgumentNullException(nameof(serializer), "please configure the JSON serializer you want to use"); + _documentSerializer = documentSerializer; + + if (!Schema.Initialized) + Schema.Initialize(); + } + + public void Dispose() { } + + public Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default) + => ExecuteQueryAsync(request, cancellationToken); + + public Task> SendMutationAsync(GraphQLRequest request, CancellationToken cancellationToken = default) + => ExecuteQueryAsync(request, cancellationToken); + + public IObservable> CreateSubscriptionStream(GraphQLRequest request) => + Observable.Defer(() => ExecuteSubscriptionAsync(request).ToObservable()) + .Concat() + .Publish() + .RefCount(); + + public IObservable> CreateSubscriptionStream(GraphQLRequest request, + Action exceptionHandler) + => CreateSubscriptionStream(request); + + #region Private Methods + + private async Task> ExecuteQueryAsync(GraphQLRequest request, CancellationToken cancellationToken) + { + var executionResult = await ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + return await ExecutionResultToGraphQLResponseAsync(executionResult, cancellationToken).ConfigureAwait(false); + } + + private async Task>> ExecuteSubscriptionAsync(GraphQLRequest request, CancellationToken cancellationToken = default) + { + var result = await ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + var stream = result.Streams?.Values.SingleOrDefault(); + + return stream == null + ? Observable.Throw>(new InvalidOperationException("the GraphQL execution did not return an observable")) + : stream.SelectMany(executionResult => Observable.FromAsync(token => ExecutionResultToGraphQLResponseAsync(executionResult, token))); + } + + private async Task ExecuteAsync(GraphQLRequest clientRequest, CancellationToken cancellationToken = default) + { + var serverRequest = _documentSerializer.Deserialize(Serializer.SerializeToString(clientRequest)); + + var result = await _documentExecuter.ExecuteAsync(options => + { + options.Schema = Schema; + options.OperationName = serverRequest?.OperationName; + options.Query = serverRequest?.Query; + options.Variables = serverRequest?.Variables; + options.Extensions = serverRequest?.Extensions; + options.CancellationToken = cancellationToken; + }).ConfigureAwait(false); + + return result; + } + + private async Task> ExecutionResultToGraphQLResponseAsync(ExecutionResult executionResult, CancellationToken cancellationToken = default) + { + using var stream = new MemoryStream(); + await _documentSerializer.WriteAsync(stream, executionResult, cancellationToken).ConfigureAwait(false); + stream.Seek(0, SeekOrigin.Begin); + return await Serializer.DeserializeFromUtf8StreamAsync(stream, cancellationToken).ConfigureAwait(false); + } + + #endregion +} diff --git a/src/GraphQL.Client.LocalExecution/ServiceCollectionExtensions.cs b/src/GraphQL.Client.LocalExecution/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..d5d4a7fb --- /dev/null +++ b/src/GraphQL.Client.LocalExecution/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using GraphQL.Client.Abstractions; +using GraphQL.DI; +using GraphQL.MicrosoftDI; +using GraphQL.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace GraphQL.Client.LocalExecution; + +public static class ServiceCollectionExtensions +{ + public static IGraphQLBuilder AddGraphQLLocalExecutionClient(this IServiceCollection services) where TSchema : ISchema + { + services.AddSingleton>(); + services.AddSingleton(p => p.GetRequiredService>()); + return new GraphQLBuilder(services, null); + } +} diff --git a/src/GraphQL.Client.Serializer.Newtonsoft/ConstantCaseEnumConverter.cs b/src/GraphQL.Client.Serializer.Newtonsoft/ConstantCaseEnumConverter.cs new file mode 100644 index 00000000..d8e73a5b --- /dev/null +++ b/src/GraphQL.Client.Serializer.Newtonsoft/ConstantCaseEnumConverter.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using GraphQL.Client.Abstractions.Utilities; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace GraphQL.Client.Serializer.Newtonsoft; + +public class ConstantCaseEnumConverter : StringEnumConverter +{ + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + } + else + { + var enumString = ((Enum)value).ToString("G"); + var memberName = value.GetType() + .GetMember(enumString, BindingFlags.DeclaredOnly | BindingFlags.Static | BindingFlags.Public) + .FirstOrDefault()?.Name; + if (string.IsNullOrEmpty(memberName)) + { + if (!AllowIntegerValues) + throw new JsonSerializationException($"Integer value {value} is not allowed."); + writer.WriteValue(value); + } + else + { + writer.WriteValue(memberName.ToConstantCase()); + } + } + } +} diff --git a/src/GraphQL.Client.Serializer.Newtonsoft/GraphQL.Client.Serializer.Newtonsoft.csproj b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQL.Client.Serializer.Newtonsoft.csproj new file mode 100644 index 00000000..22126805 --- /dev/null +++ b/src/GraphQL.Client.Serializer.Newtonsoft/GraphQL.Client.Serializer.Newtonsoft.csproj @@ -0,0 +1,16 @@ + + + + A serializer implementation for GraphQL.Client using Newtonsoft.Json as underlying JSON library + netstandard2.0 + + + + + + + + + + + diff --git a/src/GraphQL.Client.Serializer.Newtonsoft/MapConverter.cs b/src/GraphQL.Client.Serializer.Newtonsoft/MapConverter.cs new file mode 100644 index 00000000..5bc53686 --- /dev/null +++ b/src/GraphQL.Client.Serializer.Newtonsoft/MapConverter.cs @@ -0,0 +1,60 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GraphQL.Client.Serializer.Newtonsoft; + +public class MapConverter : JsonConverter +{ + public override void WriteJson(JsonWriter writer, Map value, JsonSerializer serializer) => + throw new NotImplementedException( + "This converter currently is only intended to be used to read a JSON object into a strongly-typed representation."); + + public override Map? ReadJson(JsonReader reader, Type objectType, Map existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var rootToken = JToken.ReadFrom(reader); + return rootToken.Type switch + { + JTokenType.Object => (Map)ReadDictionary(rootToken, new Map()), + JTokenType.Null => null, + _ => throw new ArgumentException("This converter can only parse when the root element is a JSON Object.") + }; + } + + private object? ReadToken(JToken? token) => + token switch + { + JObject jObject => ReadDictionary(jObject, new Dictionary()), + JArray jArray => ReadArray(jArray).ToList(), + JValue jValue => jValue.Value, + JConstructor _ => throw new ArgumentOutOfRangeException(nameof(token.Type), + "cannot deserialize a JSON constructor"), + JProperty _ => throw new ArgumentOutOfRangeException(nameof(token.Type), + "cannot deserialize a JSON property"), + JContainer _ => throw new ArgumentOutOfRangeException(nameof(token.Type), + "cannot deserialize a JSON comment"), + _ => throw new ArgumentOutOfRangeException(nameof(token.Type)) + }; + + private Dictionary ReadDictionary(JToken element, Dictionary to) + { + foreach (var property in ((JObject)element).Properties()) + { + if (IsUnsupportedJTokenType(property.Value.Type)) + continue; + to[property.Name] = ReadToken(property.Value); + } + return to; + } + + private IEnumerable ReadArray(JArray element) + { + foreach (var item in element) + { + if (IsUnsupportedJTokenType(item.Type)) + continue; + yield return ReadToken(item); + } + } + + private bool IsUnsupportedJTokenType(JTokenType type) => type == JTokenType.Constructor || type == JTokenType.Property || type == JTokenType.Comment; +} diff --git a/src/GraphQL.Client.Serializer.Newtonsoft/NewtonsoftJsonSerializer.cs b/src/GraphQL.Client.Serializer.Newtonsoft/NewtonsoftJsonSerializer.cs new file mode 100644 index 00000000..cc1d78bc --- /dev/null +++ b/src/GraphQL.Client.Serializer.Newtonsoft/NewtonsoftJsonSerializer.cs @@ -0,0 +1,56 @@ +using System.Text; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace GraphQL.Client.Serializer.Newtonsoft; + +public class NewtonsoftJsonSerializer : IGraphQLWebsocketJsonSerializer +{ + public static JsonSerializerSettings DefaultJsonSerializerSettings => new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver { IgnoreIsSpecifiedMembers = true }, + MissingMemberHandling = MissingMemberHandling.Ignore, + Converters = { new ConstantCaseEnumConverter() } + }; + + public JsonSerializerSettings JsonSerializerSettings { get; } + + public NewtonsoftJsonSerializer() : this(DefaultJsonSerializerSettings) { } + + public NewtonsoftJsonSerializer(Action configure) : this(configure.AndReturn(DefaultJsonSerializerSettings)) { } + + public NewtonsoftJsonSerializer(JsonSerializerSettings jsonSerializerSettings) + { + JsonSerializerSettings = jsonSerializerSettings; + ConfigureMandatorySerializerOptions(); + } + + // deserialize extensions to Dictionary + private void ConfigureMandatorySerializerOptions() => JsonSerializerSettings.Converters.Insert(0, new MapConverter()); + + public string SerializeToString(GraphQLRequest request) => JsonConvert.SerializeObject(request, JsonSerializerSettings); + + public byte[] SerializeToBytes(GraphQLWebSocketRequest request) + { + string json = JsonConvert.SerializeObject(request, JsonSerializerSettings); + return Encoding.UTF8.GetBytes(json); + } + + public Task DeserializeToWebsocketResponseWrapperAsync(Stream stream) => DeserializeFromUtf8Stream(stream); + + public GraphQLWebSocketResponse DeserializeToWebsocketResponse(byte[] bytes) => + JsonConvert.DeserializeObject>(Encoding.UTF8.GetString(bytes), + JsonSerializerSettings); + + public Task> DeserializeFromUtf8StreamAsync(Stream stream, CancellationToken cancellationToken) => DeserializeFromUtf8Stream>(stream); + + private Task DeserializeFromUtf8Stream(Stream stream) + { + using var sr = new StreamReader(stream); + using JsonReader reader = new JsonTextReader(sr); + var serializer = JsonSerializer.Create(JsonSerializerSettings); + return Task.FromResult(serializer.Deserialize(reader)); + } +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/ConstantCaseJsonNamingPolicy.cs b/src/GraphQL.Client.Serializer.SystemTextJson/ConstantCaseJsonNamingPolicy.cs new file mode 100644 index 00000000..f8560306 --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/ConstantCaseJsonNamingPolicy.cs @@ -0,0 +1,9 @@ +using System.Text.Json; +using GraphQL.Client.Abstractions.Utilities; + +namespace GraphQL.Client.Serializer.SystemTextJson; + +public class ConstantCaseJsonNamingPolicy : JsonNamingPolicy +{ + public override string ConvertName(string name) => name.ToConstantCase(); +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/ConverterHelperExtensions.cs b/src/GraphQL.Client.Serializer.SystemTextJson/ConverterHelperExtensions.cs new file mode 100644 index 00000000..6424e91d --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/ConverterHelperExtensions.cs @@ -0,0 +1,35 @@ +using System.Buffers; +using System.Numerics; +using System.Text; +using System.Text.Json; + +namespace GraphQL.Client.Serializer.SystemTextJson; + +public static class ConverterHelperExtensions +{ + public static object ReadNumber(this ref Utf8JsonReader reader) + { + if (reader.TryGetInt32(out int i)) + return i; + else if (reader.TryGetInt64(out long l)) + return l; + else if (reader.TryGetDouble(out double d)) + return reader.TryGetBigInteger(out var bi) && bi != new BigInteger(d) + ? bi + : (object)d; + else if (reader.TryGetDecimal(out decimal dd)) + return reader.TryGetBigInteger(out var bi) && bi != new BigInteger(dd) + ? bi + : (object)dd; + + throw new NotImplementedException($"Unexpected Number value. Raw text was: {reader.GetRawString()}"); + } + + public static bool TryGetBigInteger(this ref Utf8JsonReader reader, out BigInteger bi) => BigInteger.TryParse(reader.GetRawString(), out bi); + + public static string GetRawString(this ref Utf8JsonReader reader) + { + var byteArray = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray(); + return Encoding.UTF8.GetString(byteArray); + } +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/ErrorPathConverter.cs b/src/GraphQL.Client.Serializer.SystemTextJson/ErrorPathConverter.cs new file mode 100644 index 00000000..b50a8591 --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/ErrorPathConverter.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GraphQL.Client.Serializer.SystemTextJson; + +public class ErrorPathConverter : JsonConverter +{ + + public override ErrorPath Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + new(ReadArray(ref reader)); + + public override void Write(Utf8JsonWriter writer, ErrorPath value, JsonSerializerOptions options) + => throw new NotImplementedException( + "This converter currently is only intended to be used to read a JSON object into a strongly-typed representation."); + + private static IEnumerable ReadArray(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException("This converter can only parse when the root element is a JSON Array."); + } + + var array = new List(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + break; + + array.Add(ReadValue(ref reader)); + } + + return array; + } + + private static object? ReadValue(ref Utf8JsonReader reader) + => reader.TokenType switch + { + JsonTokenType.None => null, + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number => reader.ReadNumber(), + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Null => null, + _ => throw new InvalidOperationException($"Unexpected token type: {reader.TokenType}") + }; +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/GraphQL.Client.Serializer.SystemTextJson.csproj b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQL.Client.Serializer.SystemTextJson.csproj new file mode 100644 index 00000000..2a436c50 --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/GraphQL.Client.Serializer.SystemTextJson.csproj @@ -0,0 +1,16 @@ + + + + A serializer implementation for GraphQL.Client using System.Text.Json as underlying JSON library + netstandard2.0;netcoreapp3.1 + + + + + + + + + + + diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/ImmutableConverter.cs b/src/GraphQL.Client.Serializer.SystemTextJson/ImmutableConverter.cs new file mode 100644 index 00000000..5a14d5ff --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/ImmutableConverter.cs @@ -0,0 +1,186 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GraphQL.Client.Serializer.SystemTextJson; + +/// +/// class for converting immutable objects, derived from https://github.com/manne/obviously/blob/master/src/system.text.json/Core/ImmutableConverter.cs +/// +public class ImmutableConverter : JsonConverter +{ + public override bool CanConvert(Type typeToConvert) + { + if (typeToConvert.IsPrimitive) + return false; + + var nullableUnderlyingType = Nullable.GetUnderlyingType(typeToConvert); + if (nullableUnderlyingType != null && nullableUnderlyingType.IsValueType) + return false; + + var constructors = typeToConvert.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + if (constructors.Length != 1) + { + return false; + } + + var constructor = constructors[0]; + var parameters = constructor.GetParameters(); + + if (parameters.Length <= 0) + { + return false; + } + + var properties = typeToConvert.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (properties.Length > parameters.Length) + { + return false; + } + + return properties + .Select(property => parameters.Any(parameter => NameOfPropertyAndParameter.Matches(property.Name, parameter.Name, typeToConvert.IsAnonymous()))) + .All(hasMatchingParameter => hasMatchingParameter); + } + + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var valueOfProperty = new Dictionary(); + var namedPropertiesMapping = GetNamedProperties(options, GetProperties(typeToConvert)); + reader.Read(); + while (true) + { + if (reader.TokenType != JsonTokenType.PropertyName && reader.TokenType != JsonTokenType.String) + { + break; + } + + string jsonPropName = reader.GetString(); + string normalizedPropName = ConvertAndNormalizeName(jsonPropName, options); + if (!namedPropertiesMapping.TryGetValue(normalizedPropName, out var obProp)) + { + reader.Read(); + } + else + { + var value = JsonSerializer.Deserialize(ref reader, obProp.PropertyType, options); + reader.Read(); + valueOfProperty[obProp] = value; + } + } + + var ctor = typeToConvert.GetConstructors(BindingFlags.Public | BindingFlags.Instance).First(); + var parameters = ctor.GetParameters(); + var parameterValues = new object[parameters.Length]; + for (int index = 0; index < parameters.Length; index++) + { + var parameterInfo = parameters[index]; + var value = valueOfProperty.First(prop => + NameOfPropertyAndParameter.Matches(prop.Key.Name, parameterInfo.Name, typeToConvert.IsAnonymous())).Value; + + parameterValues[index] = value; + } + + var instance = ctor.Invoke(parameterValues); + return instance; + } + + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) + { + var strippedOptions = new JsonSerializerOptions + { + AllowTrailingCommas = options.AllowTrailingCommas, + DefaultBufferSize = options.DefaultBufferSize, + DictionaryKeyPolicy = options.DictionaryKeyPolicy, + Encoder = options.Encoder, +#pragma warning disable SYSLIB0020 + // obsolete warning disabled to keep compatibility until deprecated field is removed + IgnoreNullValues = options.IgnoreNullValues, +#pragma warning restore SYSLIB0020 + IgnoreReadOnlyProperties = options.IgnoreReadOnlyProperties, + MaxDepth = options.MaxDepth, + PropertyNameCaseInsensitive = options.PropertyNameCaseInsensitive, + PropertyNamingPolicy = options.PropertyNamingPolicy, + ReadCommentHandling = options.ReadCommentHandling, + WriteIndented = options.WriteIndented + }; + foreach (var converter in options.Converters) + { + if (!(converter is ImmutableConverter)) + strippedOptions.Converters.Add(converter); + } + + JsonSerializer.Serialize(writer, value, strippedOptions); + } + + private static PropertyInfo[] GetProperties(IReflect typeToConvert) => typeToConvert.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + private static IReadOnlyDictionary GetNamedProperties(JsonSerializerOptions options, IEnumerable properties) + { + var result = new Dictionary(); + foreach (var property in properties) + { + string name; + var nameAttribute = property.GetCustomAttribute(); + if (nameAttribute != null) + { + name = nameAttribute.Name; + } + else + { + name = options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name; + } + + string normalizedName = NormalizeName(name, options); + result.Add(normalizedName, property); + } + + return result; + } + + private static string ConvertAndNormalizeName(string name, JsonSerializerOptions options) + { + string convertedName = options.PropertyNamingPolicy?.ConvertName(name) ?? name; + return NormalizeName(convertedName, options); + } + + private static string NormalizeName(string name, JsonSerializerOptions options) => options.PropertyNameCaseInsensitive ? name.ToLowerInvariant() : name; +} + +internal static class NameOfPropertyAndParameter +{ + public static bool Matches(string propertyName, string parameterName, bool anonymousType) + { + if (string.IsNullOrEmpty(propertyName)) + { + return string.IsNullOrEmpty(parameterName); + } + + if (string.IsNullOrEmpty(parameterName)) + { + return false; + } + + if (anonymousType) + { + return propertyName.Equals(parameterName, StringComparison.Ordinal); + } + + var xRight = propertyName.AsSpan(1); + var yRight = parameterName.AsSpan(1); + return char.ToLowerInvariant(propertyName[0]).CompareTo(parameterName[0]) == 0 && xRight.Equals(yRight, StringComparison.Ordinal); + } +} + +internal static class TypeExtensions +{ + // copied from https://github.com/dahomey-technologies/Dahomey.Json/blob/master/src/Dahomey.Json/Util/TypeExtensions.cs + public static bool IsAnonymous(this Type type) => + type.Namespace == null + && type.IsSealed + && type.BaseType == typeof(object) + && !type.IsPublic + && type.IsDefined(typeof(CompilerGeneratedAttribute), false); +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/JsonSerializerOptionsExtensions.cs b/src/GraphQL.Client.Serializer.SystemTextJson/JsonSerializerOptionsExtensions.cs new file mode 100644 index 00000000..3fc8f6eb --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/JsonSerializerOptionsExtensions.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace GraphQL.Client.Serializer.SystemTextJson; + +public static class JsonSerializerOptionsExtensions +{ + public static JsonSerializerOptions SetupImmutableConverter(this JsonSerializerOptions options) + { + options.Converters.Add(new ImmutableConverter()); + return options; + } +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/MapConverter.cs b/src/GraphQL.Client.Serializer.SystemTextJson/MapConverter.cs new file mode 100644 index 00000000..78eef361 --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/MapConverter.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GraphQL.Client.Serializer.SystemTextJson; + +/// +/// A custom JsonConverter for reading the extension fields of and . +/// +/// +/// Taken and modified from GraphQL.SystemTextJson.ObjectDictionaryConverter (GraphQL.NET) +/// +public class MapConverter : JsonConverter +{ + public override Map Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => ReadDictionary(ref reader, new Map()); + + public override void Write(Utf8JsonWriter writer, Map value, JsonSerializerOptions options) + => throw new NotImplementedException( + "This converter currently is only intended to be used to read a JSON object into a strongly-typed representation."); + + private static TDictionary ReadDictionary(ref Utf8JsonReader reader, TDictionary result) + where TDictionary : Dictionary + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException(); + + string key = reader.GetString(); + + // move to property value + if (!reader.Read()) + throw new JsonException(); + + result.Add(key, ReadValue(ref reader)); + } + + return result; + } + + private static List ReadArray(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException(); + + var result = new List(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + break; + + result.Add(ReadValue(ref reader)); + } + + return result; + } + + private static object? ReadValue(ref Utf8JsonReader reader) + => reader.TokenType switch + { + JsonTokenType.StartArray => ReadArray(ref reader).ToList(), + JsonTokenType.StartObject => ReadDictionary(ref reader, new Dictionary()), + JsonTokenType.Number => reader.ReadNumber(), + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.String => reader.GetString(), + JsonTokenType.Null => null, + JsonTokenType.None => null, + _ => throw new InvalidOperationException($"Unexpected value kind: {reader.TokenType}") + }; +} diff --git a/src/GraphQL.Client.Serializer.SystemTextJson/SystemTextJsonSerializer.cs b/src/GraphQL.Client.Serializer.SystemTextJson/SystemTextJsonSerializer.cs new file mode 100644 index 00000000..2488358d --- /dev/null +++ b/src/GraphQL.Client.Serializer.SystemTextJson/SystemTextJsonSerializer.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; + +namespace GraphQL.Client.Serializer.SystemTextJson; + +public class SystemTextJsonSerializer : IGraphQLWebsocketJsonSerializer +{ + public static JsonSerializerOptions DefaultJsonSerializerOptions => new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(new ConstantCaseJsonNamingPolicy(), false) } + }.SetupImmutableConverter(); + + public JsonSerializerOptions Options { get; } + + public SystemTextJsonSerializer() : this(DefaultJsonSerializerOptions) { } + + public SystemTextJsonSerializer(Action configure) : this(configure.AndReturn(DefaultJsonSerializerOptions)) { } + + public SystemTextJsonSerializer(JsonSerializerOptions options) + { + Options = options; + ConfigureMandatorySerializerOptions(); + } + + private void ConfigureMandatorySerializerOptions() + { + // deserialize extensions to Dictionary + Options.Converters.Insert(0, new ErrorPathConverter()); + Options.Converters.Insert(0, new MapConverter()); + // allow the JSON field "data" to match the property "Data" even without JsonNamingPolicy.CamelCase + Options.PropertyNameCaseInsensitive = true; + } + + public string SerializeToString(GraphQLRequest request) => JsonSerializer.Serialize(request, Options); + + public Task> DeserializeFromUtf8StreamAsync(Stream stream, CancellationToken cancellationToken) => JsonSerializer.DeserializeAsync>(stream, Options, cancellationToken).AsTask(); + + public byte[] SerializeToBytes(GraphQLWebSocketRequest request) => JsonSerializer.SerializeToUtf8Bytes(request, Options); + + public Task DeserializeToWebsocketResponseWrapperAsync(Stream stream) => JsonSerializer.DeserializeAsync(stream, Options).AsTask(); + + public GraphQLWebSocketResponse DeserializeToWebsocketResponse(byte[] bytes) => + JsonSerializer.Deserialize>(new ReadOnlySpan(bytes), + Options); +} diff --git a/src/GraphQL.Client/GraphQL.Client.csproj b/src/GraphQL.Client/GraphQL.Client.csproj index 110fc770..19a2bb7d 100644 --- a/src/GraphQL.Client/GraphQL.Client.csproj +++ b/src/GraphQL.Client/GraphQL.Client.csproj @@ -1,18 +1,30 @@ - + - + + netstandard2.0;net461;net6.0;net7.0;net8.0 + GraphQL.Client.Http + - - A GraphQL Client - netstandard2.0 - + + NETSTANDARD + - - - + + NETFRAMEWORK + - - - + + + + + + + + + + + + + diff --git a/src/GraphQL.Client/GraphQLException.cs b/src/GraphQL.Client/GraphQLException.cs deleted file mode 100644 index 613ab2ac..00000000 --- a/src/GraphQL.Client/GraphQLException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace GraphQL.Client { - - public class GraphQLException : Exception { - - public GraphQLError[] Errors { get; } - - public GraphQLException(GraphQLError[] errors) : base(errors[0].Message) { - this.Errors = errors; - } - - } - -} diff --git a/src/GraphQL.Client/GraphQLHttpClient.cs b/src/GraphQL.Client/GraphQLHttpClient.cs new file mode 100644 index 00000000..5f54d21b --- /dev/null +++ b/src/GraphQL.Client/GraphQLHttpClient.cs @@ -0,0 +1,247 @@ +using System.Diagnostics; +#pragma warning disable IDE0005 +// see https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/8.0/implicit-global-using-netfx +using System.Net.Http; +#pragma warning restore IDE0005 +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.Http.Websocket; + +namespace GraphQL.Client.Http; + +public class GraphQLHttpClient : IGraphQLWebSocketClient, IDisposable +{ + private readonly Lazy _lazyHttpWebSocket; + private GraphQLHttpWebSocket GraphQlHttpWebSocket => _lazyHttpWebSocket.Value; + + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + private readonly bool _disposeHttpClient = false; + /// + /// the json serializer + /// + public IGraphQLWebsocketJsonSerializer JsonSerializer { get; } + + /// + /// the instance of which is used internally + /// + public HttpClient HttpClient { get; } + + /// + /// The Options to be used + /// + public GraphQLHttpClientOptions Options { get; } + + /// + /// This flag is set to when an error has occurred on an APQ and + /// has returned . To reset this, the instance of has to be disposed and a new one must be created. + /// + public bool APQDisabledForSession { get; private set; } + + /// + public IObservable WebSocketReceiveErrors => GraphQlHttpWebSocket.ReceiveErrors; + + /// + public string? WebSocketSubProtocol => GraphQlHttpWebSocket.WebsocketProtocol; + + /// + public IObservable WebsocketConnectionState => GraphQlHttpWebSocket.ConnectionState; + + /// + public IObservable PongStream => GraphQlHttpWebSocket.GetPongStream(); + + #region Constructors + + public GraphQLHttpClient(string endPoint, IGraphQLWebsocketJsonSerializer serializer) + : this(new Uri(endPoint), serializer) { } + + public GraphQLHttpClient(Uri endPoint, IGraphQLWebsocketJsonSerializer serializer) + : this(o => o.EndPoint = endPoint, serializer) { } + + public GraphQLHttpClient(Action configure, IGraphQLWebsocketJsonSerializer serializer) + : this(configure.New(), serializer) { } + + public GraphQLHttpClient(GraphQLHttpClientOptions options, IGraphQLWebsocketJsonSerializer serializer) + : this(options, serializer, new HttpClient(options.HttpMessageHandler)) + { + // set this flag to dispose the internally created HttpClient when GraphQLHttpClient gets disposed + _disposeHttpClient = true; + } + + public GraphQLHttpClient(GraphQLHttpClientOptions options, IGraphQLWebsocketJsonSerializer serializer, HttpClient httpClient) + { + Options = options ?? throw new ArgumentNullException(nameof(options)); + JsonSerializer = serializer ?? throw new ArgumentNullException(nameof(serializer), "please configure the JSON serializer you want to use"); + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _lazyHttpWebSocket = new Lazy(CreateGraphQLHttpWebSocket); + } + + public GraphQLHttpClient(Action configure, IGraphQLWebsocketJsonSerializer serializer, HttpClient httpClient) + : this(configure.New(), serializer, httpClient) { } + + public GraphQLHttpClient(Uri endPoint, IGraphQLWebsocketJsonSerializer serializer, HttpClient httpClient) + : this(o => o.EndPoint = endPoint, serializer, httpClient) { } + + public GraphQLHttpClient(string endPoint, IGraphQLWebsocketJsonSerializer serializer, HttpClient httpClient) + : this(new Uri(endPoint), serializer, httpClient) { } + + #endregion + + #region IGraphQLClient + + private const int APQ_SUPPORTED_VERSION = 1; + + /// + public async Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + string? savedQuery = null; + bool useAPQ = false; + + if (request.Query != null && !APQDisabledForSession && Options.EnableAutomaticPersistedQueries(request)) + { + // https://www.apollographql.com/docs/react/api/link/persisted-queries/ + useAPQ = true; + request.GeneratePersistedQueryExtension(); + savedQuery = request.Query; + request.Query = null; + } + + var response = await SendQueryInternalAsync(request, cancellationToken); + + if (useAPQ) + { + if (response.Errors?.Any(error => string.Equals(error.Message, "PersistedQueryNotFound", StringComparison.CurrentCultureIgnoreCase)) == true) + { + // GraphQL server supports APQ! + + // Alas, for the first time we did not guess and in vain removed Query, so we return Query and + // send request again. This is one-time "cache miss", not so scary. + request.Query = savedQuery; + return await SendQueryInternalAsync(request, cancellationToken); + } + else + { + // GraphQL server either supports APQ of some other version, or does not support it at all. + // Send a request for the second time. This is better than returning an error. Let the client work with APQ disabled. + APQDisabledForSession = Options.DisableAPQ(response); + request.Query = savedQuery; + return await SendQueryInternalAsync(request, cancellationToken); + } + } + + return response; + } + + /// + public Task> SendMutationAsync(GraphQLRequest request, + CancellationToken cancellationToken = default) + => SendQueryAsync(request, cancellationToken); + + /// + public IObservable> CreateSubscriptionStream(GraphQLRequest request) + => CreateSubscriptionStream(request, null); + + /// + public IObservable> CreateSubscriptionStream(GraphQLRequest request, Action? exceptionHandler) + { + if (_disposed) + throw new ObjectDisposedException(nameof(GraphQLHttpClient)); + + var observable = GraphQlHttpWebSocket.CreateSubscriptionStream(request, exceptionHandler); + return observable; + } + + #endregion + + /// + public Task InitializeWebsocketConnection() => GraphQlHttpWebSocket.InitializeWebSocket(); + + /// + public Task SendPingAsync(object? payload) => GraphQlHttpWebSocket.SendPingAsync(payload); + + /// + public Task SendPongAsync(object? payload) => GraphQlHttpWebSocket.SendPongAsync(payload); + + #region Private Methods + private async Task> SendQueryInternalAsync(GraphQLRequest request, CancellationToken cancellationToken = default) => + Options.UseWebSocketForQueriesAndMutations || Options.WebSocketEndPoint is not null && Options.EndPoint is null || Options.EndPoint.HasWebSocketScheme() + ? await GraphQlHttpWebSocket.SendRequestAsync(request, cancellationToken).ConfigureAwait(false) + : await SendHttpRequestAsync(request, cancellationToken).ConfigureAwait(false); + + private async Task> SendHttpRequestAsync(GraphQLRequest request, CancellationToken cancellationToken = default) + { + var preprocessedRequest = await Options.PreprocessRequest(request, this).ConfigureAwait(false); + + using var httpRequestMessage = preprocessedRequest.ToHttpRequestMessage(Options, JsonSerializer); + using var httpResponseMessage = await HttpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + var contentStream = await httpResponseMessage.Content.ReadAsStreamAsync().ConfigureAwait(false); + + if (Options.IsValidResponseToDeserialize(httpResponseMessage)) + { + var graphQLResponse = await JsonSerializer.DeserializeFromUtf8StreamAsync(contentStream, cancellationToken).ConfigureAwait(false); + return graphQLResponse.ToGraphQLHttpResponse(httpResponseMessage.Headers, httpResponseMessage.StatusCode); + } + + // error handling + string content = null; + if (contentStream != null) + { + using var sr = new StreamReader(contentStream); + content = await sr.ReadToEndAsync().ConfigureAwait(false); + } + + throw new GraphQLHttpRequestException(httpResponseMessage.StatusCode, httpResponseMessage.Headers, content); + } + + private GraphQLHttpWebSocket CreateGraphQLHttpWebSocket() + { + if (Options.WebSocketEndPoint is null && Options.EndPoint is null) + throw new InvalidOperationException("no endpoint configured"); + + var webSocketEndpoint = Options.WebSocketEndPoint ?? Options.EndPoint.GetWebSocketUri(); + return !webSocketEndpoint.HasWebSocketScheme() + ? throw new InvalidOperationException($"uri \"{webSocketEndpoint}\" is not a websocket endpoint") + : new GraphQLHttpWebSocket(webSocketEndpoint, this); + } + + #endregion + + #region IDisposable + + /// + /// Releases unmanaged resources + /// + public void Dispose() + { + lock (_disposeLocker) + { + if (!_disposed) + { + _disposed = true; + Dispose(true); + } + } + } + + private volatile bool _disposed; + private readonly object _disposeLocker = new(); + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + Debug.WriteLine($"Disposing GraphQLHttpClient on endpoint {Options.EndPoint}"); + _cancellationTokenSource.Cancel(); + if (_disposeHttpClient) + HttpClient.Dispose(); + if (_lazyHttpWebSocket.IsValueCreated) + _lazyHttpWebSocket.Value.Dispose(); + _cancellationTokenSource.Dispose(); + } + } + + #endregion +} diff --git a/src/GraphQL.Client/GraphQLHttpClientExtensions.cs b/src/GraphQL.Client/GraphQLHttpClientExtensions.cs new file mode 100644 index 00000000..1da01413 --- /dev/null +++ b/src/GraphQL.Client/GraphQLHttpClientExtensions.cs @@ -0,0 +1,44 @@ +using System.Net.WebSockets; +using GraphQL.Client.Abstractions; + +namespace GraphQL.Client.Http; + +public static class GraphQLHttpClientExtensions +{ + /// + /// Creates a subscription to a GraphQL server. The connection is not established until the first actual subscription is made.
+ /// All subscriptions made to this stream share the same hot observable.
+ /// All s are passed to the to be handled externally.
+ /// If the completes normally, the subscription is recreated with a new connection attempt.
+ /// Other s or any exception thrown by will cause the sequence to fail. + ///
+ /// the GraphQL client + /// the GraphQL request for this subscription + /// an external handler for all s occurring within the sequence + /// an observable stream for the specified subscription + public static IObservable> CreateSubscriptionStream(this IGraphQLClient client, + GraphQLRequest request, Action webSocketExceptionHandler) => + client.CreateSubscriptionStream(request, e => + { + if (e is WebSocketException webSocketException) + webSocketExceptionHandler(webSocketException); + else + throw e; + }); + + /// + public static IObservable> CreateSubscriptionStream( + this IGraphQLClient client, GraphQLRequest request, Func defineResponseType, Action webSocketExceptionHandler) + { + _ = defineResponseType; + return client.CreateSubscriptionStream(request, webSocketExceptionHandler); + } + + /// + public static IObservable> CreateSubscriptionStream( + this IGraphQLClient client, GraphQLRequest request, Func defineResponseType) + { + _ = defineResponseType; + return client.CreateSubscriptionStream(request); + } +} diff --git a/src/GraphQL.Client/GraphQLHttpClientOptions.cs b/src/GraphQL.Client/GraphQLHttpClientOptions.cs new file mode 100644 index 00000000..eda0d024 --- /dev/null +++ b/src/GraphQL.Client/GraphQLHttpClientOptions.cs @@ -0,0 +1,119 @@ +using System.Net; +#pragma warning disable IDE0005 +// see https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/8.0/implicit-global-using-netfx +using System.Net.Http; +#pragma warning restore IDE0005 +using System.Net.Http.Headers; +using System.Net.WebSockets; +using GraphQL.Client.Http.Websocket; + +namespace GraphQL.Client.Http; + +/// +/// The Options that the will use. +/// +public class GraphQLHttpClientOptions +{ + /// + /// The GraphQL EndPoint to be used + /// + public Uri? EndPoint { get; set; } + + /// + /// The GraphQL EndPoint to be used for websocket connections + /// + public Uri? WebSocketEndPoint { get; set; } + + /// + /// The GraphQL websocket protocol to be used. Defaults to the older "graphql-ws" protocol to not break old code. + /// + public string? WebSocketProtocol { get; set; } = WebSocketProtocols.AUTO_NEGOTIATE; + + /// + /// The that is going to be used + /// + public HttpMessageHandler HttpMessageHandler { get; set; } = new HttpClientHandler(); + + /// + /// The that will be send on POST + /// + public string MediaType { get; set; } = "application/json"; // This should be "application/graphql" also "application/x-www-form-urlencoded" is Accepted + + /// + /// The back-off strategy for automatic websocket/subscription reconnects. Calculates the delay before the next connection attempt is made.
+ /// default formula: min(n, 5) * 1,5 * random(0.0, 1.0) + ///
+ public Func BackOffStrategy { get; set; } = n => + { + var rnd = new Random(); + return TimeSpan.FromSeconds(Math.Min(n, 5) * 1.5 + rnd.NextDouble()); + }; + + /// + /// If , the websocket connection is also used for regular queries and mutations + /// + public bool UseWebSocketForQueriesAndMutations { get; set; } = false; + + /// + /// Request preprocessing function. Can be used i.e. to inject authorization info into a GraphQL request payload. + /// + public Func> PreprocessRequest { get; set; } = (request, client) => + Task.FromResult(request is GraphQLHttpRequest graphQLHttpRequest ? graphQLHttpRequest : new GraphQLHttpRequest(request)); + + /// + /// Delegate to determine if GraphQL response may be properly deserialized into . + /// Note that compatible to the draft graphql-over-http spec GraphQL Server MAY return 4xx status codes (401/403, etc.) + /// with well-formed GraphQL response containing errors collection. + /// + public Func IsValidResponseToDeserialize { get; set; } = DefaultIsValidResponseToDeserialize; + + private static readonly IReadOnlyCollection _acceptedResponseContentTypes = new[] { "application/graphql+json", "application/json", "application/graphql-response+json" }; + + public static bool DefaultIsValidResponseToDeserialize(HttpResponseMessage r) + { + if (r.Content.Headers.ContentType?.MediaType != null && !_acceptedResponseContentTypes.Contains(r.Content.Headers.ContentType.MediaType)) + return false; + + return r.IsSuccessStatusCode || r.StatusCode == HttpStatusCode.BadRequest; + } + + /// + /// This callback is called after successfully establishing a websocket connection but before any regular request is made. + /// + public Func OnWebsocketConnected { get; set; } = client => Task.CompletedTask; + + /// + /// Configure additional websocket options (i.e. headers). This will not be invoked on Windows 7 when targeting .NET Framework 4.x. + /// + public Action ConfigureWebsocketOptions { get; set; } = options => { }; + + /// + /// Sets the `ConnectionParams` object sent with the GQL_CONNECTION_INIT message on establishing a GraphQL websocket connection. + /// See https://github.com/apollographql/subscriptions-transport-ws/blob/master/PROTOCOL.md#gql_connection_init. + /// + public Func ConfigureWebSocketConnectionInitPayload { get; set; } = options => null; + + /// + /// The default user agent request header. + /// Default to the GraphQL client assembly. + /// + public ProductInfoHeaderValue? DefaultUserAgentRequestHeader { get; set; } + = new ProductInfoHeaderValue(typeof(GraphQLHttpClient).Assembly.GetName().Name, typeof(GraphQLHttpClient).Assembly.GetName().Version.ToString()); + + /// + /// Delegate permitting use of Automatic Persisted Queries (APQ). + /// By default, returns for all requests. Note that GraphQL server should support APQ. Otherwise, the client disables APQ completely + /// after an unsuccessful attempt to send an APQ request and then send only regular requests. + /// + public Func EnableAutomaticPersistedQueries { get; set; } = _ => false; + + /// + /// A delegate which takes an and returns a boolean to disable any future persisted queries for that session. + /// This defaults to disabling on PersistedQueryNotSupported or a 400 or 500 HTTP error. + /// + public Func DisableAPQ { get; set; } = response => + { + return response.Errors?.Any(error => string.Equals(error.Message, "PersistedQueryNotSupported", StringComparison.CurrentCultureIgnoreCase)) == true + || response is IGraphQLHttpResponse httpResponse && (int)httpResponse.StatusCode >= 400 && (int)httpResponse.StatusCode < 600; + }; +} diff --git a/src/GraphQL.Client/GraphQLHttpRequest.cs b/src/GraphQL.Client/GraphQLHttpRequest.cs new file mode 100644 index 00000000..67f37892 --- /dev/null +++ b/src/GraphQL.Client/GraphQLHttpRequest.cs @@ -0,0 +1,57 @@ +#pragma warning disable IDE0005 +// see https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/8.0/implicit-global-using-netfx +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +#pragma warning restore IDE0005 +using System.Net.Http.Headers; +using System.Text; +using GraphQL.Client.Abstractions; + +namespace GraphQL.Client.Http; + +public class GraphQLHttpRequest : GraphQLRequest +{ + public GraphQLHttpRequest() + { + } + + public GraphQLHttpRequest([StringSyntax("GraphQL")] string query, object? variables = null, string? operationName = null, Dictionary? extensions = null) + : base(query, variables, operationName, extensions) + { + } + public GraphQLHttpRequest(GraphQLQuery query, object? variables = null, string? operationName = null, Dictionary? extensions = null) + : base(query, variables, operationName, extensions) + { + } + + public GraphQLHttpRequest(GraphQLRequest other) + : base(other) + { + } + + /// + /// Creates a from this . + /// Used by to convert GraphQL requests when sending them as regular HTTP requests. + /// + /// the passed from + /// the passed from + /// + public virtual HttpRequestMessage ToHttpRequestMessage(GraphQLHttpClientOptions options, IGraphQLJsonSerializer serializer) + { + var message = new HttpRequestMessage(HttpMethod.Post, options.EndPoint) + { + Content = new StringContent(serializer.SerializeToString(this), Encoding.UTF8, options.MediaType) + }; + message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/graphql-response+json")); + message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + message.Headers.AcceptCharset.Add(new StringWithQualityHeaderValue("utf-8")); + + // Explicitly setting content header to avoid issues with some GrahQL servers + message.Content.Headers.ContentType = new MediaTypeHeaderValue(options.MediaType); + + if (options.DefaultUserAgentRequestHeader != null) + message.Headers.UserAgent.Add(options.DefaultUserAgentRequestHeader); + + return message; + } +} diff --git a/src/GraphQL.Client/GraphQLHttpRequestException.cs b/src/GraphQL.Client/GraphQLHttpRequestException.cs new file mode 100644 index 00000000..808f1417 --- /dev/null +++ b/src/GraphQL.Client/GraphQLHttpRequestException.cs @@ -0,0 +1,38 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace GraphQL.Client.Http; + +/// +/// An exception thrown on unexpected +/// +public class GraphQLHttpRequestException : Exception +{ + /// + /// The returned status code + /// + public HttpStatusCode StatusCode { get; } + + /// + /// the returned response headers + /// + public HttpResponseHeaders ResponseHeaders { get; } + + /// + /// the returned content + /// + public string? Content { get; } + + /// + /// Creates a new instance of + /// + /// + /// + /// + public GraphQLHttpRequestException(HttpStatusCode statusCode, HttpResponseHeaders responseHeaders, string? content) : base($"The HTTP request failed with status code {statusCode}") + { + StatusCode = statusCode; + ResponseHeaders = responseHeaders; + Content = content; + } +} diff --git a/src/GraphQL.Client/GraphQLHttpResponse.cs b/src/GraphQL.Client/GraphQLHttpResponse.cs new file mode 100644 index 00000000..8b4f53ba --- /dev/null +++ b/src/GraphQL.Client/GraphQLHttpResponse.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace GraphQL.Client.Http; + +public class GraphQLHttpResponse : GraphQLResponse, IGraphQLHttpResponse +{ + public GraphQLHttpResponse(GraphQLResponse response, HttpResponseHeaders responseHeaders, HttpStatusCode statusCode) + { + Data = response.Data; + Errors = response.Errors; + Extensions = response.Extensions; + ResponseHeaders = responseHeaders; + StatusCode = statusCode; + } + + public HttpResponseHeaders ResponseHeaders { get; set; } + + public HttpStatusCode StatusCode { get; set; } +} + +public interface IGraphQLHttpResponse : IGraphQLResponse +{ + HttpResponseHeaders ResponseHeaders { get; set; } + + HttpStatusCode StatusCode { get; set; } +} + +public static class GraphQLResponseExtensions +{ + public static GraphQLHttpResponse ToGraphQLHttpResponse(this GraphQLResponse response, HttpResponseHeaders responseHeaders, HttpStatusCode statusCode) => new(response, responseHeaders, statusCode); + + /// + /// Casts to . Throws if the cast fails. + /// + /// + /// + /// is not a + /// + public static GraphQLHttpResponse AsGraphQLHttpResponse(this GraphQLResponse response) => (GraphQLHttpResponse)response; +} diff --git a/src/GraphQL.Client/GraphQLResponse.cs b/src/GraphQL.Client/GraphQLResponse.cs deleted file mode 100644 index 1f203156..00000000 --- a/src/GraphQL.Client/GraphQLResponse.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace GraphQL.Client { - - public class GraphQLResponse : IEquatable?> { - - public T Data { get; set; } - - public GraphQLError[]? Errors { get; set; } - - public IDictionary? Extensions { get; set; } - - public override bool Equals(object? obj) => this.Equals(obj as GraphQLResponse); - - public bool Equals(GraphQLResponse? other) { - if (other == null) { return false; } - if (ReferenceEquals(this, other)) { return true; } - if (!EqualityComparer.Default.Equals(this.Data, other.Data)) { return false; } - { - if (this.Errors != null && other.Errors != null) { - if (!Enumerable.SequenceEqual(this.Errors, other.Errors)) { return false; } - } - else if (this.Errors != null && other.Errors == null) { return false; } - else if (this.Errors == null && other.Errors != null) { return false; } - } - if (!EqualityComparer?>.Default.Equals(this.Extensions, other.Extensions)) { return false; } - return true; - } - - public override int GetHashCode() { - unchecked { - var hashCode = EqualityComparer.Default.GetHashCode(this.Data); - { - if (this.Errors != null) { - foreach (var element in this.Errors) { - hashCode = (hashCode * 397) ^ EqualityComparer.Default.GetHashCode(element); - } - } - else { - hashCode = (hashCode * 397) ^ 0; - } - } - hashCode = (hashCode * 397) ^ EqualityComparer?>.Default.GetHashCode(this.Extensions); - return hashCode; - } - } - - - public static bool operator ==(GraphQLResponse? response1, GraphQLResponse? response2) => EqualityComparer?>.Default.Equals(response1, response2); - - public static bool operator !=(GraphQLResponse? response1, GraphQLResponse? response2) => !(response1 == response2); - - } - - /// - /// The dynamic version of - /// - public class GraphQLResponse : GraphQLResponse { } - -} diff --git a/src/GraphQL.Client/GraphQLSubscriptionException.cs b/src/GraphQL.Client/GraphQLSubscriptionException.cs new file mode 100644 index 00000000..21dd8982 --- /dev/null +++ b/src/GraphQL.Client/GraphQLSubscriptionException.cs @@ -0,0 +1,25 @@ +#if !NET8_0_OR_GREATER +using System.Runtime.Serialization; +#endif + +namespace GraphQL.Client.Http; + +[Serializable] +public class GraphQLSubscriptionException : Exception +{ + public GraphQLSubscriptionException() + { + } + + public GraphQLSubscriptionException(object error) : base(error.ToString()) + { + } + +#if !NET8_0_OR_GREATER + protected GraphQLSubscriptionException( + SerializationInfo info, + StreamingContext context) : base(info, context) + { + } +#endif +} diff --git a/src/GraphQL.Client/IGraphQLClient.cs b/src/GraphQL.Client/IGraphQLClient.cs deleted file mode 100644 index d1576e3c..00000000 --- a/src/GraphQL.Client/IGraphQLClient.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace GraphQL.Client { - - public interface IGraphQLClient : IDisposable { - - Task> SendQueryAsync(GraphQLRequest request, CancellationToken cancellationToken = default); - - Task> SendMutationAsync(GraphQLRequest request, CancellationToken cancellationToken = default); - - } - -} diff --git a/src/GraphQL.Client/UriExtensions.cs b/src/GraphQL.Client/UriExtensions.cs new file mode 100644 index 00000000..54e623b7 --- /dev/null +++ b/src/GraphQL.Client/UriExtensions.cs @@ -0,0 +1,38 @@ +namespace GraphQL.Client.Http; + +public static class UriExtensions +{ + /// + /// Returns true if equals "wss" or "ws" + /// + /// + /// + public static bool HasWebSocketScheme(this Uri? uri) => + uri is not null && + (uri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) || uri.Scheme.Equals("ws", StringComparison.OrdinalIgnoreCase)); + + /// + /// Infers the websocket uri from . + /// + /// + /// + public static Uri GetWebSocketUri(this Uri uri) + { + if (uri is null) + throw new ArgumentNullException(nameof(uri)); + + if (uri.HasWebSocketScheme()) + return uri; + + string webSocketScheme; + + if (uri.Scheme == Uri.UriSchemeHttps) + webSocketScheme = "wss"; + else if (uri.Scheme == Uri.UriSchemeHttp) + webSocketScheme = "ws"; + else + throw new NotSupportedException($"cannot infer websocket uri from uri scheme {uri.Scheme}"); + + return new UriBuilder(uri) { Scheme = webSocketScheme }.Uri; + } +} diff --git a/src/GraphQL.Client/Websocket/GraphQLHttpWebSocket.cs b/src/GraphQL.Client/Websocket/GraphQLHttpWebSocket.cs new file mode 100644 index 00000000..4be55bc7 --- /dev/null +++ b/src/GraphQL.Client/Websocket/GraphQLHttpWebSocket.cs @@ -0,0 +1,628 @@ +using System.Diagnostics; +#pragma warning disable IDE0005 +// see https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/8.0/implicit-global-using-netfx +using System.Net.Http; +#pragma warning restore IDE0005 +using System.Net.WebSockets; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Reactive.Threading.Tasks; +using GraphQL.Client.Abstractions.Websocket; + +namespace GraphQL.Client.Http.Websocket; + + +internal class GraphQLHttpWebSocket : IDisposable +{ + + #region Private fields + + protected readonly Uri _webSocketUri; + protected readonly GraphQLHttpClient _client; + private readonly ArraySegment _buffer; + private readonly CancellationTokenSource _internalCancellationTokenSource = new(); + protected readonly CancellationToken _internalCancellationToken; + private readonly Subject _requestSubject = new(); + protected readonly Subject _exceptionSubject = new(); + protected readonly BehaviorSubject _stateSubject = new(GraphQLWebsocketConnectionState.Disconnected); + private readonly IDisposable _requestSubscription; + + protected int _connectionAttempt = 0; + protected IConnectableObservable _incomingMessages; + protected IDisposable _incomingMessagesConnection; + private IWebsocketProtocolHandler? _websocketProtocolHandler; + protected GraphQLHttpClientOptions Options => _client.Options; + + private Task _initializeWebSocketTask = Task.CompletedTask; + private readonly object _initializeLock = new(); + + +#if NETFRAMEWORK + protected WebSocket? _clientWebSocket = null; +#else + protected ClientWebSocket? _clientWebSocket = null; +#endif + + #endregion + + #region Public properties + + /// + /// The current websocket state + /// + public WebSocketState WebSocketState => _clientWebSocket?.State ?? WebSocketState.None; + + /// + /// Publishes all errors which occur within the receive pipeline + /// + public IObservable ReceiveErrors => _exceptionSubject.AsObservable(); + + /// + /// Publishes the connection state of the + /// + public IObservable ConnectionState => _stateSubject.DistinctUntilChanged(); + + /// + /// Publishes all messages which are received on the websocket + /// + public IObservable IncomingMessageStream { get; } + + /// + /// The websocket protocol used for subscriptions or full-websocket connections + /// + public string? WebsocketProtocol => _websocketProtocolHandler?.WebsocketProtocol; + + + public IObservable Pongs => null; + + #endregion + + public GraphQLHttpWebSocket(Uri webSocketUri, GraphQLHttpClient client) + { + _internalCancellationToken = _internalCancellationTokenSource.Token; + _webSocketUri = webSocketUri; + _client = client; + _buffer = new ArraySegment(new byte[8192]); + IncomingMessageStream = GetMessageStream(); + + _requestSubscription = _requestSubject + .Select(request => Observable.FromAsync(() => SendWebSocketRequestAsync(request))) + .Concat() + .Subscribe(); + + } + + /// + /// Returns the pong message stream. Subscribing initiates the websocket connection if not already established. + /// + /// + public IObservable GetPongStream() => + Observable.Defer(async () => + { + if (_websocketProtocolHandler is null) + { + await InitializeWebSocket().ConfigureAwait(false); + } + + return _websocketProtocolHandler.CreatePongObservable(); + }) + // complete sequence on OperationCanceledException, this is triggered by the cancellation token + .Catch(exception => + Observable.Empty()); + + #region Send requests + + /// + public async Task SendPingAsync(object? payload) + { + if (_websocketProtocolHandler is null) + { + await InitializeWebSocket().ConfigureAwait(false); + } + + await _websocketProtocolHandler.SendPingAsync(payload); + } + + /// + public async Task SendPongAsync(object? payload) + { + if (_websocketProtocolHandler is null) + { + await InitializeWebSocket().ConfigureAwait(false); + } + + await _websocketProtocolHandler.SendPongAsync(payload); + } + + /// + /// Send a regular GraphQL request (query, mutation) via websocket + /// + /// the response type + /// the to send + /// the token to cancel the request + /// + public async Task> SendRequestAsync(GraphQLRequest request, + CancellationToken cancellationToken = default) + { + if (_websocketProtocolHandler is null) + { + await InitializeWebSocket().ConfigureAwait(false); + } + + return await _websocketProtocolHandler.CreateGraphQLRequestObservable(request) + // complete sequence on OperationCanceledException, this is triggered by the cancellation token + .Catch, OperationCanceledException>(exception => + Observable.Empty>()) + .FirstAsync() + .ToTask(cancellationToken); + } + + /// + /// Create a new subscription stream + /// + /// the response type + /// the to start the subscription + /// Optional: exception handler for handling exceptions within the receive pipeline + /// a which represents the subscription + public IObservable> CreateSubscriptionStream(GraphQLRequest request, Action? exceptionHandler = null) => + Observable.Defer(async () => + { + if (_websocketProtocolHandler is null) + { + await InitializeWebSocket().ConfigureAwait(false); + } + return _websocketProtocolHandler?.CreateSubscriptionObservable(request); + }) + // complete sequence on OperationCanceledException, this is triggered by the cancellation token + .Catch, OperationCanceledException>(exception => + Observable.Empty>()) + // wrap results + .Select(response => new Tuple, Exception>(response, null)) + // do exception handling + .Catch, Exception>, Exception>(e => + { + try + { + if (exceptionHandler == null) + { + // if the external handler is not set, propagate all exceptions except WebSocketExceptions + // this will ensure that the client tries to re-establish subscriptions on connection loss + if (!(e is WebSocketException)) + throw e; + } + else + { + // exceptions thrown by the handler will propagate to OnError() + exceptionHandler?.Invoke(e); + } + + // throw exception on the observable to be caught by Retry() or complete sequence if cancellation was requested + if (_internalCancellationToken.IsCancellationRequested) + return Observable.Empty, Exception>>(); + else + { + Debug.WriteLine($"Catch handler thread id: {Thread.CurrentThread.ManagedThreadId}"); + return Observable.Throw, Exception>>(e); + } + } + catch (Exception exception) + { + // wrap all other exceptions to be propagated behind retry + return Observable.Return(new Tuple, Exception>(null, exception)); + } + }) + // attempt to recreate the websocket for rethrown exceptions + .Retry() + // unwrap and push results or throw wrapped exceptions + .SelectMany(t => + { + // if the result contains an exception, throw it on the observable + if (t.Item2 != null) + { + Debug.WriteLine($"unwrap exception thread id: {Thread.CurrentThread.ManagedThreadId} => {t.Item2}"); + return Observable.Throw>(t.Item2); + } + if (t.Item1 == null) + { + Debug.WriteLine($"empty item thread id: {Thread.CurrentThread.ManagedThreadId}"); + return Observable.Empty>(); + } + return Observable.Return(t.Item1); + }); + + protected Task QueueWebSocketRequest(GraphQLWebSocketRequest request) + { + _requestSubject.OnNext(request); + return request.SendTask(); + } + + protected async Task SendWebSocketRequestAsync(GraphQLWebSocketRequest request) + { + try + { + if (_internalCancellationToken.IsCancellationRequested) + { + request.SendCanceled(); + return Unit.Default; + } + + await InitializeWebSocket().ConfigureAwait(false); + await SendWebSocketMessageAsync(request, _internalCancellationToken).ConfigureAwait(false); + request.SendCompleted(); + } + catch (Exception e) + { + request.SendFailed(e); + } + return Unit.Default; + } + + protected async Task SendWebSocketMessageAsync(GraphQLWebSocketRequest request, CancellationToken cancellationToken = default) + { + var requestBytes = _client.JsonSerializer.SerializeToBytes(request); + await _clientWebSocket.SendAsync( + new ArraySegment(requestBytes), + WebSocketMessageType.Text, + true, + cancellationToken).ConfigureAwait(false); + } + + #endregion + + public Task InitializeWebSocket() + { + // do not attempt to initialize if cancellation is requested + if (Completion != null) + throw new OperationCanceledException(); + + lock (_initializeLock) + { + // if an initialization task is already running, return that + if (_initializeWebSocketTask != null && + !_initializeWebSocketTask.IsFaulted && + !_initializeWebSocketTask.IsCompleted) + return _initializeWebSocketTask; + + // if the websocket is open, return a completed task + if (_clientWebSocket != null && _clientWebSocket.State == WebSocketState.Open) + return Task.CompletedTask; + + // else (re-)create websocket and connect + _clientWebSocket?.Dispose(); + +#if NETFRAMEWORK + // fix websocket not supported on win 7 using + // https://github.com/PingmanTools/System.Net.WebSockets.Client.Managed + _clientWebSocket = SystemClientWebSocket.CreateClientWebSocket(); + switch (_clientWebSocket) + { + case ClientWebSocket nativeWebSocket: + ConfigureWebSocketSubProtocols(nativeWebSocket.Options.AddSubProtocol); + nativeWebSocket.Options.ClientCertificates = ((HttpClientHandler)Options.HttpMessageHandler).ClientCertificates; + nativeWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)Options.HttpMessageHandler).UseDefaultCredentials; + Options.ConfigureWebsocketOptions(nativeWebSocket.Options); + break; + case System.Net.WebSockets.Managed.ClientWebSocket managedWebSocket: + ConfigureWebSocketSubProtocols(managedWebSocket.Options.AddSubProtocol); + managedWebSocket.Options.ClientCertificates = ((HttpClientHandler)Options.HttpMessageHandler).ClientCertificates; + managedWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)Options.HttpMessageHandler).UseDefaultCredentials; + break; + default: + throw new NotSupportedException($"unknown websocket type {_clientWebSocket.GetType().Name}"); + } +#else + _clientWebSocket = new ClientWebSocket(); + ConfigureWebSocketSubProtocols(_clientWebSocket.Options.AddSubProtocol); + + // the following properties are not supported in Blazor WebAssembly and throw a PlatformNotSupportedException error when accessed + try + { + var certs = ((HttpClientHandler)Options.HttpMessageHandler).ClientCertificates; + if (certs != null) // ClientWebSocketOptions.ClientCertificates.set throws ArgumentNullException + _clientWebSocket.Options.ClientCertificates = certs; + } + catch (NotImplementedException) + { + Debug.WriteLine("property 'ClientWebSocketOptions.ClientCertificates' not implemented by current platform"); + } + catch (PlatformNotSupportedException) + { + Debug.WriteLine("property 'ClientWebSocketOptions.ClientCertificates' not supported by current platform"); + } + + try + { + _clientWebSocket.Options.UseDefaultCredentials = ((HttpClientHandler)Options.HttpMessageHandler).UseDefaultCredentials; + } + catch (NotImplementedException) + { + Debug.WriteLine("property 'ClientWebSocketOptions.UseDefaultCredentials' not implemented by current platform"); + } + catch (PlatformNotSupportedException) + { + Debug.WriteLine("Property 'ClientWebSocketOptions.UseDefaultCredentials' not supported by current platform"); + } + + Options.ConfigureWebsocketOptions(_clientWebSocket.Options); + +#endif + return _initializeWebSocketTask = ConnectAsync(_internalCancellationToken); + } + } + + public void ConfigureWebSocketSubProtocols(Action addSubProtocol) + { + if (_client.Options.WebSocketProtocol is null) + { + foreach (string protocol in WebSocketProtocols.GetSupportedWebSocketProtocols()) + { + addSubProtocol(protocol); + } + } + else + addSubProtocol(_client.Options.WebSocketProtocol); + } + + + protected async Task ConnectAsync(CancellationToken token) + { + try + { + //Client sends a WebSocket handshake request with the sub-protocol: graphql-transport-ws + await BackOff().ConfigureAwait(false); + _stateSubject.OnNext(GraphQLWebsocketConnectionState.Connecting); + Debug.WriteLine($"opening websocket {_clientWebSocket.GetHashCode()} (thread {Thread.CurrentThread.ManagedThreadId})"); + await _clientWebSocket.ConnectAsync(_webSocketUri, token).ConfigureAwait(false); + // check negotiated sub protocol and create matching instance of IWebsocketProtocolHandler + _websocketProtocolHandler = _clientWebSocket.SubProtocol switch + { + WebSocketProtocols.GRAPHQL_WS + => new GraphQLWSProtocolHandler(this, _client, QueueWebSocketRequest, SendWebSocketMessageAsync), + WebSocketProtocols.GRAPHQL_TRANSPORT_WS + => new GraphQLTransportWSProtocolHandler(this, _client, QueueWebSocketRequest, SendWebSocketMessageAsync), + _ => throw new NotSupportedException( + $"negotiated websocket protocol \"{_clientWebSocket.SubProtocol}\" not supported") + }; + _stateSubject.OnNext(GraphQLWebsocketConnectionState.Connected); + Debug.WriteLine($"connection established on websocket {_clientWebSocket.GetHashCode()}, invoking Options.OnWebsocketConnected()"); + await (Options.OnWebsocketConnected?.Invoke(_client) ?? Task.CompletedTask).ConfigureAwait(false); + Debug.WriteLine($"invoking Options.OnWebsocketConnected() on websocket {_clientWebSocket.GetHashCode()}"); + _connectionAttempt = 1; + + // Client immediately dispatches a ConnectionInit message optionally providing a payload as agreed with the server + + // create receiving observable + _incomingMessages = Observable + .Defer(() => GetReceiveTask().ToObservable()) + .Repeat() + // complete sequence on OperationCanceledException, this is triggered by the cancellation token on disposal + .Catch(exception => Observable.Empty()) + .Publish(); + + // subscribe maintenance + var maintenanceSubscription = _incomingMessages.Subscribe(_ => { }, ex => + { + Debug.WriteLine($"incoming message stream {_incomingMessages.GetHashCode()} received an error: {ex}"); + _exceptionSubject.OnNext(ex); + _incomingMessagesConnection?.Dispose(); + _stateSubject.OnNext(GraphQLWebsocketConnectionState.Disconnected); + }, + () => + { + Debug.WriteLine($"incoming message stream {_incomingMessages.GetHashCode()} completed"); + _incomingMessagesConnection?.Dispose(); + _stateSubject.OnNext(GraphQLWebsocketConnectionState.Disconnected); + }); + + + // connect observable + var connection = _incomingMessages.Connect(); + Debug.WriteLine($"new incoming message stream {_incomingMessages.GetHashCode()} created"); + + var closeConnectionDisposable = new CompositeDisposable(); + _incomingMessagesConnection = new CompositeDisposable(connection, maintenanceSubscription, closeConnectionDisposable); + + await _websocketProtocolHandler.InitializeConnectionAsync(_incomingMessages, closeConnectionDisposable).ConfigureAwait(false); + } + catch (Exception e) + { + Debug.WriteLine($"failed to establish websocket connection"); + _stateSubject.OnNext(GraphQLWebsocketConnectionState.Disconnected); + _exceptionSubject.OnNext(e); + throw; + } + } + + + /// + /// delay the next connection attempt using + /// + /// + protected Task BackOff() + { + _connectionAttempt++; + + if (_connectionAttempt == 1) + return Task.CompletedTask; + + var delay = Options.BackOffStrategy?.Invoke(_connectionAttempt - 1) ?? TimeSpan.FromSeconds(5); + Debug.WriteLine($"connection attempt #{_connectionAttempt}, backing off for {delay.TotalSeconds} s"); + return Task.Delay(delay, _internalCancellationToken); + } + + protected IObservable GetMessageStream() => + Observable.Create(async observer => + { + // make sure the websocket is connected + await InitializeWebSocket().ConfigureAwait(false); + // subscribe observer to message stream + var subscription = new CompositeDisposable(_incomingMessages + .Subscribe(observer)) + { + // register the observer's OnCompleted method with the cancellation token to complete the sequence on disposal + _internalCancellationTokenSource.Token.Register(observer.OnCompleted) + }; + + // add some debug output + var hashCode = subscription.GetHashCode(); + subscription.Add(Disposable.Create(() => Debug.WriteLine($"incoming message subscription {hashCode} disposed"))); + Debug.WriteLine($"new incoming message subscription {hashCode} created"); + + return subscription; + }); + + protected Task _receiveAsyncTask = null; + protected readonly object _receiveTaskLocker = new(); + /// + /// wrapper method to pick up the existing request task if already running + /// + /// + protected Task GetReceiveTask() + { + lock (_receiveTaskLocker) + { + _internalCancellationToken.ThrowIfCancellationRequested(); + if (_receiveAsyncTask == null || + _receiveAsyncTask.IsFaulted || + _receiveAsyncTask.IsCompleted) + _receiveAsyncTask = ReceiveWebsocketMessagesAsync(); + } + + return _receiveAsyncTask; + } + + /// + /// read a single message from the websocket + /// + /// + protected async Task ReceiveWebsocketMessagesAsync() + { + _internalCancellationToken.ThrowIfCancellationRequested(); + + try + { + Debug.WriteLine($"waiting for data on websocket {_clientWebSocket.GetHashCode()} (thread {Thread.CurrentThread.ManagedThreadId})..."); + + using var ms = new MemoryStream(); + WebSocketReceiveResult webSocketReceiveResult = null; + do + { + // cancellation is done implicitly via the close method + webSocketReceiveResult = await _clientWebSocket.ReceiveAsync(_buffer, CancellationToken.None).ConfigureAwait(false); + ms.Write(_buffer.Array, _buffer.Offset, webSocketReceiveResult.Count); + } + while (!webSocketReceiveResult.EndOfMessage && !_internalCancellationToken.IsCancellationRequested); + + _internalCancellationToken.ThrowIfCancellationRequested(); + ms.Seek(0, SeekOrigin.Begin); + + switch (webSocketReceiveResult.MessageType) + { + case WebSocketMessageType.Text: + var response = await _client.JsonSerializer.DeserializeToWebsocketResponseWrapperAsync(ms).ConfigureAwait(false); + response.MessageBytes = ms.ToArray(); + Debug.WriteLine($"{response.MessageBytes.Length} bytes received for id {response.Id} on websocket {_clientWebSocket.GetHashCode()} (thread {Thread.CurrentThread.ManagedThreadId})..."); + return response; + + case WebSocketMessageType.Close: + WebsocketMessageWrapper closeResponse = null; + try + { + closeResponse = await _client.JsonSerializer.DeserializeToWebsocketResponseWrapperAsync(ms).ConfigureAwait(false); + } + catch (Exception e) + { + Console.WriteLine(e); + } + if (closeResponse != null) + closeResponse.MessageBytes = ms.ToArray(); + Debug.WriteLine($"Connection closed by the server."); + throw new Exception("Connection closed by the server."); + + default: + throw new NotSupportedException($"Websocket message type {webSocketReceiveResult.MessageType} not supported."); + + } + } + catch (Exception e) + { + Debug.WriteLine($"exception thrown while receiving websocket data: {e}"); + throw; + } + } + + protected async Task CloseAsync() + { + if (_clientWebSocket == null) + return; + + // don't attempt to close the websocket if it is in a failed state + if (_clientWebSocket.State != WebSocketState.Open && + _clientWebSocket.State != WebSocketState.CloseReceived && + _clientWebSocket.State != WebSocketState.CloseSent) + { + Debug.WriteLine($"websocket {_clientWebSocket.GetHashCode()} state = {_clientWebSocket.State}"); + return; + } + + if (_websocketProtocolHandler is not null) + { + Debug.WriteLine($"send \"connection_terminate\" message"); + await _websocketProtocolHandler.SendCloseConnectionRequestAsync().ConfigureAwait(false); + } + + Debug.WriteLine($"closing websocket {_clientWebSocket.GetHashCode()}"); + await _clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None).ConfigureAwait(false); + _stateSubject.OnNext(GraphQLWebsocketConnectionState.Disconnected); + + } + + #region IDisposable + + public void Dispose() => Complete(); + + /// + /// Cancels the current operation, closes the websocket connection and disposes of internal resources. + /// + public void Complete() + { + lock (_completedLocker) + { + Completion ??= CompleteAsync(); + } + } + + /// + /// Task to await the completion (a.k.a. disposal) of this websocket. + /// + /// Async disposal as recommended by Stephen Cleary (https://blog.stephencleary.com/2013/03/async-oop-6-disposal.html) + public Task? Completion { get; protected set; } + + protected readonly object _completedLocker = new(); + protected async Task CompleteAsync() + { + Debug.WriteLine("disposing GraphQLHttpWebSocket..."); + + _incomingMessagesConnection?.Dispose(); + + if (!_internalCancellationTokenSource.IsCancellationRequested) + _internalCancellationTokenSource.Cancel(); + + await CloseAsync().ConfigureAwait(false); + _requestSubscription?.Dispose(); + _clientWebSocket?.Dispose(); + + _stateSubject?.OnCompleted(); + _stateSubject?.Dispose(); + + _exceptionSubject?.OnCompleted(); + _exceptionSubject?.Dispose(); + _internalCancellationTokenSource.Dispose(); + + Debug.WriteLine("GraphQLHttpWebSocket disposed"); + } + + #endregion +} diff --git a/src/GraphQL.Client/Websocket/GraphQLTransportWSProtocolHandler.cs b/src/GraphQL.Client/Websocket/GraphQLTransportWSProtocolHandler.cs new file mode 100644 index 00000000..2f3b28b0 --- /dev/null +++ b/src/GraphQL.Client/Websocket/GraphQLTransportWSProtocolHandler.cs @@ -0,0 +1,321 @@ +using System.Diagnostics; +using System.Net.WebSockets; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Text; +using GraphQL.Client.Abstractions.Websocket; + +namespace GraphQL.Client.Http.Websocket; + +internal class GraphQLTransportWSProtocolHandler : IWebsocketProtocolHandler +{ + public string WebsocketProtocol => WebSocketProtocols.GRAPHQL_TRANSPORT_WS; + + private readonly GraphQLHttpWebSocket _webSocketHandler; + private readonly GraphQLHttpClient _client; + private readonly Func _queueWebSocketRequest; + private readonly Func _sendWebsocketMessage; + + public GraphQLTransportWSProtocolHandler( + GraphQLHttpWebSocket webSocketHandler, + GraphQLHttpClient client, + Func queueWebSocketRequest, + Func sendWebsocketMessage) + { + _webSocketHandler = webSocketHandler; + _client = client; + _queueWebSocketRequest = queueWebSocketRequest; + _sendWebsocketMessage = sendWebsocketMessage; + } + + public IObservable> CreateSubscriptionObservable(GraphQLRequest request) + => Observable.Create>(async observer => + { + Debug.WriteLine($"Create observable thread id: {Thread.CurrentThread.ManagedThreadId}"); + var preprocessedRequest = await _client.Options.PreprocessRequest(request, _client).ConfigureAwait(false); + + + var startRequest = new GraphQLWebSocketRequest + { + Id = Guid.NewGuid().ToString("N"), + Type = GraphQLWebSocketMessageType.GQL_SUBSCRIBE, + Payload = preprocessedRequest + }; + + + var stopRequest = new GraphQLWebSocketRequest + { + Id = startRequest.Id, + Type = GraphQLWebSocketMessageType.GQL_COMPLETE + }; + + var observable = Observable.Create>(o => + _webSocketHandler.IncomingMessageStream + // ignore null values and messages for other requests + .Where(response => response != null && response.Id == startRequest.Id) + .Subscribe(response => + { + // terminate the sequence when a 'complete' message is received + if (response.Type == GraphQLWebSocketMessageType.GQL_COMPLETE) + { + Debug.WriteLine($"received 'complete' message on subscription {startRequest.Id}"); + o.OnCompleted(); + return; + } + + if (!(response.Type == GraphQLWebSocketMessageType.GQL_NEXT || response.Type == GraphQLWebSocketMessageType.GQL_ERROR)) + throw new WebSocketException("Did not receive 'next' nor 'error'"); + + switch (response.Type) + { + case GraphQLWebSocketMessageType.GQL_NEXT: + // post the GraphQLResponse to the stream (even if a GraphQL error occurred) + Debug.WriteLine($"received payload on subscription {startRequest.Id} (thread {Thread.CurrentThread.ManagedThreadId})"); + var typedResponse = _client.JsonSerializer.DeserializeToWebsocketResponse>(response.MessageBytes); + Debug.WriteLine($"payload => {Encoding.UTF8.GetString(response.MessageBytes)}"); + o.OnNext(typedResponse.Payload); + break; + case GraphQLWebSocketMessageType.GQL_ERROR: + // the payload only consists of the error array + Debug.WriteLine($"received error on subscription {startRequest.Id} (thread {Thread.CurrentThread.ManagedThreadId})"); + Debug.WriteLine($"payload => {Encoding.UTF8.GetString(response.MessageBytes)}"); + var errorResponse = _client.JsonSerializer.DeserializeToWebsocketResponse(response.MessageBytes); + o.OnNext(new GraphQLResponse { Errors = errorResponse.Payload }); + // in case of a GraphQL error, terminate the sequence after the response has been posted + Debug.WriteLine($"terminating subscription {startRequest.Id} because of a GraphQL error"); + o.OnCompleted(); + break; + default: + // If the message type was not 'complete', then it must only be 'next' or 'error' + throw new WebSocketException("Did not receive 'next' nor 'error'"); + } + }, + e => + { + Debug.WriteLine($"response stream for subscription {startRequest.Id} failed: {e}"); + o.OnError(e); + }, + () => + { + Debug.WriteLine($"response stream for subscription {startRequest.Id} completed"); + o.OnCompleted(); + }) + ); + + try + { + // initialize websocket (completes immediately if socket is already open) + await _webSocketHandler.InitializeWebSocket().ConfigureAwait(false); + } + catch (Exception e) + { + // subscribe observer to failed observable + return (CompositeDisposable)Observable.Throw>(e).Subscribe(observer); + } + + var disposable = new CompositeDisposable( + observable.Subscribe(observer), + Disposable.Create(() => + { + Debug.WriteLine($"disposing subscription {startRequest.Id}, websocket state is '{_webSocketHandler.WebSocketState}'"); + // only try to send close request on open websocket + if (_webSocketHandler.WebSocketState != WebSocketState.Open) + return; + + try + { + Debug.WriteLine($"sending stop message on subscription {startRequest.Id}"); + _queueWebSocketRequest(stopRequest).GetAwaiter().GetResult(); + } + // do not break on disposing + catch (OperationCanceledException) { } + }) + ); + + Debug.WriteLine($"sending start message on subscription {startRequest.Id}"); + // send subscription request + try + { + await _queueWebSocketRequest(startRequest).ConfigureAwait(false); + } + catch (Exception e) + { + Debug.WriteLine(e); + throw; + } + + return disposable; + }); + + public IObservable> CreateGraphQLRequestObservable(GraphQLRequest request) + => Observable.Create>(async observer => + { + var preprocessedRequest = await _client.Options.PreprocessRequest(request, _client).ConfigureAwait(false); + var websocketRequest = new GraphQLWebSocketRequest + { + Id = Guid.NewGuid().ToString("N"), + Type = GraphQLWebSocketMessageType.GQL_SUBSCRIBE, + Payload = preprocessedRequest + }; + var observable = _webSocketHandler.IncomingMessageStream + .Where(response => response != null && response.Id == websocketRequest.Id) + .TakeUntil(response => response.Type == GraphQLWebSocketMessageType.GQL_COMPLETE) + .Select(response => + { + Debug.WriteLine($"received response for request {websocketRequest.Id}"); + switch (response.Type) + { + case GraphQLWebSocketMessageType.GQL_NEXT: + var typedResponse = _client.JsonSerializer.DeserializeToWebsocketResponse>(response.MessageBytes); + return typedResponse.Payload; + case GraphQLWebSocketMessageType.GQL_ERROR: + // the payload only consists of the error array + var errorResponse = _client.JsonSerializer.DeserializeToWebsocketResponse(response.MessageBytes); + return new GraphQLResponse { Errors = errorResponse.Payload }; + } + return null; + }); + + try + { + // initialize websocket (completes immediately if socket is already open) + await _webSocketHandler.InitializeWebSocket().ConfigureAwait(false); + } + catch (Exception e) + { + // subscribe observer to failed observable + return Observable.Throw>(e).Subscribe(observer); + } + + var disposable = new CompositeDisposable( + observable.Subscribe(observer) + ); + + Debug.WriteLine($"submitting request {websocketRequest.Id}"); + // send request + try + { + await _queueWebSocketRequest(websocketRequest).ConfigureAwait(false); + } + catch (Exception e) + { + Debug.WriteLine(e); + throw; + } + + return disposable; + }); + + public IObservable CreatePongObservable() + => _webSocketHandler.IncomingMessageStream + .Where(msg => msg != null && msg.Type == GraphQLWebSocketMessageType.GQL_PONG) + .Select(msg => + { + object? payload = null; + try + { + // try to deserialize response to put it back in the pong request + var responseObject = + _client.JsonSerializer.DeserializeToWebsocketResponse(msg.MessageBytes); + payload = responseObject.Payload; + } + catch (Exception) + { + // ignore exception + } + + return payload; + }); + + public async Task InitializeConnectionAsync(IObservable incomingMessages, + CompositeDisposable closeConnectionDisposable) + { + var initRequest = new GraphQLWebSocketRequest + { + Type = GraphQLWebSocketMessageType.GQL_CONNECTION_INIT, + Payload = _client.Options.ConfigureWebSocketConnectionInitPayload(_client.Options) + }; + + // setup task to await connection_ack message + var ackTask = incomingMessages + .Where(response => response != null) + .TakeUntil(response => response.Type == GraphQLWebSocketMessageType.GQL_CONNECTION_ACK || + response.Type == GraphQLWebSocketMessageType.GQL_CONNECTION_ERROR) + .LastAsync() + .ToTask(); + + // send connection init + Debug.WriteLine($"sending connection init message"); + await _sendWebsocketMessage(initRequest, CancellationToken.None).ConfigureAwait(false); + var response = await ackTask.ConfigureAwait(false); + + if (response.Type != GraphQLWebSocketMessageType.GQL_CONNECTION_ACK) + { + string? errorPayload = Encoding.UTF8.GetString(response.MessageBytes); + Debug.WriteLine($"connection error received: {errorPayload}"); + throw new GraphQLWebsocketConnectionException(errorPayload); + } + + Debug.WriteLine($"connection acknowledged: {Encoding.UTF8.GetString(response.MessageBytes)}"); + + closeConnectionDisposable.Add(incomingMessages + .Where(msg => msg != null && msg.Type == GraphQLWebSocketMessageType.GQL_PING) + .SelectMany(msg => Observable.FromAsync(() => RespondWithPongAsync(msg))) + .Subscribe()); + } + + public Task SendCloseConnectionRequestAsync() + => _sendWebsocketMessage(new GraphQLWebSocketRequest { Type = GraphQLWebSocketMessageType.GQL_COMPLETE }, CancellationToken.None); + + private async Task RespondWithPongAsync(WebsocketMessageWrapper pingMessage) + { + // respond with a PONG when a ping is received + if (pingMessage.Type == GraphQLWebSocketMessageType.GQL_PING) + { + object? payload = null; + try + { + // try to deserialize response to put it back in the pong request + var responseObject = _client.JsonSerializer.DeserializeToWebsocketResponse(pingMessage.MessageBytes); + payload = responseObject.Payload; + } + catch (Exception) + { + // ignore exception + } + Debug.WriteLine($"ping received, responding with pong"); + var pongRequest = new GraphQLWebSocketRequest + { + Type = GraphQLWebSocketMessageType.GQL_PONG, + Payload = payload + }; + + await _queueWebSocketRequest(pongRequest).ConfigureAwait(false); + } + } + + public Task SendPingAsync(object? payload) + { + Debug.WriteLine("sending ping"); + var webSocketRequest = new GraphQLWebSocketRequest + { + Type = GraphQLWebSocketMessageType.GQL_PING, + Payload = payload + }; + + return _queueWebSocketRequest(webSocketRequest); + } + + public Task SendPongAsync(object? payload) + { + Debug.WriteLine("sending pong"); + var webSocketRequest = new GraphQLWebSocketRequest + { + Type = GraphQLWebSocketMessageType.GQL_PONG, + Payload = payload + }; + + return _queueWebSocketRequest(webSocketRequest); + } +} diff --git a/src/GraphQL.Client/Websocket/GraphQLWSProtocolHandler.cs b/src/GraphQL.Client/Websocket/GraphQLWSProtocolHandler.cs new file mode 100644 index 00000000..e08da57e --- /dev/null +++ b/src/GraphQL.Client/Websocket/GraphQLWSProtocolHandler.cs @@ -0,0 +1,235 @@ +using System.Diagnostics; +using System.Net.WebSockets; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Text; +using GraphQL.Client.Abstractions.Websocket; + +namespace GraphQL.Client.Http.Websocket; + +internal class GraphQLWSProtocolHandler : IWebsocketProtocolHandler +{ + public string WebsocketProtocol => WebSocketProtocols.GRAPHQL_WS; + + private readonly GraphQLHttpWebSocket _webSocketHandler; + private readonly GraphQLHttpClient _client; + private readonly Func _queueWebSocketRequest; + private readonly Func _sendWebsocketMessage; + + public GraphQLWSProtocolHandler( + GraphQLHttpWebSocket webSocketHandler, + GraphQLHttpClient client, + Func queueWebSocketRequest, + Func sendWebsocketMessage) + { + _webSocketHandler = webSocketHandler; + _client = client; + _queueWebSocketRequest = queueWebSocketRequest; + _sendWebsocketMessage = sendWebsocketMessage; + } + + public IObservable> CreateSubscriptionObservable(GraphQLRequest request) + => Observable.Create>(async observer => + { + Debug.WriteLine($"Create observable thread id: {Thread.CurrentThread.ManagedThreadId}"); + var preprocessedRequest = await _client.Options.PreprocessRequest(request, _client).ConfigureAwait(false); + + var startRequest = new GraphQLWebSocketRequest + { + Id = Guid.NewGuid().ToString("N"), + Type = GraphQLWebSocketMessageType.GQL_START, + Payload = preprocessedRequest + }; + var stopRequest = new GraphQLWebSocketRequest + { + Id = startRequest.Id, + Type = GraphQLWebSocketMessageType.GQL_STOP + }; + + var observable = Observable.Create>(o => + _webSocketHandler.IncomingMessageStream + // ignore null values and messages for other requests + .Where(response => response != null && response.Id == startRequest.Id) + .Subscribe(response => + { + // terminate the sequence when a 'complete' message is received + if (response.Type == GraphQLWebSocketMessageType.GQL_COMPLETE) + { + Debug.WriteLine($"received 'complete' message on subscription {startRequest.Id}"); + o.OnCompleted(); + return; + } + + // post the GraphQLResponse to the stream (even if a GraphQL error occurred) + Debug.WriteLine($"received payload on subscription {startRequest.Id} (thread {Thread.CurrentThread.ManagedThreadId})"); + var typedResponse = + _client.JsonSerializer.DeserializeToWebsocketResponse>( + response.MessageBytes); + Debug.WriteLine($"payload => {System.Text.Encoding.UTF8.GetString(response.MessageBytes)}"); + o.OnNext(typedResponse.Payload); + + // in case of a GraphQL error, terminate the sequence after the response has been posted + if (response.Type == GraphQLWebSocketMessageType.GQL_ERROR) + { + Debug.WriteLine($"terminating subscription {startRequest.Id} because of a GraphQL error"); + o.OnCompleted(); + } + }, + e => + { + Debug.WriteLine($"response stream for subscription {startRequest.Id} failed: {e}"); + o.OnError(e); + }, + () => + { + Debug.WriteLine($"response stream for subscription {startRequest.Id} completed"); + o.OnCompleted(); + }) + ); + + try + { + // initialize websocket (completes immediately if socket is already open) + await _webSocketHandler.InitializeWebSocket().ConfigureAwait(false); + } + catch (Exception e) + { + // subscribe observer to failed observable + return Observable.Throw>(e).Subscribe(observer); + } + + var disposable = new CompositeDisposable( + observable.Subscribe(observer), + Disposable.Create(() => + { + Debug.WriteLine($"disposing subscription {startRequest.Id}, websocket state is '{_webSocketHandler.WebSocketState}'"); + // only try to send close request on open websocket + if (_webSocketHandler.WebSocketState != WebSocketState.Open) + return; + + try + { + Debug.WriteLine($"sending stop message on subscription {startRequest.Id}"); + _queueWebSocketRequest(stopRequest).GetAwaiter().GetResult(); + } + // do not break on disposing + catch (OperationCanceledException) { } + }) + ); + + Debug.WriteLine($"sending start message on subscription {startRequest.Id}"); + // send subscription request + try + { + await _queueWebSocketRequest(startRequest).ConfigureAwait(false); + } + catch (Exception e) + { + Debug.WriteLine(e); + throw; + } + + return disposable; + }); + + public IObservable> CreateGraphQLRequestObservable(GraphQLRequest request) + => Observable.Create>(async observer => + { + var preprocessedRequest = await _client.Options.PreprocessRequest(request, _client).ConfigureAwait(false); + var websocketRequest = new GraphQLWebSocketRequest + { + Id = Guid.NewGuid().ToString("N"), + Type = GraphQLWebSocketMessageType.GQL_START, + Payload = preprocessedRequest + }; + var observable = _webSocketHandler.IncomingMessageStream + .Where(response => response != null && response.Id == websocketRequest.Id) + .TakeUntil(response => response.Type == GraphQLWebSocketMessageType.GQL_COMPLETE) + .Select(response => + { + Debug.WriteLine($"received response for request {websocketRequest.Id}"); + var typedResponse = + _client.JsonSerializer.DeserializeToWebsocketResponse>( + response.MessageBytes); + return typedResponse.Payload; + }); + + try + { + // initialize websocket (completes immediately if socket is already open) + await _webSocketHandler.InitializeWebSocket().ConfigureAwait(false); + } + catch (Exception e) + { + // subscribe observer to failed observable + return Observable.Throw>(e).Subscribe(observer); + } + + var disposable = new CompositeDisposable( + observable.Subscribe(observer) + ); + + Debug.WriteLine($"submitting request {websocketRequest.Id}"); + // send request + try + { + await _queueWebSocketRequest(websocketRequest).ConfigureAwait(false); + } + catch (Exception e) + { + Debug.WriteLine(e); + throw; + } + + return disposable; + }); + + + public async Task InitializeConnectionAsync(IObservable incomingMessages, + CompositeDisposable closeConnectionDisposable) + { + var initRequest = new GraphQLWebSocketRequest + { + Type = GraphQLWebSocketMessageType.GQL_CONNECTION_INIT, + Payload = _client.Options.ConfigureWebSocketConnectionInitPayload(_client.Options) + }; + + // setup task to await connection_ack message + var ackTask = incomingMessages + .Where(response => response != null) + .TakeUntil(response => response.Type == GraphQLWebSocketMessageType.GQL_CONNECTION_ACK || + response.Type == GraphQLWebSocketMessageType.GQL_CONNECTION_ERROR) + .LastAsync() + .ToTask(); + + // send connection init + Debug.WriteLine($"sending connection init message"); + await _sendWebsocketMessage(initRequest, CancellationToken.None).ConfigureAwait(false); + var response = await ackTask.ConfigureAwait(false); + + if (response.Type != GraphQLWebSocketMessageType.GQL_CONNECTION_ACK) + { + string? errorPayload = Encoding.UTF8.GetString(response.MessageBytes); + Debug.WriteLine($"connection error received: {errorPayload}"); + throw new GraphQLWebsocketConnectionException(errorPayload); + } + + Debug.WriteLine($"connection acknowledged: {Encoding.UTF8.GetString(response.MessageBytes)}"); + } + + public Task SendCloseConnectionRequestAsync() + => _sendWebsocketMessage(new GraphQLWebSocketRequest { Type = GraphQLWebSocketMessageType.GQL_CONNECTION_TERMINATE }, CancellationToken.None); + + public IObservable CreatePongObservable() + => throw PingPongNotSupportedException; + + public Task SendPingAsync(object? payload) + => throw PingPongNotSupportedException; + + public Task SendPongAsync(object? payload) + => throw PingPongNotSupportedException; + + private NotSupportedException PingPongNotSupportedException + => new("ping/pong is not supported by the \"graphql-ws\" websocket protocol"); +} diff --git a/src/GraphQL.Client/Websocket/GraphQLWebsocketConnectionException.cs b/src/GraphQL.Client/Websocket/GraphQLWebsocketConnectionException.cs new file mode 100644 index 00000000..aedebb90 --- /dev/null +++ b/src/GraphQL.Client/Websocket/GraphQLWebsocketConnectionException.cs @@ -0,0 +1,28 @@ +#if !NET8_0_OR_GREATER +using System.Runtime.Serialization; +#endif + +namespace GraphQL.Client.Http.Websocket; + +[Serializable] +public class GraphQLWebsocketConnectionException : Exception +{ + public GraphQLWebsocketConnectionException() + { + } + + public GraphQLWebsocketConnectionException(string message) : base(message) + { + } + + public GraphQLWebsocketConnectionException(string message, Exception innerException) : base(message, innerException) + { + } + +#if !NET8_0_OR_GREATER + protected GraphQLWebsocketConnectionException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +#endif + +} diff --git a/src/GraphQL.Client/Websocket/IWebsocketProtocolHandler.cs b/src/GraphQL.Client/Websocket/IWebsocketProtocolHandler.cs new file mode 100644 index 00000000..dc097ee4 --- /dev/null +++ b/src/GraphQL.Client/Websocket/IWebsocketProtocolHandler.cs @@ -0,0 +1,23 @@ +using System.Reactive.Disposables; +using GraphQL.Client.Abstractions.Websocket; + +namespace GraphQL.Client.Http.Websocket; + +public interface IWebsocketProtocolHandler +{ + string WebsocketProtocol { get; } + + IObservable> CreateSubscriptionObservable(GraphQLRequest request); + + IObservable> CreateGraphQLRequestObservable(GraphQLRequest request); + + IObservable CreatePongObservable(); + + Task InitializeConnectionAsync(IObservable incomingMessages, CompositeDisposable closeConnectionDisposable); + + Task SendCloseConnectionRequestAsync(); + + Task SendPingAsync(object? payload); + + Task SendPongAsync(object? payload); +} diff --git a/src/GraphQL.Client/Websocket/WebSocketProtocols.cs b/src/GraphQL.Client/Websocket/WebSocketProtocols.cs new file mode 100644 index 00000000..957f8708 --- /dev/null +++ b/src/GraphQL.Client/Websocket/WebSocketProtocols.cs @@ -0,0 +1,20 @@ +using System.Reflection; + +namespace GraphQL.Client.Http.Websocket; +public static class WebSocketProtocols +{ + public const string AUTO_NEGOTIATE = null; + + //The WebSocket sub-protocol used for the [GraphQL over WebSocket Protocol](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md). + public const string GRAPHQL_TRANSPORT_WS = "graphql-transport-ws"; + + //The deprecated subprotocol used by [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws). + public const string GRAPHQL_WS = "graphql-ws"; + + public static IEnumerable GetSupportedWebSocketProtocols() => + typeof(WebSocketProtocols) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(info => (info.IsLiteral || info.IsInitOnly) && info.FieldType == typeof(string)) + .Select(f => f.IsLiteral ? (string)f.GetRawConstantValue() : (string)f.GetValue(null)) + .Where(s => s is not null); +} diff --git a/src/GraphQL.Primitives/ErrorPath.cs b/src/GraphQL.Primitives/ErrorPath.cs new file mode 100644 index 00000000..a43bf02e --- /dev/null +++ b/src/GraphQL.Primitives/ErrorPath.cs @@ -0,0 +1,12 @@ +namespace GraphQL; + +public class ErrorPath : List +{ + public ErrorPath() + { + } + + public ErrorPath(IEnumerable collection) : base(collection) + { + } +} diff --git a/src/GraphQL.Primitives/GraphQL.Primitives.csproj b/src/GraphQL.Primitives/GraphQL.Primitives.csproj index ca03991d..9cbacbe6 100644 --- a/src/GraphQL.Primitives/GraphQL.Primitives.csproj +++ b/src/GraphQL.Primitives/GraphQL.Primitives.csproj @@ -1,15 +1,12 @@ - - - - GraphQL basic types - GraphQL - netstandard1.0;netstandard2.0 - - - - - - + + GraphQL basic types + GraphQL + netstandard2.0;net6.0;net7.0;net8.0 + + + + + diff --git a/src/GraphQL.Primitives/GraphQLError.cs b/src/GraphQL.Primitives/GraphQLError.cs index 6ccdd39c..9732c4b6 100644 --- a/src/GraphQL.Primitives/GraphQLError.cs +++ b/src/GraphQL.Primitives/GraphQLError.cs @@ -1,105 +1,114 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Runtime.Serialization; -namespace GraphQL { +namespace GraphQL; - /// - /// Represents a GraphQL Error of a GraphQL Query - /// - public class GraphQLError : IEquatable { +/// +/// Represents a GraphQL Error of a GraphQL Query +/// +public class GraphQLError : IEquatable +{ + /// + /// The locations of the error + /// + [DataMember(Name = "locations")] + public GraphQLLocation[]? Locations { get; set; } - /// - /// The extensions of the error - /// - public IDictionary? Extensions { get; set; } + /// + /// The message of the error + /// + [DataMember(Name = "message")] + public string Message { get; set; } - /// - /// The locations of the error - /// - public GraphQLLocation[]? Locations { get; set; } + /// + /// The Path of the error + /// + [DataMember(Name = "path")] + public ErrorPath? Path { get; set; } - /// - /// The message of the error - /// - public string Message { get; set; } + /// + /// The extensions of the error + /// + [DataMember(Name = "extensions")] + public Map? Extensions { get; set; } - /// - /// The Path of the error - /// - public dynamic[]? Path { get; set; } + /// + /// Returns a value that indicates whether this instance is equal to a specified object + /// + /// The object to compare with this instance + /// true if obj is an instance of and equals the value of the instance; otherwise, false + public override bool Equals(object? obj) => Equals(obj as GraphQLError); - /// - /// Returns a value that indicates whether this instance is equal to a specified object - /// - /// The object to compare with this instance - /// true if obj is an instance of and equals the value of the instance; otherwise, false - public override bool Equals(object? obj) => - this.Equals(obj as GraphQLError); + /// + /// Returns a value that indicates whether this instance is equal to a specified object + /// + /// The object to compare with this instance + /// true if obj is an instance of and equals the value of the instance; otherwise, false + public bool Equals(GraphQLError? other) + { + if (other == null) + { return false; } + if (ReferenceEquals(this, other)) + { return true; } + { + if (Locations != null && other.Locations != null) + { + if (!Locations.SequenceEqual(other.Locations)) + { return false; } + } + else if (Locations != null && other.Locations == null) + { return false; } + else if (Locations == null && other.Locations != null) + { return false; } + } + if (!EqualityComparer.Default.Equals(Message, other.Message)) + { return false; } + { + if (Path != null && other.Path != null) + { + if (!Path.SequenceEqual(other.Path)) + { return false; } + } + else if (Path != null && other.Path == null) + { return false; } + else if (Path == null && other.Path != null) + { return false; } + } + return true; + } - /// - /// Returns a value that indicates whether this instance is equal to a specified object - /// - /// The object to compare with this instance - /// true if obj is an instance of and equals the value of the instance; otherwise, false - public bool Equals(GraphQLError? other) { - if (other == null) { return false; } - if (ReferenceEquals(this, other)) { return true; } - if (!EqualityComparer?>.Default.Equals(this.Extensions, other.Extensions)) { return false; } - { - if (this.Locations != null && other.Locations != null) { - if (!this.Locations.SequenceEqual(other.Locations)) { return false; } - } - else if (this.Locations != null && other.Locations == null) { return false; } - else if (this.Locations == null && other.Locations != null) { return false; } - } - if (!EqualityComparer.Default.Equals(this.Message, other.Message)) { return false; } - { - if (this.Path != null && other.Path != null) { - if (!this.Path.SequenceEqual(other.Path)) { return false; } - } - else if (this.Path != null && other.Path == null) { return false; } - else if (this.Path == null && other.Path != null) { return false; } - } - return true; - } + /// + /// + /// + public override int GetHashCode() + { + var hashCode = 0; + if (Locations != null) + { + hashCode ^= EqualityComparer.Default.GetHashCode(Locations); + } + hashCode ^= EqualityComparer.Default.GetHashCode(Message); + if (Path != null) + { + hashCode ^= EqualityComparer.Default.GetHashCode(Path); + } + return hashCode; + } - /// - /// - /// - public override int GetHashCode() { - var hashCode = 0; - if (this.Extensions != null) { - hashCode = hashCode ^ EqualityComparer>.Default.GetHashCode(this.Extensions); - } - if (this.Locations != null) { - hashCode = hashCode ^ EqualityComparer.Default.GetHashCode(this.Locations); - } - hashCode = hashCode ^ EqualityComparer.Default.GetHashCode(this.Message); - if (this.Path != null) { - hashCode = hashCode ^ EqualityComparer.Default.GetHashCode(this.Path); - } - return hashCode; - } - - /// - /// Tests whether two specified instances are equivalent - /// - /// The instance that is to the left of the equality operator - /// The instance that is to the right of the equality operator - /// true if left and right are equal; otherwise, false - public static bool operator ==(GraphQLError? left, GraphQLError? right) => - EqualityComparer.Default.Equals(left, right); - - /// - /// Tests whether two specified instances are not equal - /// - /// The instance that is to the left of the not equal operator - /// The instance that is to the right of the not equal operator - /// true if left and right are unequal; otherwise, false - public static bool operator !=(GraphQLError? left, GraphQLError? right) => - !EqualityComparer.Default.Equals(left, right); - - } + /// + /// Tests whether two specified instances are equivalent + /// + /// The instance that is to the left of the equality operator + /// The instance that is to the right of the equality operator + /// true if left and right are equal; otherwise, false + public static bool operator ==(GraphQLError? left, GraphQLError? right) => + EqualityComparer.Default.Equals(left, right); + /// + /// Tests whether two specified instances are not equal + /// + /// The instance that is to the left of the not equal operator + /// The instance that is to the right of the not equal operator + /// true if left and right are unequal; otherwise, false + public static bool operator !=(GraphQLError? left, GraphQLError? right) => + !EqualityComparer.Default.Equals(left, right); } diff --git a/src/GraphQL.Primitives/GraphQLLocation.cs b/src/GraphQL.Primitives/GraphQLLocation.cs index 0147b05a..efec6256 100644 --- a/src/GraphQL.Primitives/GraphQLLocation.cs +++ b/src/GraphQL.Primitives/GraphQLLocation.cs @@ -1,66 +1,63 @@ -using System; -using System.Collections.Generic; - -namespace GraphQL { - - /// - /// Represents a GraphQL Location of a GraphQL Query - /// - public sealed class GraphQLLocation : IEquatable { - - /// - /// The Column - /// - public uint Column { get; set; } - - /// - /// The Line - /// - public uint Line { get; set; } - - /// - /// Returns a value that indicates whether this instance is equal to a specified object - /// - /// The object to compare with this instance - /// true if obj is an instance of and equals the value of the instance; otherwise, false - public override bool Equals(object obj) => this.Equals(obj as GraphQLLocation); - - /// - /// Returns a value that indicates whether this instance is equal to a specified object - /// - /// The object to compare with this instance - /// true if obj is an instance of and equals the value of the instance; otherwise, false - public bool Equals(GraphQLLocation? other) { - if (other == null) { return false; } - if (ReferenceEquals(this, other)) { return true; } - return EqualityComparer.Default.Equals(this.Column, other.Column) && - EqualityComparer.Default.Equals(this.Line, other.Line); - } - - /// - /// - /// - public override int GetHashCode() => - this.Column.GetHashCode() ^ this.Line.GetHashCode(); - - /// - /// Tests whether two specified instances are equivalent - /// - /// The instance that is to the left of the equality operator - /// The instance that is to the right of the equality operator - /// true if left and right are equal; otherwise, false - public static bool operator ==(GraphQLLocation? left, GraphQLLocation? right) => - EqualityComparer.Default.Equals(left, right); - - /// - /// Tests whether two specified instances are not equal - /// - /// The instance that is to the left of the not equal operator - /// The instance that is to the right of the not equal operator - /// true if left and right are unequal; otherwise, false - public static bool operator !=(GraphQLLocation? left, GraphQLLocation? right) => - !EqualityComparer.Default.Equals(left, right); - - } - +namespace GraphQL; + +/// +/// Represents a GraphQL Location of a GraphQL Query +/// +public sealed class GraphQLLocation : IEquatable +{ + /// + /// The Column + /// + public uint Column { get; set; } + + /// + /// The Line + /// + public uint Line { get; set; } + + /// + /// Returns a value that indicates whether this instance is equal to a specified object + /// + /// The object to compare with this instance + /// true if obj is an instance of and equals the value of the instance; otherwise, false + public override bool Equals(object obj) => Equals(obj as GraphQLLocation); + + /// + /// Returns a value that indicates whether this instance is equal to a specified object + /// + /// The object to compare with this instance + /// true if obj is an instance of and equals the value of the instance; otherwise, false + public bool Equals(GraphQLLocation? other) + { + if (other == null) + { return false; } + if (ReferenceEquals(this, other)) + { return true; } + return EqualityComparer.Default.Equals(Column, other.Column) && + EqualityComparer.Default.Equals(Line, other.Line); + } + + /// + /// + /// + public override int GetHashCode() => + Column.GetHashCode() ^ Line.GetHashCode(); + + /// + /// Tests whether two specified instances are equivalent + /// + /// The instance that is to the left of the equality operator + /// The instance that is to the right of the equality operator + /// true if left and right are equal; otherwise, false + public static bool operator ==(GraphQLLocation? left, GraphQLLocation? right) => + EqualityComparer.Default.Equals(left, right); + + /// + /// Tests whether two specified instances are not equal + /// + /// The instance that is to the left of the not equal operator + /// The instance that is to the right of the not equal operator + /// true if left and right are unequal; otherwise, false + public static bool operator !=(GraphQLLocation? left, GraphQLLocation? right) => + !EqualityComparer.Default.Equals(left, right); } diff --git a/src/GraphQL.Primitives/GraphQLQuery.cs b/src/GraphQL.Primitives/GraphQLQuery.cs new file mode 100644 index 00000000..df6eded8 --- /dev/null +++ b/src/GraphQL.Primitives/GraphQLQuery.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +namespace GraphQL; + +/// +/// Value object representing a GraphQL query string and storing the corresponding APQ hash.
+/// Use this to hold query strings you want to use more than once. +///
+public class GraphQLQuery : IEquatable +{ + /// + /// The actual query string + /// + public string Text { get; } + + /// + /// The SHA256 hash used for the automatic persisted queries feature (APQ) + /// + public string Sha256Hash { get; } + + public GraphQLQuery([StringSyntax("GraphQL")] string text) + { + Text = text; + Sha256Hash = Hash.Compute(Text); + } + + public static implicit operator string(GraphQLQuery query) + => query.Text; + + public bool Equals(GraphQLQuery other) => Sha256Hash == other.Sha256Hash; + + public override bool Equals(object? obj) => obj is GraphQLQuery other && Equals(other); + + public override int GetHashCode() => Sha256Hash.GetHashCode(); +} diff --git a/src/GraphQL.Primitives/GraphQLRequest.cs b/src/GraphQL.Primitives/GraphQLRequest.cs index ea0773e5..2d3e13ce 100644 --- a/src/GraphQL.Primitives/GraphQLRequest.cs +++ b/src/GraphQL.Primitives/GraphQLRequest.cs @@ -1,125 +1,143 @@ -using System; -using System.Collections.Generic; - -namespace GraphQL { - - /// - /// A GraphQL request - /// - public class GraphQLRequest : IEquatable { - - /// - /// The Query - /// - public string Query { get; set; } - - /// - /// The name of the Operation - /// - public string? OperationName { get; set; } - - /// - /// Returns a value that indicates whether this instance is equal to a specified object - /// - /// The object to compare with this instance - /// true if obj is an instance of and equals the value of the instance; otherwise, false - public override bool Equals(object? obj) => this.Equals(obj as GraphQLRequest); - - /// - /// Returns a value that indicates whether this instance is equal to a specified object - /// - /// The object to compare with this instance - /// true if obj is an instance of and equals the value of the instance; otherwise, false - public bool Equals(GraphQLRequest? other) { - if (other == null) { return false; } - if (ReferenceEquals(this, other)) { return true; } - if (!EqualityComparer.Default.Equals(this.Query, other.Query)) { return false; } - if (!EqualityComparer.Default.Equals(this.OperationName, other.OperationName)) { return false; } - return true; - } - - /// - /// - /// - public override int GetHashCode() { - unchecked { - var hashCode = EqualityComparer.Default.GetHashCode(this.Query); - hashCode = (hashCode * 397) ^ EqualityComparer.Default.GetHashCode(this.OperationName); - return hashCode; - } - } - - /// - /// Tests whether two specified instances are equivalent - /// - /// The instance that is to the left of the equality operator - /// The instance that is to the right of the equality operator - /// true if left and right are equal; otherwise, false - public static bool operator ==(GraphQLRequest? left, GraphQLRequest? right) => EqualityComparer.Default.Equals(left, right); - - /// - /// Tests whether two specified instances are not equal - /// - /// The instance that is to the left of the not equal operator - /// The instance that is to the right of the not equal operator - /// true if left and right are unequal; otherwise, false - public static bool operator !=(GraphQLRequest? left, GraphQLRequest? right) => !(left == right); - - } - - public class GraphQLRequest : GraphQLRequest, IEquatable?> { - - /// - /// Represents the variables sended - /// - public T Variables { get; set; } - - /// - /// Returns a value that indicates whether this instance is equal to a specified object - /// - /// The object to compare with this instance - /// true if obj is an instance of and equals the value of the instance; otherwise, false - public override bool Equals(object? obj) => this.Equals(obj as GraphQLRequest); - - /// - /// Returns a value that indicates whether this instance is equal to a specified object - /// - /// The object to compare with this instance - /// true if obj is an instance of and equals the value of the instance; otherwise, false - public bool Equals(GraphQLRequest? other) { - if (other == null) { return false; } - if (ReferenceEquals(this, other)) { return true; } - if (!EqualityComparer.Default.Equals(this.Query, other.Query)) { return false; } - if (!EqualityComparer.Default.Equals(this.OperationName, other.OperationName)) { return false; } - if (!EqualityComparer.Default.Equals(this.Variables, other.Variables)) { return false; } - return true; - } - - /// - /// - /// - public override int GetHashCode() { - unchecked { - return base.GetHashCode() * 397 ^ EqualityComparer.Default.GetHashCode(this.Variables); - } - } - - /// - /// Tests whether two specified instances are equivalent - /// - /// The instance that is to the left of the equality operator - /// The instance that is to the right of the equality operator - /// true if left and right are equal; otherwise, false - public static bool operator ==(GraphQLRequest? left, GraphQLRequest? right) => EqualityComparer?>.Default.Equals(left, right); - - /// - /// Tests whether two specified instances are not equal - /// - /// The instance that is to the left of the not equal operator - /// The instance that is to the right of the not equal operator - /// true if left and right are unequal; otherwise, false - public static bool operator !=(GraphQLRequest? left, GraphQLRequest? right) => !(left == right); - - } - +using System.Diagnostics.CodeAnalysis; + +namespace GraphQL; + +/// +/// A GraphQL request +/// +public class GraphQLRequest : Dictionary, IEquatable +{ + public const string OPERATION_NAME_KEY = "operationName"; + public const string QUERY_KEY = "query"; + public const string VARIABLES_KEY = "variables"; + public const string EXTENSIONS_KEY = "extensions"; + public const string EXTENSIONS_PERSISTED_QUERY_KEY = "persistedQuery"; + public const int APQ_SUPPORTED_VERSION = 1; + + private string? _sha265Hash; + + /// + /// The query string + /// + [StringSyntax("GraphQL")] + public string? Query + { + get => TryGetValue(QUERY_KEY, out object value) ? (string)value : null; + set + { + this[QUERY_KEY] = value; + // if the query string gets overwritten, reset the hash value + _sha265Hash = null; + } + } + + /// + /// The operation to execute + /// + public string? OperationName + { + get => TryGetValue(OPERATION_NAME_KEY, out object value) ? (string)value : null; + set => this[OPERATION_NAME_KEY] = value; + } + + /// + /// Represents the request variables + /// + public object? Variables + { + get => TryGetValue(VARIABLES_KEY, out object value) ? value : null; + set => this[VARIABLES_KEY] = value; + } + + /// + /// Represents the request extensions + /// + public Dictionary? Extensions + { + get => TryGetValue(EXTENSIONS_KEY, out object value) && value is Dictionary d ? d : null; + set => this[EXTENSIONS_KEY] = value; + } + + public GraphQLRequest() { } + + public GraphQLRequest([StringSyntax("GraphQL")] string query, object? variables = null, string? operationName = null, Dictionary? extensions = null) + { + Query = query; + Variables = variables; + OperationName = operationName; + Extensions = extensions; + } + + public GraphQLRequest(GraphQLQuery query, object? variables = null, string? operationName = null, + Dictionary? extensions = null) + : this(query.Text, variables, operationName, extensions) + { + _sha265Hash = query.Sha256Hash; + } + + public GraphQLRequest(GraphQLRequest other) : base(other) { } + + public void GeneratePersistedQueryExtension() + { + if (Query is null) + throw new InvalidOperationException($"{nameof(Query)} is null"); + + Extensions ??= new(); + Extensions[EXTENSIONS_PERSISTED_QUERY_KEY] = new Dictionary + { + ["version"] = APQ_SUPPORTED_VERSION, + ["sha256Hash"] = _sha265Hash ??= Hash.Compute(Query), + }; + } + + /// + /// Returns a value that indicates whether this instance is equal to a specified object + /// + /// The object to compare with this instance + /// true if obj is an instance of and equals the value of the instance; otherwise, false + public override bool Equals(object? obj) + { + if (obj is null) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (obj.GetType() != GetType()) + return false; + return Equals((GraphQLRequest)obj); + } + + /// + /// Returns a value that indicates whether this instance is equal to a specified object + /// + /// The object to compare with this instance + /// true if obj is an instance of and equals the value of the instance; otherwise, false + public virtual bool Equals(GraphQLRequest? other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + return Count == other.Count && !this.Except(other).Any(); + } + + /// + /// + /// + public override int GetHashCode() => (Query, OperationName, Variables, Extensions).GetHashCode(); + + /// + /// Tests whether two specified instances are equivalent + /// + /// The instance that is to the left of the equality operator + /// The instance that is to the right of the equality operator + /// true if left and right are equal; otherwise, false + public static bool operator ==(GraphQLRequest? left, GraphQLRequest? right) => EqualityComparer.Default.Equals(left, right); + + /// + /// Tests whether two specified instances are not equal + /// + /// The instance that is to the left of the not equal operator + /// The instance that is to the right of the not equal operator + /// true if left and right are unequal; otherwise, false + public static bool operator !=(GraphQLRequest? left, GraphQLRequest? right) => !(left == right); } diff --git a/src/GraphQL.Primitives/GraphQLResponse.cs b/src/GraphQL.Primitives/GraphQLResponse.cs new file mode 100644 index 00000000..adcd6457 --- /dev/null +++ b/src/GraphQL.Primitives/GraphQLResponse.cs @@ -0,0 +1,88 @@ +using System.Runtime.Serialization; + +namespace GraphQL; + +public class GraphQLResponse : IGraphQLResponse, IEquatable?> +{ + [DataMember(Name = "data")] + public T Data { get; set; } + object IGraphQLResponse.Data => Data; + + [DataMember(Name = "errors")] + public GraphQLError[]? Errors { get; set; } + + [DataMember(Name = "extensions")] + public Map? Extensions { get; set; } + + public override bool Equals(object? obj) => Equals(obj as GraphQLResponse); + + public bool Equals(GraphQLResponse? other) + { + if (other == null) + { return false; } + if (ReferenceEquals(this, other)) + { return true; } + if (!EqualityComparer.Default.Equals(Data, other.Data)) + { return false; } + + if (Errors != null && other.Errors != null) + { + if (!Enumerable.SequenceEqual(Errors, other.Errors)) + { return false; } + } + else if (Errors != null && other.Errors == null) + { return false; } + else if (Errors == null && other.Errors != null) + { return false; } + + if (Extensions != null && other.Extensions != null) + { + if (!Enumerable.SequenceEqual(Extensions, other.Extensions)) + { return false; } + } + else if (Extensions != null && other.Extensions == null) + { return false; } + else if (Extensions == null && other.Extensions != null) + { return false; } + + return true; + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = EqualityComparer.Default.GetHashCode(Data); + { + if (Errors != null) + { + foreach (var element in Errors) + { + hashCode = (hashCode * 397) ^ EqualityComparer.Default.GetHashCode(element); + } + } + else + { + hashCode = (hashCode * 397) ^ 0; + } + + if (Extensions != null) + { + foreach (var element in Extensions) + { + hashCode = (hashCode * 397) ^ EqualityComparer>.Default.GetHashCode(element); + } + } + else + { + hashCode = (hashCode * 397) ^ 0; + } + } + return hashCode; + } + } + + public static bool operator ==(GraphQLResponse? response1, GraphQLResponse? response2) => EqualityComparer?>.Default.Equals(response1, response2); + + public static bool operator !=(GraphQLResponse? response1, GraphQLResponse? response2) => !(response1 == response2); +} diff --git a/src/GraphQL.Primitives/Hash.cs b/src/GraphQL.Primitives/Hash.cs new file mode 100644 index 00000000..e360dd65 --- /dev/null +++ b/src/GraphQL.Primitives/Hash.cs @@ -0,0 +1,44 @@ +using System.Buffers; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace GraphQL; + +internal static class Hash +{ + private static SHA256? _sha256; + + internal static string Compute(string query) + { + int expected = Encoding.UTF8.GetByteCount(query); + byte[]? inputBytes = ArrayPool.Shared.Rent(expected); + int written = Encoding.UTF8.GetBytes(query, 0, query.Length, inputBytes, 0); + Debug.Assert(written == expected, (string)$"Encoding.UTF8.GetBytes returned unexpected bytes: {written} instead of {expected}"); + + var shaShared = Interlocked.Exchange(ref _sha256, null) ?? SHA256.Create(); + +#if NET5_0_OR_GREATER + Span bytes = stackalloc byte[32]; + if (!shaShared.TryComputeHash(inputBytes.AsSpan().Slice(0, written), bytes, out int bytesWritten)) // bytesWritten ignored since it is always 32 + throw new InvalidOperationException("Too small buffer for hash"); +#else + byte[] bytes = shaShared.ComputeHash(inputBytes, 0, written); +#endif + + ArrayPool.Shared.Return(inputBytes); + Interlocked.CompareExchange(ref _sha256, shaShared, null); + +#if NET5_0_OR_GREATER + return Convert.ToHexString(bytes); +#else + var builder = new StringBuilder(bytes.Length * 2); + foreach (byte item in bytes) + { + builder.Append(item.ToString("x2")); + } + + return builder.ToString(); +#endif + } +} diff --git a/src/GraphQL.Primitives/IGraphQLResponse.cs b/src/GraphQL.Primitives/IGraphQLResponse.cs new file mode 100644 index 00000000..4d4c2f93 --- /dev/null +++ b/src/GraphQL.Primitives/IGraphQLResponse.cs @@ -0,0 +1,10 @@ +namespace GraphQL; + +public interface IGraphQLResponse +{ + object Data { get; } + + GraphQLError[]? Errors { get; set; } + + Map? Extensions { get; set; } +} diff --git a/src/GraphQL.Primitives/Map.cs b/src/GraphQL.Primitives/Map.cs new file mode 100644 index 00000000..7c5823f1 --- /dev/null +++ b/src/GraphQL.Primitives/Map.cs @@ -0,0 +1,6 @@ +namespace GraphQL; + +/// +/// A type equivalent to a javascript map. Create a custom json converter for this class to customize your serializers behaviour +/// +public class Map : Dictionary { } diff --git a/src/GraphQL.Primitives/StringSyntaxAttribute.cs b/src/GraphQL.Primitives/StringSyntaxAttribute.cs new file mode 100644 index 00000000..8be2e889 --- /dev/null +++ b/src/GraphQL.Primitives/StringSyntaxAttribute.cs @@ -0,0 +1,72 @@ +#if !NET7_0_OR_GREATER + +// ReSharper disable InconsistentNaming +// ReSharper disable once CheckNamespace +namespace System.Diagnostics.CodeAnalysis; + +/// Stub version of the StringSyntaxAttribute, which was introduced in .NET 7 +public sealed class StringSyntaxAttribute : Attribute +{ + /// Initializes the with the identifier of the syntax used. + /// The syntax identifier. + public StringSyntaxAttribute(string syntax) + { + Syntax = syntax; + Arguments = Array.Empty(); + } + + /// Initializes the with the identifier of the syntax used. + /// The syntax identifier. + /// Optional arguments associated with the specific syntax employed. + public StringSyntaxAttribute(string syntax, params object?[] arguments) + { + Syntax = syntax; + Arguments = arguments; + } + + /// Gets the identifier of the syntax used. + public string Syntax { get; } + + /// Optional arguments associated with the specific syntax employed. + public object?[] Arguments { get; } + + /// The syntax identifier for strings containing composite formats for string formatting. +#pragma warning disable IDE1006 + public const string CompositeFormat = nameof(CompositeFormat); + + /// The syntax identifier for strings containing date format specifiers. + public const string DateOnlyFormat = nameof(DateOnlyFormat); + + /// The syntax identifier for strings containing date and time format specifiers. + public const string DateTimeFormat = nameof(DateTimeFormat); + + /// The syntax identifier for strings containing format specifiers. + public const string EnumFormat = nameof(EnumFormat); + + /// The syntax identifier for strings containing format specifiers. + public const string GuidFormat = nameof(GuidFormat); + + /// The syntax identifier for strings containing JavaScript Object Notation (JSON). + public const string Json = nameof(Json); + + /// The syntax identifier for strings containing numeric format specifiers. + public const string NumericFormat = nameof(NumericFormat); + + /// The syntax identifier for strings containing regular expressions. + public const string Regex = nameof(Regex); + + /// The syntax identifier for strings containing time format specifiers. + public const string TimeOnlyFormat = nameof(TimeOnlyFormat); + + /// The syntax identifier for strings containing format specifiers. + public const string TimeSpanFormat = nameof(TimeSpanFormat); + + /// The syntax identifier for strings containing URIs. + public const string Uri = nameof(Uri); + + /// The syntax identifier for strings containing XML. + public const string Xml = nameof(Xml); +#pragma warning restore IDE1006 +} + +#endif diff --git a/src/src.props b/src/src.props deleted file mode 100644 index d9a5800d..00000000 --- a/src/src.props +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - true - - - diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 00000000..d1655ff8 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,2 @@ +# Configure await +configure_await_analysis_mode = disabled diff --git a/tests/GraphQL.Client.Http.Tests/BaseTest.cs b/tests/GraphQL.Client.Http.Tests/BaseTest.cs deleted file mode 100644 index ba47cdea..00000000 --- a/tests/GraphQL.Client.Http.Tests/BaseTest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using Microsoft.AspNetCore.TestHost; - -namespace GraphQL.Client.Http.Tests { - - public abstract class BaseTest : IDisposable { - - private readonly TestServer testServer = new TestServer(Server.Test.Program.CreateHostBuilder()); - - public void Dispose() { - this.testServer.Dispose(); - } - - } - -} diff --git a/tests/GraphQL.Client.Http.Tests/GraphQL.Client.Http.Tests.csproj b/tests/GraphQL.Client.Http.Tests/GraphQL.Client.Http.Tests.csproj deleted file mode 100644 index 3114fa2d..00000000 --- a/tests/GraphQL.Client.Http.Tests/GraphQL.Client.Http.Tests.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - netcoreapp3.1 - - - - - - - - - - - diff --git a/tests/GraphQL.Client.Serializer.Tests/BaseSerializeNoCamelCaseTest.cs b/tests/GraphQL.Client.Serializer.Tests/BaseSerializeNoCamelCaseTest.cs new file mode 100644 index 00000000..14d77d16 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/BaseSerializeNoCamelCaseTest.cs @@ -0,0 +1,70 @@ +using System.Text; +using FluentAssertions; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.LocalExecution; +using GraphQL.Client.Serializer.Tests.TestData; +using GraphQL.Client.Tests.Common; +using GraphQL.Client.Tests.Common.Helpers; +using Xunit; + +namespace GraphQL.Client.Serializer.Tests; + +public abstract class BaseSerializeNoCamelCaseTest +{ + public IGraphQLWebsocketJsonSerializer ClientSerializer { get; } + + public IGraphQLTextSerializer ServerSerializer { get; } + + public IGraphQLClient ChatClient { get; } + + public IGraphQLClient StarWarsClient { get; } + + protected BaseSerializeNoCamelCaseTest(IGraphQLWebsocketJsonSerializer clientSerializer, IGraphQLTextSerializer serverSerializer) + { + ClientSerializer = clientSerializer; + ServerSerializer = serverSerializer; + ChatClient = GraphQLLocalExecutionClient.New(Common.GetChatSchema(), clientSerializer, serverSerializer); + StarWarsClient = GraphQLLocalExecutionClient.New(Common.GetStarWarsSchema(), clientSerializer, serverSerializer); + } + + [Theory] + [ClassData(typeof(SerializeToStringTestData))] + public void SerializeToStringTest(string expectedJson, GraphQLRequest request) + { + var json = ClientSerializer.SerializeToString(request).RemoveWhitespace(); + json.Should().Be(expectedJson.RemoveWhitespace()); + } + + [Theory] + [ClassData(typeof(SerializeToBytesTestData))] + public void SerializeToBytesTest(string expectedJson, GraphQLWebSocketRequest request) + { + var json = Encoding.UTF8.GetString(ClientSerializer.SerializeToBytes(request)).RemoveWhitespace(); + json.Should().Be(expectedJson.RemoveWhitespace()); + } + + [Fact] + public async void WorksWithoutCamelCaseNamingStrategy() + { + const string message = "some random testing message"; + var graphQLRequest = new GraphQLRequest( + @"mutation($input: MessageInputType){ + addMessage(message: $input){ + content + } + }", + new + { + input = new + { + fromId = "2", + content = message, + sentAt = DateTime.Now + } + }); + var response = await ChatClient.SendMutationAsync(graphQLRequest, () => new { addMessage = new { content = "" } }); + + Assert.Equal(message, response.Data.addMessage.content); + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/BaseSerializerTest.cs b/tests/GraphQL.Client.Serializer.Tests/BaseSerializerTest.cs new file mode 100644 index 00000000..7cf5c3e1 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/BaseSerializerTest.cs @@ -0,0 +1,219 @@ +using System.Reflection; +using System.Text; +using FluentAssertions; +using FluentAssertions.Execution; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.LocalExecution; +using GraphQL.Client.Serializer.Tests.TestData; +using GraphQL.Client.Tests.Common; +using GraphQL.Client.Tests.Common.Chat; +using GraphQL.Client.Tests.Common.Chat.Schema; +using GraphQL.Client.Tests.Common.Helpers; +using GraphQL.Client.Tests.Common.StarWars.TestData; +using Xunit; + +namespace GraphQL.Client.Serializer.Tests; + +public abstract class BaseSerializerTest +{ + public IGraphQLWebsocketJsonSerializer ClientSerializer { get; } + + public IGraphQLTextSerializer ServerSerializer { get; } + + public IGraphQLClient ChatClient { get; } + + public IGraphQLClient StarWarsClient { get; } + + protected BaseSerializerTest(IGraphQLWebsocketJsonSerializer clientSerializer, IGraphQLTextSerializer serverSerializer) + { + ClientSerializer = clientSerializer; + ServerSerializer = serverSerializer; + ChatClient = GraphQLLocalExecutionClient.New(Common.GetChatSchema(), clientSerializer, serverSerializer); + StarWarsClient = GraphQLLocalExecutionClient.New(Common.GetStarWarsSchema(), clientSerializer, serverSerializer); + } + + [Theory] + [ClassData(typeof(SerializeToStringTestData))] + public void SerializeToStringTest(string expectedJson, GraphQLRequest request) + { + var json = ClientSerializer.SerializeToString(request).RemoveWhitespace(); + json.Should().BeEquivalentTo(expectedJson.RemoveWhitespace()); + } + + [Theory] + [ClassData(typeof(SerializeToBytesTestData))] + public void SerializeToBytesTest(string expectedJson, GraphQLWebSocketRequest request) + { + var json = Encoding.UTF8.GetString(ClientSerializer.SerializeToBytes(request)).RemoveWhitespace(); + json.Should().BeEquivalentTo(expectedJson.RemoveWhitespace()); + } + + [Theory] + [ClassData(typeof(DeserializeResponseTestData))] + public async void DeserializeFromUtf8StreamTest(string json, IGraphQLResponse expectedResponse) + { + var jsonBytes = Encoding.UTF8.GetBytes(json); + await using var ms = new MemoryStream(jsonBytes); + var response = await DeserializeToUnknownType(expectedResponse.Data?.GetType() ?? typeof(object), ms); + + //var response = await Serializer.DeserializeFromUtf8StreamAsync(ms, CancellationToken.None); + + response.Data.Should().BeEquivalentTo(expectedResponse.Data, options => options.WithAutoConversion()); + + if (expectedResponse.Errors is null) + response.Errors.Should().BeNull(); + else + { + using (new AssertionScope()) + { + response.Errors.Should().NotBeNull(); + response.Errors.Should().HaveSameCount(expectedResponse.Errors); + for (int i = 0; i < expectedResponse.Errors.Length; i++) + { + response.Errors[i].Message.Should().BeEquivalentTo(expectedResponse.Errors[i].Message); + response.Errors[i].Locations.Should().BeEquivalentTo(expectedResponse.Errors[i].Locations?.ToList()); + response.Errors[i].Path.Should().BeEquivalentTo(expectedResponse.Errors[i].Path); + response.Errors[i].Extensions.Should().BeEquivalentTo(expectedResponse.Errors[i].Extensions); + } + } + } + + if (expectedResponse.Extensions == null) + response.Extensions.Should().BeNull(); + else + { + foreach (var element in expectedResponse.Extensions) + { + response.Extensions.Should().ContainKey(element.Key); + response.Extensions[element.Key].Should().BeEquivalentTo(element.Value); + } + } + } + + public async Task DeserializeToUnknownType(Type dataType, Stream stream) + { + MethodInfo mi = ClientSerializer.GetType().GetMethod("DeserializeFromUtf8StreamAsync", BindingFlags.Instance | BindingFlags.Public); + MethodInfo mi2 = mi.MakeGenericMethod(dataType); + var task = (Task)mi2.Invoke(ClientSerializer, new object[] { stream, CancellationToken.None }); + await task; + var resultProperty = task.GetType().GetProperty("Result", BindingFlags.Public | BindingFlags.Instance); + var result = resultProperty.GetValue(task); + return (IGraphQLResponse)result; + } + + [Fact] + public async void CanDeserializeExtensions() + { + var response = await ChatClient.SendQueryAsync( + new GraphQLRequest("query { extensionsTest }"), + () => new { extensionsTest = "" }); + + response.Errors.Should().NotBeNull(); + response.Errors.Should().ContainSingle(); + response.Errors[0].Extensions.Should().NotBeNull(); + response.Errors[0].Extensions.Should().ContainKey("data"); + + response.Errors[0].Extensions["data"].Should().BeEquivalentTo(ChatQuery.TestExtensions); + } + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void CanDoSerializationWithAnonymousTypes(int id, string name) + { + var graphQLRequest = new GraphQLRequest(@" + query Human($id: String!){ + human(id: $id) { + name + } + } + + query Droid($id: String!) { + droid(id: $id) { + name + } + }", + new { id = id.ToString() }, + "Human"); + + var response = await StarWarsClient.SendQueryAsync(graphQLRequest, () => new { Human = new { Name = string.Empty } }); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.Human.Name); + } + + [Fact] + public async void CanDoSerializationWithPredefinedTypes() + { + const string message = "some random testing message"; + var response = await ChatClient.AddMessageAsync(message); + + Assert.Equal(message, response.Data.AddMessage.Content); + } + + public class WithNullable + { + public int? NullableInt { get; set; } + } + + [Fact] + public void CanSerializeNullableInt() + { + Action action = () => ClientSerializer.SerializeToString(new GraphQLRequest + { + Query = "{}", + Variables = new WithNullable + { + NullableInt = 2 + } + }); + + action.Should().NotThrow(); + } + + public class WithNullableStruct + { + public DateTime? NullableStruct { get; set; } + } + + [Fact] + public void CanSerializeNullableStruct() + { + Action action = () => ClientSerializer.SerializeToString(new GraphQLRequest + { + Query = "{}", + Variables = new WithNullableStruct + { + NullableStruct = DateTime.Now + } + }); + + action.Should().NotThrow(); + } + + [Fact] + public async Task CanDeserializeObjectWithBothConstructorAndProperties() + { + // Arrange + const string jsonString = @"{ ""data"": { ""property1"": ""value1"", ""property2"": ""value2"" } }"; + var contentStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(jsonString)); + + // Act + var result = await ClientSerializer + .DeserializeFromUtf8StreamAsync(contentStream, default); + + // Assert + result.Data.Property1.Should().Be("value1"); + result.Data.Property2.Should().Be("value2"); + } + + private class WithConstructorAndProperties + { + public WithConstructorAndProperties(string property2) + { + Property2 = property2; + } + public string? Property1 { get; set; } + public string Property2 { get; } + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/ConsistencyTests.cs b/tests/GraphQL.Client.Serializer.Tests/ConsistencyTests.cs new file mode 100644 index 00000000..7a3f8f55 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/ConsistencyTests.cs @@ -0,0 +1,94 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using GraphQL.Client.Serializer.Newtonsoft; +using GraphQL.Client.Serializer.SystemTextJson; +using Newtonsoft.Json; +using Xunit; + +namespace GraphQL.Client.Serializer.Tests; + +public class ConsistencyTests +{ + [Theory] + [InlineData(@"{ + ""array"": [ + ""some stuff"", + ""something else"" + ], + ""string"": ""this is a string"", + ""boolean"": true, + ""number"": 1234.567, + ""nested object"": { + ""prop1"": false + }, + ""arrayOfObjects"": [ + {""number"": 1234.567}, + {""number"": 567.8} + ] + }")] + [InlineData("null")] + public void MapConvertersShouldBehaveConsistent(string json) + { + //const string json = @"{ + // ""array"": [ + // ""some stuff"", + // ""something else"" + // ], + // ""string"": ""this is a string"", + // ""boolean"": true, + // ""number"": 1234.567, + // ""nested object"": { + // ""prop1"": false + // }, + // ""arrayOfObjects"": [ + // {""number"": 1234.567}, + // {""number"": 567.8} + // ] + // }"; + + var newtonsoftSerializer = new NewtonsoftJsonSerializer(); + var systemTextJsonSerializer = new SystemTextJsonSerializer(); + + var newtonsoftMap = JsonConvert.DeserializeObject(json, newtonsoftSerializer.JsonSerializerSettings); + var systemTextJsonMap = System.Text.Json.JsonSerializer.Deserialize(json, systemTextJsonSerializer.Options); + + + using (new AssertionScope()) + { + CompareMaps(newtonsoftMap, systemTextJsonMap); + } + + newtonsoftMap.Should().BeEquivalentTo(systemTextJsonMap, options => options + .RespectingRuntimeTypes()); + } + + /// + /// Regression test for https://github.com/graphql-dotnet/graphql-client/issues/601 + /// + [Fact] + public void MapConvertersShouldBeAbleToDeserializeNullValues() + { + var newtonsoftSerializer = new NewtonsoftJsonSerializer(); + var systemTextJsonSerializer = new SystemTextJsonSerializer(); + string json = "null"; + + JsonConvert.DeserializeObject(json, newtonsoftSerializer.JsonSerializerSettings).Should().BeNull(); + System.Text.Json.JsonSerializer.Deserialize(json, systemTextJsonSerializer.Options).Should().BeNull(); + } + + private void CompareMaps(Dictionary? first, Dictionary? second) + { + if (first is null) + second.Should().BeNull(); + else + foreach (var keyValuePair in first) + { + second.Should().ContainKey(keyValuePair.Key); + second[keyValuePair.Key].Should().BeOfType(keyValuePair.Value.GetType()); + if (keyValuePair.Value is Dictionary map) + CompareMaps(map, (Dictionary)second[keyValuePair.Key]); + else + keyValuePair.Value.Should().BeEquivalentTo(second[keyValuePair.Key]); + } + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/DefaultValidationTest.cs b/tests/GraphQL.Client.Serializer.Tests/DefaultValidationTest.cs new file mode 100644 index 00000000..861b59b6 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/DefaultValidationTest.cs @@ -0,0 +1,28 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using GraphQL.Client.Http; +using Xunit; + +namespace GraphQL.Client.Serializer.Tests; + +public class DefaultValidationTest +{ + [Theory] + [InlineData(HttpStatusCode.OK, "application/json", true)] + [InlineData(HttpStatusCode.OK, "application/graphql-response+json", true)] + [InlineData(HttpStatusCode.BadRequest, "application/json", true)] + [InlineData(HttpStatusCode.BadRequest, "text/html", false)] + [InlineData(HttpStatusCode.OK, "text/html", false)] + [InlineData(HttpStatusCode.Forbidden, "text/html", false)] + [InlineData(HttpStatusCode.Forbidden, "application/json", false)] + public void IsValidResponse_OkJson_True(HttpStatusCode statusCode, string mediaType, bool expectedResult) + { + var response = new HttpResponseMessage(statusCode); + response.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType); + + bool isValid = new GraphQLHttpClientOptions().IsValidResponseToDeserialize(response); + + isValid.Should().Be(expectedResult); + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/GraphQL.Client.Serializer.Tests.csproj b/tests/GraphQL.Client.Serializer.Tests/GraphQL.Client.Serializer.Tests.csproj new file mode 100644 index 00000000..b0efc377 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/GraphQL.Client.Serializer.Tests.csproj @@ -0,0 +1,35 @@ + + + + + + net8 + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/GraphQL.Client.Serializer.Tests/NewtonsoftSerializerTest.cs b/tests/GraphQL.Client.Serializer.Tests/NewtonsoftSerializerTest.cs new file mode 100644 index 00000000..a359e372 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/NewtonsoftSerializerTest.cs @@ -0,0 +1,25 @@ +using GraphQL.Client.Serializer.Newtonsoft; +using GraphQL.Execution; +using Newtonsoft.Json; + +namespace GraphQL.Client.Serializer.Tests; + +public class NewtonsoftSerializerTest : BaseSerializerTest +{ + public NewtonsoftSerializerTest() + : base( + new NewtonsoftJsonSerializer(), + new NewtonsoftJson.GraphQLSerializer(new ErrorInfoProvider(opt => opt.ExposeData = true))) + { + } +} + +public class NewtonsoftSerializeNoCamelCaseTest : BaseSerializeNoCamelCaseTest +{ + public NewtonsoftSerializeNoCamelCaseTest() + : base( + new NewtonsoftJsonSerializer(new JsonSerializerSettings { Converters = { new ConstantCaseEnumConverter() } }), + new NewtonsoftJson.GraphQLSerializer(new ErrorInfoProvider(opt => opt.ExposeData = true))) + { + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/SystemTextJsonSerializerTests.cs b/tests/GraphQL.Client.Serializer.Tests/SystemTextJsonSerializerTests.cs new file mode 100644 index 00000000..c94eed8a --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/SystemTextJsonSerializerTests.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using GraphQL.Client.Serializer.SystemTextJson; +using GraphQL.Execution; + +namespace GraphQL.Client.Serializer.Tests; + +public class SystemTextJsonSerializerTests : BaseSerializerTest +{ + public SystemTextJsonSerializerTests() + : base( + new SystemTextJsonSerializer(), + new GraphQL.SystemTextJson.GraphQLSerializer(new ErrorInfoProvider(opt => opt.ExposeData = true))) + { + } +} + +public class SystemTextJsonSerializeNoCamelCaseTest : BaseSerializeNoCamelCaseTest +{ + public SystemTextJsonSerializeNoCamelCaseTest() + : base( + new SystemTextJsonSerializer(new JsonSerializerOptions { Converters = { new JsonStringEnumConverter(new ConstantCaseJsonNamingPolicy(), false) } }.SetupImmutableConverter()), + new GraphQL.SystemTextJson.GraphQLSerializer(new ErrorInfoProvider(opt => opt.ExposeData = true))) + { + } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/TestData/DeserializeResponseTestData.cs b/tests/GraphQL.Client.Serializer.Tests/TestData/DeserializeResponseTestData.cs new file mode 100644 index 00000000..53388f1f --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/TestData/DeserializeResponseTestData.cs @@ -0,0 +1,140 @@ +using System.Collections; + +namespace GraphQL.Client.Serializer.Tests.TestData; + +public class DeserializeResponseTestData : IEnumerable +{ + public IEnumerator GetEnumerator() + { + // object array structure: + // [0]: input json + // [1]: expected deserialized response + + yield return new object[] { + "{\"errors\":[{\"message\":\"Throttled\",\"extensions\":{\"code\":\"THROTTLED\",\"documentation\":\"https://help.shopify.com/api/graphql-admin-api/graphql-admin-api-rate-limits\"}}],\"extensions\":{\"cost\":{\"requestedQueryCost\":992,\"actualQueryCost\":null,\"throttleStatus\":{\"maximumAvailable\":1000,\"currentlyAvailable\":632,\"restoreRate\":50}}}}", + new GraphQLResponse { + Data = null, + Errors = new[] { + new GraphQLError { + Message = "Throttled", + Extensions = new Map { + {"code", "THROTTLED" }, + {"documentation", "https://help.shopify.com/api/graphql-admin-api/graphql-admin-api-rate-limits" } + } + } + }, + Extensions = new Map { + {"cost", new Dictionary { + {"requestedQueryCost", 992}, + {"actualQueryCost", null}, + {"throttleStatus", new Dictionary { + {"maximumAvailable", 1000}, + {"currentlyAvailable", 632}, + {"restoreRate", 50} + }} + }} + } + } + }; + + yield return new object[] + { + @"{ + ""errors"": [ + { + ""message"": ""Name for character with ID 1002 could not be fetched."", + ""locations"": [ + { + ""line"": 6, + ""column"": 7 + } + ], + ""path"": [ + ""hero"", + ""heroFriends"", + 1, + ""name"" + ] + } + ], + ""data"": { + ""hero"": { + ""name"": ""R2-D2"", + ""heroFriends"": [ + { + ""id"": ""1000"", + ""name"": ""Luke Skywalker"" + }, + { + ""id"": ""1002"", + ""name"": null + }, + { + ""id"": ""1003"", + ""name"": ""Leia Organa"" + } + ] + } + } + }", + NewAnonymouslyTypedGraphQLResponse(new + { + hero = new + { + name = "R2-D2", + heroFriends = new List + { + new Friend {Id = "1000", Name = "Luke Skywalker"}, + new Friend {Id = "1002", Name = null}, + new Friend {Id = "1003", Name = "Leia Organa"} + } + } + }, + new[] { + new GraphQLError { + Message = "Name for character with ID 1002 could not be fetched.", + Locations = new [] { new GraphQLLocation{Line = 6, Column = 7 }}, + Path = new ErrorPath{"hero", "heroFriends", 1, "name"} + } + }) + }; + + // add test for github issue #230 : https://github.com/graphql-dotnet/graphql-client/issues/230 + yield return new object[] { + "{\"data\":{\"getMyModelType\":{\"id\":\"foo\",\"title\":\"The best Foo movie!\"}}}", + new GraphQLResponse { + Data = new GetMyModelTypeResponse + { + getMyModelType = new Movie + { + id = "foo", + title = "The best Foo movie!" + } + }, + } + }; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private GraphQLResponse NewAnonymouslyTypedGraphQLResponse(T data, GraphQLError[]? errors = null, Map? extensions = null) + => new GraphQLResponse { Data = data, Errors = errors, Extensions = extensions }; +} + +public class Friend +{ + public string Id { get; set; } + public string? Name { get; set; } +} + +public class GetMyModelTypeResponse +{ + //--- Properties --- + public Movie getMyModelType { get; set; } +} +public class Movie +{ + //--- Properties --- + public string id { get; set; } + public string title { get; set; } +} diff --git a/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToBytesTestData.cs b/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToBytesTestData.cs new file mode 100644 index 00000000..85725616 --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToBytesTestData.cs @@ -0,0 +1,30 @@ +using System.Collections; +using GraphQL.Client.Abstractions.Websocket; + +namespace GraphQL.Client.Serializer.Tests.TestData; + +public class SerializeToBytesTestData : IEnumerable +{ + public IEnumerator GetEnumerator() + { + yield return new object[] { + "{\"id\":\"1234567\",\"type\":\"start\",\"payload\":{\"query\":\"simplequerystring\",\"variables\":null,\"operationName\":null,\"extensions\":null}}", + new GraphQLWebSocketRequest { + Id = "1234567", + Type = GraphQLWebSocketMessageType.GQL_START, + Payload = new GraphQLRequest("simplequerystring") + } + }; + yield return new object[] { + "{\"id\":\"34476567\",\"type\":\"start\",\"payload\":{\"query\":\"simplequerystring\",\"variables\":{\"camelCaseProperty\":\"camelCase\",\"PascalCaseProperty\":\"PascalCase\"},\"operationName\":null,\"extensions\":null}}", + new GraphQLWebSocketRequest { + Id = "34476567", + Type = GraphQLWebSocketMessageType.GQL_START, + Payload = new GraphQLRequest("simple query string", new { camelCaseProperty = "camelCase", PascalCaseProperty = "PascalCase"}) + } + + }; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToStringTestData.cs b/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToStringTestData.cs new file mode 100644 index 00000000..85bc5e3b --- /dev/null +++ b/tests/GraphQL.Client.Serializer.Tests/TestData/SerializeToStringTestData.cs @@ -0,0 +1,42 @@ +using System.Collections; + +namespace GraphQL.Client.Serializer.Tests.TestData; + +public class SerializeToStringTestData : IEnumerable +{ + public IEnumerator GetEnumerator() + { + yield return new object[] { + "{\"query\":\"simplequerystring\",\"variables\":null,\"operationName\":null,\"extensions\":null}", + new GraphQLRequest("simple query string") + }; + yield return new object[] { + "{\"query\":\"simplequerystring\",\"variables\":null,\"operationName\":null,\"extensions\":{\"a\":\"abc\",\"b\":true,\"c\":{\"d\":42}}}", + new GraphQLRequest("simple query string", extensions: new Dictionary { ["a"] = "abc", ["b"] = true, ["c"] = new Dictionary { ["d"] = 42 } }) + }; + yield return new object[] { + "{\"query\":\"simplequerystring\",\"variables\":{\"camelCaseProperty\":\"camelCase\",\"PascalCaseProperty\":\"PascalCase\"},\"operationName\":null,\"extensions\":null}", + new GraphQLRequest("simple query string", new { camelCaseProperty = "camelCase", PascalCaseProperty = "PascalCase"}) + }; + yield return new object[] { + "{\"query\":\"simplequerystring\",\"variables\":null,\"operationName\":null,\"extensions\":null,\"authentication\":\"an-authentication-token\"}", + new GraphQLRequest("simple query string"){{"authentication", "an-authentication-token"}} + }; + yield return new object[] { + "{\"query\":\"enumtest\",\"variables\":{\"enums\":[\"REGULAR\",\"PASCAL_CASE\",\"CAMEL_CASE\",\"LOWER\",\"UPPER\",\"CONSTANT_CASE\"]},\"operationName\":null,\"extensions\":null}", + new GraphQLRequest("enumtest", new { enums = Enum.GetValues(typeof(TestEnum)).Cast()}) + }; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public enum TestEnum + { + Regular, + PascalCase, + camelCase, + lower, + UPPER, + CONSTANT_CASE + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/AddMessageMutationResult.cs b/tests/GraphQL.Client.Tests.Common/Chat/AddMessageMutationResult.cs new file mode 100644 index 00000000..b2c551ce --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/AddMessageMutationResult.cs @@ -0,0 +1,11 @@ +namespace GraphQL.Client.Tests.Common.Chat; + +public class AddMessageMutationResult +{ + public AddMessageContent AddMessage { get; set; } + + public class AddMessageContent + { + public string Content { get; set; } + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/AddMessageVariables.cs b/tests/GraphQL.Client.Tests.Common/Chat/AddMessageVariables.cs new file mode 100644 index 00000000..8f55c02c --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/AddMessageVariables.cs @@ -0,0 +1,15 @@ +namespace GraphQL.Client.Tests.Common.Chat; + +public class AddMessageVariables +{ + public AddMessageInput Input { get; set; } + + public class AddMessageInput + { + public string FromId { get; set; } + + public string Content { get; set; } + + public string SentAt { get; set; } + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/GraphQLClientChatExtensions.cs b/tests/GraphQL.Client.Tests.Common/Chat/GraphQLClientChatExtensions.cs new file mode 100644 index 00000000..fa10984a --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/GraphQLClientChatExtensions.cs @@ -0,0 +1,45 @@ +using GraphQL.Client.Abstractions; + +namespace GraphQL.Client.Tests.Common.Chat; + +public static class GraphQLClientChatExtensions +{ + public const string ADD_MESSAGE_QUERY = +@"mutation($input: MessageInputType){ + addMessage(message: $input){ + content + } +}"; + + public static Task> AddMessageAsync(this IGraphQLClient client, string message) + { + var variables = new AddMessageVariables + { + Input = new AddMessageVariables.AddMessageInput + { + FromId = "2", + Content = message, + SentAt = DateTime.Now.ToString("s") + } + }; + + var graphQLRequest = new GraphQLRequest(ADD_MESSAGE_QUERY, variables); + return client.SendMutationAsync(graphQLRequest); + } + + public static Task> JoinDeveloperUser(this IGraphQLClient client) + { + var graphQLRequest = new GraphQLRequest(@" + mutation($userId: String){ + join(userId: $userId){ + displayName + id + } + }", + new + { + userId = "1" + }); + return client.SendMutationAsync(graphQLRequest); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/JoinDeveloperMutationResult.cs b/tests/GraphQL.Client.Tests.Common/Chat/JoinDeveloperMutationResult.cs new file mode 100644 index 00000000..d55bf0ca --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/JoinDeveloperMutationResult.cs @@ -0,0 +1,13 @@ +namespace GraphQL.Client.Tests.Common.Chat; + +public class JoinDeveloperMutationResult +{ + public JoinContent Join { get; set; } + + public class JoinContent + { + public string DisplayName { get; set; } + + public string Id { get; set; } + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/CapitalizedFieldsGraphType.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/CapitalizedFieldsGraphType.cs new file mode 100644 index 00000000..c10a77b6 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/CapitalizedFieldsGraphType.cs @@ -0,0 +1,14 @@ +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class CapitalizedFieldsGraphType : ObjectGraphType +{ + public CapitalizedFieldsGraphType() + { + Name = "CapitalizedFields"; + + Field("StringField") + .Resolve(context => "hello world"); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatMutation.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatMutation.cs new file mode 100644 index 00000000..70a2a863 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatMutation.cs @@ -0,0 +1,37 @@ +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class ChatMutation : ObjectGraphType +{ + public ChatMutation(IChat chat) + { + Field("addMessage") + .Argument("message") + .Resolve(context => + { + var receivedMessage = context.GetArgument("message"); + var message = chat.AddMessage(receivedMessage); + return message; + }); + + Field("join") + .Argument("userId") + .Resolve(context => + { + var userId = context.GetArgument("userId"); + var userJoined = chat.Join(userId); + return userJoined; + }); + } +} + +public class MessageInputType : InputObjectGraphType +{ + public MessageInputType() + { + Field("fromId"); + Field("content"); + Field("sentAt"); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatQuery.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatQuery.cs new file mode 100644 index 00000000..5b506225 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatQuery.cs @@ -0,0 +1,58 @@ +using GraphQL.Types; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class ChatQuery : ObjectGraphType +{ + private readonly IServiceProvider _serviceProvider; + + public static readonly Dictionary TestExtensions = new() + { + {"extension1", "hello world"}, + {"another extension", 4711}, + {"long", 19942590700} + }; + + // properties for unit testing + + public readonly ManualResetEventSlim LongRunningQueryBlocker = new ManualResetEventSlim(); + public readonly ManualResetEventSlim WaitingOnQueryBlocker = new ManualResetEventSlim(); + + public ChatQuery(IChat chat, IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + Name = "ChatQuery"; + + Field>("messages").Resolve(context => chat.AllMessages.Take(100)); + + Field("extensionsTest") + .Resolve(context => + { + context.Errors.Add(new ExecutionError("this error contains extension fields", TestExtensions)); + return null; + }); + + Field("longRunning") + .Resolve(context => + { + WaitingOnQueryBlocker.Set(); + LongRunningQueryBlocker.Wait(); + WaitingOnQueryBlocker.Reset(); + return "finally returned"; + }); + + Field("clientUserAgent") + .Resolve(context => + { + var contextAccessor = _serviceProvider.GetRequiredService(); + if (!contextAccessor.HttpContext.Request.Headers.UserAgent.Any()) + { + context.Errors.Add(new ExecutionError("user agent header not set")); + return null; + } + return contextAccessor.HttpContext.Request.Headers.UserAgent.ToString(); + }); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSchema.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSchema.cs new file mode 100644 index 00000000..9cacc7d4 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSchema.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class ChatSchema : Types.Schema +{ + public ChatSchema(IServiceProvider services) + : base(services) + { + Query = services.GetRequiredService(); + Mutation = services.GetRequiredService(); + Subscription = services.GetRequiredService(); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSubscriptions.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSubscriptions.cs new file mode 100644 index 00000000..0653205d --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ChatSubscriptions.cs @@ -0,0 +1,90 @@ +using System.Reactive.Linq; +using GraphQL.Resolvers; +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class ChatSubscriptions : ObjectGraphType +{ + private readonly IChat _chat; + + public ChatSubscriptions(IChat chat) + { + _chat = chat; + AddField(new FieldType + { + Name = "messageAdded", + Type = typeof(MessageType), + Resolver = new FuncFieldResolver(ResolveMessage), + StreamResolver = new SourceStreamResolver(Subscribe) + }); + + AddField(new FieldType + { + Name = "contentAdded", + Type = typeof(MessageType), + Resolver = new FuncFieldResolver(ResolveMessage), + StreamResolver = new SourceStreamResolver(Subscribe) + }); + + AddField(new FieldType + { + Name = "messageAddedByUser", + Arguments = new QueryArguments( + new QueryArgument> { Name = "id" } + ), + Type = typeof(MessageType), + Resolver = new FuncFieldResolver(ResolveMessage), + StreamResolver = new SourceStreamResolver(SubscribeById) + }); + + AddField(new FieldType + { + Name = "userJoined", + Type = typeof(MessageFromType), + Resolver = new FuncFieldResolver(context => context.Source as MessageFrom), + StreamResolver = new SourceStreamResolver(context => _chat.UserJoined()) + }); + + + AddField(new FieldType + { + Name = "failImmediately", + Type = typeof(MessageType), + Resolver = new FuncFieldResolver(ResolveMessage), + StreamResolver = new SourceStreamResolver((Func>)(context => throw new NotSupportedException("this is supposed to fail"))) + }); + } + + private IObservable SubscribeById(IResolveFieldContext context) + { + var user = context.User; + + var sub = "Anonymous"; + if (user != null) + sub = user.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + + var messages = _chat.Messages(sub); + + var id = context.GetArgument("id"); + return messages.Where(message => message.From.Id == id); + } + + private Message ResolveMessage(IResolveFieldContext context) + { + var message = context.Source as Message; + + return message; + } + + private IObservable Subscribe(IResolveFieldContext context) + { + var user = context.User; + + var sub = "Anonymous"; + if (user != null) + sub = user.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + + return _chat.Messages(sub); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/IChat.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/IChat.cs new file mode 100644 index 00000000..a00a35dc --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/IChat.cs @@ -0,0 +1,110 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public interface IChat +{ + ConcurrentStack AllMessages { get; } + + Message AddMessage(Message message); + + MessageFrom Join(string userId); + + IObservable Messages(string user); + IObservable UserJoined(); + + Message AddMessage(ReceivedMessage message); +} + +public class Chat : IChat +{ + private readonly ISubject _messageStream = new ReplaySubject(1); + private readonly ISubject _userJoined = new Subject(); + + public Chat() + { + AllMessages = new ConcurrentStack(); + Users = new ConcurrentDictionary + { + ["1"] = "developer", + ["2"] = "tester" + }; + } + + public ConcurrentDictionary Users { get; private set; } + + public ConcurrentStack AllMessages { get; private set; } + + public Message AddMessage(ReceivedMessage message) + { + if (!Users.TryGetValue(message.FromId, out var displayName)) + { + displayName = "(unknown)"; + } + + return AddMessage(new Message + { + Content = message.Content, + SentAt = message.SentAt, + From = new MessageFrom + { + DisplayName = displayName, + Id = message.FromId + } + }); + } + + public Message AddMessage(Message message) + { + AllMessages.Push(message); + _messageStream.OnNext(message); + return message; + } + + public MessageFrom Join(string userId) + { + if (!Users.TryGetValue(userId, out var displayName)) + { + displayName = "(unknown)"; + } + + var joinedUser = new MessageFrom + { + Id = userId, + DisplayName = displayName + }; + + _userJoined.OnNext(joinedUser); + return joinedUser; + } + + public IObservable Messages(string user) => + Observable.Create(observer => + { + Debug.WriteLine($"creating messages stream for user '{user}' on thread {Thread.CurrentThread.ManagedThreadId}"); + return new CompositeDisposable + { + _messageStream.Select(message => + { + message.Sub = user; + return message; + }) + .Subscribe(observer), + Disposable.Create(() => Debug.WriteLine($"disposing messages stream for user '{user}' on thread {Thread.CurrentThread.ManagedThreadId}")) + }; + }); + + public void AddError(Exception exception) => _messageStream.OnError(exception); + + public IObservable UserJoined() => _userJoined.AsObservable(); +} + +public class User +{ + public string Id { get; set; } + public string Name { get; set; } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/Message.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/Message.cs new file mode 100644 index 00000000..aaf77218 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/Message.cs @@ -0,0 +1,12 @@ +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class Message +{ + public MessageFrom From { get; set; } + + public string Sub { get; set; } + + public string Content { get; set; } + + public DateTime SentAt { get; set; } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFrom.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFrom.cs new file mode 100644 index 00000000..637d5198 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFrom.cs @@ -0,0 +1,8 @@ +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class MessageFrom +{ + public string Id { get; set; } + + public string DisplayName { get; set; } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFromType.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFromType.cs new file mode 100644 index 00000000..45cb68d7 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageFromType.cs @@ -0,0 +1,12 @@ +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class MessageFromType : ObjectGraphType +{ + public MessageFromType() + { + Field(o => o.Id); + Field(o => o.DisplayName); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageType.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageType.cs new file mode 100644 index 00000000..a1c8c837 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/MessageType.cs @@ -0,0 +1,20 @@ +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class MessageType : ObjectGraphType +{ + public MessageType() + { + Field(o => o.Content); + Field(o => o.SentAt); + Field(o => o.Sub); + Field(o => o.From, false, typeof(MessageFromType)).Resolve(ResolveFrom); + } + + private MessageFrom ResolveFrom(IResolveFieldContext context) + { + var message = context.Source; + return message.From; + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Chat/Schema/ReceivedMessage.cs b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ReceivedMessage.cs new file mode 100644 index 00000000..78a675dc --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Chat/Schema/ReceivedMessage.cs @@ -0,0 +1,10 @@ +namespace GraphQL.Client.Tests.Common.Chat.Schema; + +public class ReceivedMessage +{ + public string FromId { get; set; } + + public string Content { get; set; } + + public DateTime SentAt { get; set; } +} diff --git a/tests/GraphQL.Client.Tests.Common/Common.cs b/tests/GraphQL.Client.Tests.Common/Common.cs new file mode 100644 index 00000000..83fa8929 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Common.cs @@ -0,0 +1,53 @@ +using GraphQL.Client.Tests.Common.Chat.Schema; +using GraphQL.Client.Tests.Common.StarWars; +using GraphQL.Client.Tests.Common.StarWars.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace GraphQL.Client.Tests.Common; + +public static class Common +{ + public const string STAR_WARS_ENDPOINT = "/graphql/starwars"; + public const string CHAT_ENDPOINT = "/graphql/chat"; + + public static StarWarsSchema GetStarWarsSchema() + { + var services = new ServiceCollection(); + services.AddStarWarsSchema(); + return services.BuildServiceProvider().GetRequiredService(); + } + + public static ChatSchema GetChatSchema() + { + var services = new ServiceCollection(); + services.AddChatSchema(); + return services.BuildServiceProvider().GetRequiredService(); + } + + public static void AddStarWarsSchema(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + } + + public static void AddChatSchema(this IServiceCollection services) + { + var chat = new Chat.Schema.Chat(); + services.AddSingleton(chat); + services.AddSingleton(chat); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/GraphQL.Client.Tests.Common.csproj b/tests/GraphQL.Client.Tests.Common/GraphQL.Client.Tests.Common.csproj new file mode 100644 index 00000000..6b76cf7d --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/GraphQL.Client.Tests.Common.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + false + + + + + + + + + + + + + + + + + diff --git a/tests/GraphQL.Client.Tests.Common/Helpers/AvailableJsonSerializers.cs b/tests/GraphQL.Client.Tests.Common/Helpers/AvailableJsonSerializers.cs new file mode 100644 index 00000000..465a31a1 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Helpers/AvailableJsonSerializers.cs @@ -0,0 +1,21 @@ +using System.Collections; +using GraphQL.Client.Abstractions; + +namespace GraphQL.Client.Tests.Common.Helpers; + +public class AvailableJsonSerializers : IEnumerable where TSerializerInterface : IGraphQLJsonSerializer +{ + public IEnumerator GetEnumerator() + { + // try to find one in the assembly and assign that + var type = typeof(TSerializerInterface); + return AppDomain.CurrentDomain + .GetAssemblies() + .SelectMany(s => s.GetTypes()) + .Where(p => type.IsAssignableFrom(p) && !p.IsInterface && !p.IsAbstract) + .Select(serializerType => new object[] { Activator.CreateInstance(serializerType) }) + .GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/tests/GraphQL.Client.Tests.Common/Helpers/CallbackMonitor.cs b/tests/GraphQL.Client.Tests.Common/Helpers/CallbackMonitor.cs new file mode 100644 index 00000000..b6399a2f --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Helpers/CallbackMonitor.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; + +namespace GraphQL.Client.Tests.Common.Helpers; + +public class CallbackMonitor +{ + private readonly ManualResetEventSlim _callbackInvoked = new ManualResetEventSlim(); + + /// + /// The timeout for . Defaults to 1 second. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Indicates that an update has been received since the last + /// + public bool CallbackInvoked => _callbackInvoked.IsSet; + + /// + /// The last payload which was received. + /// + public T LastPayload { get; private set; } + + public void Invoke(T param) + { + LastPayload = param; + Debug.WriteLine($"CallbackMonitor invoke handler thread id: {Thread.CurrentThread.ManagedThreadId}"); + _callbackInvoked.Set(); + } + + /// + /// Resets the tester class. Should be called before triggering the potential update + /// + public void Reset() + { + LastPayload = default; + _callbackInvoked.Reset(); + } + + public CallbackAssertions Should() => new CallbackAssertions(this); + + public class CallbackAssertions : ReferenceTypeAssertions, CallbackAssertions> + { + public CallbackAssertions(CallbackMonitor tester) : base(tester) + { } + + protected override string Identifier => "callback"; + + public AndWhichConstraint, TPayload> HaveBeenInvokedWithPayload(TimeSpan timeout, + string because = "", params object[] becauseArgs) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => + { + Debug.WriteLine($"HaveBeenInvokedWithPayload thread id: {Thread.CurrentThread.ManagedThreadId}"); + return Subject._callbackInvoked.Wait(timeout); + }) + .ForCondition(isSet => isSet) + .FailWith("Expected {context:callback} to be invoked{reason}, but did not receive a call within {0}", timeout); + + Subject._callbackInvoked.Reset(); + return new AndWhichConstraint, TPayload>(this, Subject.LastPayload); + } + public AndWhichConstraint, TPayload> HaveBeenInvokedWithPayload(string because = "", params object[] becauseArgs) + => HaveBeenInvokedWithPayload(Subject.Timeout, because, becauseArgs); + + public AndConstraint> HaveBeenInvoked(TimeSpan timeout, string because = "", params object[] becauseArgs) + => HaveBeenInvokedWithPayload(timeout, because, becauseArgs); + public AndConstraint> HaveBeenInvoked(string because = "", params object[] becauseArgs) + => HaveBeenInvokedWithPayload(Subject.Timeout, because, becauseArgs); + + public AndConstraint> NotHaveBeenInvoked(TimeSpan timeout, + string because = "", params object[] becauseArgs) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject._callbackInvoked.Wait(timeout)) + .ForCondition(isSet => !isSet) + .FailWith("Expected {context:callback} to not be invoked{reason}, but did receive a call: {0}", Subject.LastPayload); + + Subject._callbackInvoked.Reset(); + return new AndConstraint>(this); + } + public AndConstraint> NotHaveBeenInvoked(string because = "", params object[] becauseArgs) + => NotHaveBeenInvoked(TimeSpan.FromMilliseconds(100), because, becauseArgs); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Helpers/ConcurrentTaskWrapper.cs b/tests/GraphQL.Client.Tests.Common/Helpers/ConcurrentTaskWrapper.cs new file mode 100644 index 00000000..fa78d4a7 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Helpers/ConcurrentTaskWrapper.cs @@ -0,0 +1,47 @@ +namespace GraphQL.Client.Tests.Common.Helpers; + +public class ConcurrentTaskWrapper +{ + public static ConcurrentTaskWrapper New(Func> createTask) => new(createTask); + + private readonly Func _createTask; + private Task _internalTask; + + public ConcurrentTaskWrapper(Func createTask) + { + _createTask = createTask; + } + + public Task Invoke() + { + if (_internalTask != null) + return _internalTask; + + return _internalTask = _createTask(); + } +} + +public class ConcurrentTaskWrapper +{ + private readonly Func> _createTask; + private Task _internalTask; + + public ConcurrentTaskWrapper(Func> createTask) + { + _createTask = createTask; + } + + public Task Invoke() + { + if (_internalTask != null) + return _internalTask; + + return _internalTask = _createTask(); + } + + public void Start() => _internalTask ??= _createTask(); + + public Func> Invoking() => Invoke; + + public void Clear() => _internalTask = null; +} diff --git a/tests/GraphQL.Client.Tests.Common/Helpers/MiscellaneousExtensions.cs b/tests/GraphQL.Client.Tests.Common/Helpers/MiscellaneousExtensions.cs new file mode 100644 index 00000000..d51afe07 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Helpers/MiscellaneousExtensions.cs @@ -0,0 +1,23 @@ +using GraphQL.Client.Http; + +namespace GraphQL.Client.Tests.Common.Helpers; + +public static class MiscellaneousExtensions +{ + public static string RemoveWhitespace(this string input) => + new string(input.ToCharArray() + .Where(c => !char.IsWhiteSpace(c)) + .ToArray()); + + public static CallbackMonitor ConfigureMonitorForOnWebsocketConnected( + this GraphQLHttpClient client) + { + var tester = new CallbackMonitor(); + client.Options.OnWebsocketConnected = c => + { + tester.Invoke(c); + return Task.CompletedTask; + }; + return tester; + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Helpers/NetworkHelpers.cs b/tests/GraphQL.Client.Tests.Common/Helpers/NetworkHelpers.cs new file mode 100644 index 00000000..dda8d6a0 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Helpers/NetworkHelpers.cs @@ -0,0 +1,16 @@ +using System.Net; +using System.Net.Sockets; + +namespace GraphQL.Client.Tests.Common.Helpers; + +public static class NetworkHelpers +{ + public static int GetFreeTcpPortNumber() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + var port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } +} diff --git a/tests/GraphQL.Client.Tests.Common/Properties/launchSettings.json b/tests/GraphQL.Client.Tests.Common/Properties/launchSettings.json new file mode 100644 index 00000000..a50815da --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "GraphQL.Client.Tests.Common": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:59034" + } + } +} \ No newline at end of file diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/Extensions/ResolveFieldContextExtensions.cs b/tests/GraphQL.Client.Tests.Common/StarWars/Extensions/ResolveFieldContextExtensions.cs new file mode 100644 index 00000000..2aac17a6 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/Extensions/ResolveFieldContextExtensions.cs @@ -0,0 +1,61 @@ +using GraphQL.Builders; +using GraphQL.Client.Tests.Common.StarWars.Types; +using GraphQL.Types.Relay.DataObjects; + +namespace GraphQL.Client.Tests.Common.StarWars.Extensions; + +public static class ResolveFieldContextExtensions +{ + public static Connection GetPagedResults(this IResolveConnectionContext context, StarWarsData data, List ids) where U : StarWarsCharacter + { + List idList; + List list; + string cursor; + string endCursor; + var pageSize = context.PageSize ?? 20; + + if (context.IsUnidirectional || context.After != null || context.Before == null) + { + if (context.After != null) + { + idList = ids + .SkipWhile(x => !Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(x)).Equals(context.After)) + .Take(context.First ?? pageSize).ToList(); + } + else + { + idList = ids + .Take(context.First ?? pageSize).ToList(); + } + } + else + { + if (context.Before != null) + { + idList = ids.Reverse() + .SkipWhile(x => !Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(x)).Equals(context.Before)) + .Take(context.Last ?? pageSize).ToList(); + } + else + { + idList = ids.Reverse() + .Take(context.Last ?? pageSize).ToList(); + } + } + + list = data.GetCharactersAsync(idList).Result as List ?? throw new InvalidOperationException($"GetCharactersAsync method should return list of '{typeof(U).Name}' items."); + cursor = list.Count > 0 ? list.Last().Cursor : null; + endCursor = ids.Count > 0 ? Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(ids.Last())) : null; + + return new Connection + { + Edges = list.Select(x => new Edge { Cursor = x.Cursor, Node = x }).ToList(), + TotalCount = list.Count, + PageInfo = new PageInfo + { + EndCursor = endCursor, + HasNextPage = endCursor == null ? false : cursor != endCursor, + } + }; + } +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsData.cs b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsData.cs new file mode 100644 index 00000000..3c32d1f0 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsData.cs @@ -0,0 +1,86 @@ +using GraphQL.Client.Tests.Common.StarWars.Types; + +namespace GraphQL.Client.Tests.Common.StarWars; + +public class StarWarsData +{ + private readonly List _characters = new List(); + + public StarWarsData() + { + _characters.Add(new Human + { + Id = "1", + Name = "Luke", + Friends = new List { "3", "4" }, + AppearsIn = new[] { 4, 5, 6 }, + HomePlanet = "Tatooine", + Cursor = "MQ==" + }); + _characters.Add(new Human + { + Id = "2", + Name = "Vader", + AppearsIn = new[] { 4, 5, 6 }, + HomePlanet = "Tatooine", + Cursor = "Mg==" + }); + + _characters.Add(new Droid + { + Id = "3", + Name = "R2-D2", + Friends = new List { "1", "4" }, + AppearsIn = new[] { 4, 5, 6 }, + PrimaryFunction = "Astromech", + Cursor = "Mw==" + }); + _characters.Add(new Droid + { + Id = "4", + Name = "C-3PO", + AppearsIn = new[] { 4, 5, 6 }, + PrimaryFunction = "Protocol", + Cursor = "NA==" + }); + } + + public IEnumerable GetFriends(StarWarsCharacter character) + { + if (character == null) + { + return null; + } + + var friends = new List(); + var lookup = character.Friends; + if (lookup != null) + { + foreach (var c in _characters.Where(h => lookup.Contains(h.Id))) + friends.Add(c); + } + return friends; + } + + public StarWarsCharacter AddCharacter(StarWarsCharacter character) + { + character.Id = _characters.Count.ToString(); + _characters.Add(character); + return character; + } + + public Task GetHumanByIdAsync(string id) + { + return Task.FromResult(_characters.FirstOrDefault(h => h.Id == id && h is Human) as Human); + } + + public Task GetDroidByIdAsync(string id) + { + return Task.FromResult(_characters.FirstOrDefault(h => h.Id == id && h is Droid) as Droid); + } + + public Task> GetCharactersAsync(List guids) + { + return Task.FromResult(_characters.Where(c => guids.Contains(c.Id)).ToList()); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsMutation.cs b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsMutation.cs new file mode 100644 index 00000000..3d57d1fe --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsMutation.cs @@ -0,0 +1,32 @@ +using GraphQL.Client.Tests.Common.StarWars.Types; +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.StarWars; + +/// Mutation graph type for StarWars schema. +/// +/// This is an example JSON request for a mutation +/// { +/// "query": "mutation ($human:HumanInput!){ createHuman(human: $human) { id name } }", +/// "variables": { +/// "human": { +/// "name": "Boba Fett" +/// } +/// } +/// } +/// +public class StarWarsMutation : ObjectGraphType +{ + public StarWarsMutation(StarWarsData data) + { + Name = "Mutation"; + + Field("createHuman") + .Argument>("human") + .Resolve(context => + { + var human = context.GetArgument("human"); + return data.AddCharacter(human); + }); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsQuery.cs b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsQuery.cs new file mode 100644 index 00000000..1fb231b9 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsQuery.cs @@ -0,0 +1,24 @@ +using GraphQL.Client.Tests.Common.StarWars.Types; +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.StarWars; + +public class StarWarsQuery : ObjectGraphType +{ + public StarWarsQuery(StarWarsData data) + { + Name = "Query"; + + Field("hero").ResolveAsync(async context => await data.GetDroidByIdAsync("3")); + Field("human") + .Argument>("id", "id of the human") + .ResolveAsync(async context => await data.GetHumanByIdAsync(context.GetArgument("id")) + ); + + Func> func = (context, id) => data.GetDroidByIdAsync(id); + + Field("droid") + .Argument>("id", "id of the droid") + .ResolveDelegate(func); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsSchema.cs b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsSchema.cs new file mode 100644 index 00000000..e2220220 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/StarWarsSchema.cs @@ -0,0 +1,16 @@ +using GraphQL.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace GraphQL.Client.Tests.Common.StarWars; + +public class StarWarsSchema : Schema +{ + public StarWarsSchema(IServiceProvider serviceProvider) + : base(serviceProvider) + { + Query = serviceProvider.GetRequiredService(); + Mutation = serviceProvider.GetRequiredService(); + + Description = "Example StarWars universe schema"; + } +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/TestData/StarWarsHumans.cs b/tests/GraphQL.Client.Tests.Common/StarWars/TestData/StarWarsHumans.cs new file mode 100644 index 00000000..4efd812e --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/TestData/StarWarsHumans.cs @@ -0,0 +1,17 @@ +using System.Collections; + +namespace GraphQL.Client.Tests.Common.StarWars.TestData; + +/// +/// Test data object +/// +public class StarWarsHumans : IEnumerable +{ + public IEnumerator GetEnumerator() + { + yield return new object[] { 1, "Luke" }; + yield return new object[] { 2, "Vader" }; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/Types/CharacterInterface.cs b/tests/GraphQL.Client.Tests.Common/StarWars/Types/CharacterInterface.cs new file mode 100644 index 00000000..0cf9d6b2 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/Types/CharacterInterface.cs @@ -0,0 +1,19 @@ +using GraphQL.Types; +using GraphQL.Types.Relay; + +namespace GraphQL.Client.Tests.Common.StarWars.Types; + +public class CharacterInterface : InterfaceGraphType +{ + public CharacterInterface() + { + Name = "Character"; + + Field>("id").Description("The id of the character."); + Field("name").Description("The name of the character."); + + Field>("friends"); + Field>>("friendsConnection"); + Field>("appearsIn").Description("Which movie they appear in."); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/Types/DroidType.cs b/tests/GraphQL.Client.Tests.Common/StarWars/Types/DroidType.cs new file mode 100644 index 00000000..661737f6 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/Types/DroidType.cs @@ -0,0 +1,28 @@ +using GraphQL.Client.Tests.Common.StarWars.Extensions; +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.StarWars.Types; + +public class DroidType : ObjectGraphType +{ + public DroidType(StarWarsData data) + { + Name = "Droid"; + Description = "A mechanical creature in the Star Wars universe."; + + Field>("id").Description("The id of the droid.").Resolve(context => context.Source.Id); + Field("name").Description("The name of the droid.").Resolve(context => context.Source.Name); + + Field>("friends").Resolve(context => data.GetFriends(context.Source)); + + Connection("friendsConnection") + .Description("A list of a character's friends.") + .Bidirectional() + .Resolve(context => context.GetPagedResults(data, context.Source.Friends)); + + Field>("appearsIn").Description("Which movie they appear in."); + Field("primaryFunction").Description("The primary function of the droid.").Resolve(context => context.Source.PrimaryFunction); + + Interface(); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/Types/EpisodeEnum.cs b/tests/GraphQL.Client.Tests.Common/StarWars/Types/EpisodeEnum.cs new file mode 100644 index 00000000..7fbc855f --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/Types/EpisodeEnum.cs @@ -0,0 +1,22 @@ +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.StarWars.Types; + +public class EpisodeEnum : EnumerationGraphType +{ + public EpisodeEnum() + { + Name = "Episode"; + Description = "One of the films in the Star Wars Trilogy."; + Add("NEWHOPE", 4, "Released in 1977."); + Add("EMPIRE", 5, "Released in 1980."); + Add("JEDI", 6, "Released in 1983."); + } +} + +public enum Episodes +{ + NEWHOPE = 4, + EMPIRE = 5, + JEDI = 6 +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/Types/HumanInputType.cs b/tests/GraphQL.Client.Tests.Common/StarWars/Types/HumanInputType.cs new file mode 100644 index 00000000..0402876e --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/Types/HumanInputType.cs @@ -0,0 +1,13 @@ +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.StarWars.Types; + +public class HumanInputType : InputObjectGraphType +{ + public HumanInputType() + { + Name = "HumanInput"; + Field>("name"); + Field("homePlanet"); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/Types/HumanType.cs b/tests/GraphQL.Client.Tests.Common/StarWars/Types/HumanType.cs new file mode 100644 index 00000000..ed19cf5c --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/Types/HumanType.cs @@ -0,0 +1,28 @@ +using GraphQL.Client.Tests.Common.StarWars.Extensions; +using GraphQL.Types; + +namespace GraphQL.Client.Tests.Common.StarWars.Types; + +public class HumanType : ObjectGraphType +{ + public HumanType(StarWarsData data) + { + Name = "Human"; + + Field>("id").Description("The id of the human.").Resolve(context => context.Source.Id); + Field("name").Description("The name of the human.").Resolve(context => context.Source.Name); + + Field>("friends").Resolve(context => data.GetFriends(context.Source)); + + Connection("friendsConnection") + .Description("A list of a character's friends.") + .Bidirectional() + .Resolve(context => context.GetPagedResults(data, context.Source.Friends)); + + Field>("appearsIn").Description("Which movie they appear in."); + + Field("homePlanet").Description("The home planet of the human.").Resolve(context => context.Source.HomePlanet); + + Interface(); + } +} diff --git a/tests/GraphQL.Client.Tests.Common/StarWars/Types/StarWarsCharacter.cs b/tests/GraphQL.Client.Tests.Common/StarWars/Types/StarWarsCharacter.cs new file mode 100644 index 00000000..fb2688f3 --- /dev/null +++ b/tests/GraphQL.Client.Tests.Common/StarWars/Types/StarWarsCharacter.cs @@ -0,0 +1,20 @@ +namespace GraphQL.Client.Tests.Common.StarWars.Types; + +public abstract class StarWarsCharacter +{ + public string Id { get; set; } + public string Name { get; set; } + public List Friends { get; set; } + public int[] AppearsIn { get; set; } + public string Cursor { get; set; } +} + +public class Human : StarWarsCharacter +{ + public string HomePlanet { get; set; } +} + +public class Droid : StarWarsCharacter +{ + public string PrimaryFunction { get; set; } +} diff --git a/tests/GraphQL.Client.Tests/GraphQL.Client.Tests.csproj b/tests/GraphQL.Client.Tests/GraphQL.Client.Tests.csproj deleted file mode 100644 index 11fa8112..00000000 --- a/tests/GraphQL.Client.Tests/GraphQL.Client.Tests.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - netcoreapp3.1 - - - - - - - diff --git a/tests/GraphQL.Client.Tests/GraphQLLocationTest.cs b/tests/GraphQL.Client.Tests/GraphQLLocationTest.cs deleted file mode 100644 index 468d521e..00000000 --- a/tests/GraphQL.Client.Tests/GraphQLLocationTest.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Xunit; - -namespace GraphQL.Client.Tests { - - public class GraphQLLocationTest { - - [Fact] - public void ConstructorFact() { - var graphQLLocation = new GraphQLLocation { Column = 1, Line = 2 }; - Assert.Equal(1U, graphQLLocation.Column); - Assert.Equal(2U, graphQLLocation.Line); - } - - [Fact] - public void Equality1Fact() { - var graphQLLocation = new GraphQLLocation { Column = 1, Line = 2 }; - Assert.Equal(graphQLLocation, graphQLLocation); - } - - [Fact] - public void Equality2Fact() { - var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; - var graphQLLocation2 = new GraphQLLocation { Column = 1, Line = 2 }; - Assert.Equal(graphQLLocation1, graphQLLocation2); - } - - [Fact] - public void EqualityOperatorFact() { - var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; - var graphQLLocation2 = new GraphQLLocation { Column = 1, Line = 2 }; - Assert.True(graphQLLocation1 == graphQLLocation2); - } - - [Fact] - public void InEqualityFact() { - var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; - var graphQLLocation2 = new GraphQLLocation { Column = 2, Line = 1 }; - Assert.NotEqual(graphQLLocation1, graphQLLocation2); - } - - [Fact] - public void InEqualityOperatorFact() { - var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; - var graphQLLocation2 = new GraphQLLocation { Column = 2, Line = 1 }; - Assert.True(graphQLLocation1 != graphQLLocation2); - } - - [Fact] - public void GetHashCodeFact() { - var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; - var graphQLLocation2 = new GraphQLLocation { Column = 1, Line = 2 }; - Assert.True(graphQLLocation1.GetHashCode() == graphQLLocation2.GetHashCode()); - } - - } - -} diff --git a/tests/GraphQL.Client.Tests/GraphQLRequestTest.cs b/tests/GraphQL.Client.Tests/GraphQLRequestTest.cs deleted file mode 100644 index c63a79bc..00000000 --- a/tests/GraphQL.Client.Tests/GraphQLRequestTest.cs +++ /dev/null @@ -1,234 +0,0 @@ -using Xunit; - -namespace GraphQL.Client.Tests { - - public class GraphQLRequestTest { - - [Fact] - public void ConstructorFact() { - var graphQLRequest = new GraphQLRequest { Query = "{hero{name}}" }; - Assert.NotNull(graphQLRequest.Query); - Assert.Null(graphQLRequest.OperationName); - Assert.Null(graphQLRequest.Variables); - } - - [Fact] - public void ConstructorExtendedFact() { - var graphQLRequest = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - Assert.NotNull(graphQLRequest.Query); - Assert.NotNull(graphQLRequest.OperationName); - Assert.NotNull(graphQLRequest.Variables); - } - - [Fact] - public void Equality1Fact() { - var graphQLRequest = new GraphQLRequest { Query = "{hero{name}}" }; - Assert.Equal(graphQLRequest, graphQLRequest); - } - - [Fact] - public void Equality2Fact() { - var graphQLRequest1 = new GraphQLRequest { Query = "{hero{name}}" }; - var graphQLRequest2 = new GraphQLRequest { Query = "{hero{name}}" }; - Assert.Equal(graphQLRequest1, graphQLRequest2); - } - - [Fact] - public void Equality3Fact() { - var graphQLRequest1 = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - var graphQLRequest2 = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - Assert.Equal(graphQLRequest1, graphQLRequest2); - } - - [Fact] - public void EqualityOperatorFact() { - var graphQLRequest1 = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - var graphQLRequest2 = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - Assert.True(graphQLRequest1 == graphQLRequest2); - } - - [Fact] - public void InEquality1Fact() { - var graphQLRequest1 = new GraphQLRequest { Query = "{hero{name1}}" }; - var graphQLRequest2 = new GraphQLRequest { Query = "{hero{name2}}" }; - Assert.NotEqual(graphQLRequest1, graphQLRequest2); - } - - [Fact] - public void InEquality2Fact() { - var graphQLRequest1 = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue1" - } - }; - var graphQLRequest2 = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue2" - } - }; - Assert.NotEqual(graphQLRequest1, graphQLRequest2); - } - - [Fact] - public void InEqualityOperatorFact() { - var graphQLRequest1 = new GraphQLRequest { Query = "{hero{name1}}" }; - var graphQLRequest2 = new GraphQLRequest { Query = "{hero{name2}}" }; - Assert.True(graphQLRequest1 != graphQLRequest2); - } - - [Fact] - public void GetHashCode1Fact() { - var graphQLRequest1 = new GraphQLRequest { Query = "{hero{name}}" }; - var graphQLRequest2 = new GraphQLRequest { Query = "{hero{name}}" }; - Assert.True(graphQLRequest1.GetHashCode() == graphQLRequest2.GetHashCode()); - } - - [Fact] - public void GetHashCode2Fact() { - var graphQLRequest1 = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - var graphQLRequest2 = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - Assert.True(graphQLRequest1.GetHashCode() == graphQLRequest2.GetHashCode()); - } - - [Fact] - public void PropertyQueryGetFact() { - var graphQLRequest = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - Assert.Equal("{hero{name}}", graphQLRequest.Query); - } - - [Fact] - public void PropertyQuerySetFact() { - var graphQLRequest = new GraphQLRequest { - Query = "{hero{name1}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - graphQLRequest.Query = "{hero{name2}}"; - Assert.Equal("{hero{name2}}", graphQLRequest.Query); - } - - [Fact] - public void PropertyOperationNameGetFact() { - var graphQLRequest = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - Assert.Equal("operationName", graphQLRequest.OperationName); - } - - [Fact] - public void PropertyOperationNameNullGetFact() { - var graphQLRequest = new GraphQLRequest { Query = "{hero{name}}" }; - Assert.Null(graphQLRequest.OperationName); - } - - [Fact] - public void PropertyOperationNameSetFact() { - var graphQLRequest = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName1", - Variables = new { - varName = "varValue" - } - }; - graphQLRequest.OperationName = "operationName2"; - Assert.Equal("operationName2", graphQLRequest.OperationName); - } - - [Fact] - public void PropertyVariableGetFact() { - var graphQLRequest = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue" - } - }; - Assert.Equal(new { - varName = "varValue" - }, graphQLRequest.Variables); - } - - [Fact] - public void PropertyVariableNullGetFact() { - var graphQLRequest = new GraphQLRequest { Query = "{hero{name}}" }; - Assert.Null(graphQLRequest.Variables); - } - - [Fact] - public void PropertyVariableSetFact() { - var graphQLRequest = new GraphQLRequest { - Query = "{hero{name}}", - OperationName = "operationName", - Variables = new { - varName = "varValue1" - } - }; - graphQLRequest.Variables = new { - varName = "varValue2" - }; - Assert.Equal(new { - varName = "varValue2" - }, graphQLRequest.Variables); - } - - } - -} diff --git a/tests/GraphQL.Client.Tests/GraphQLResponseTest.cs b/tests/GraphQL.Client.Tests/GraphQLResponseTest.cs deleted file mode 100644 index e124d134..00000000 --- a/tests/GraphQL.Client.Tests/GraphQLResponseTest.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Xunit; - -namespace GraphQL.Client.Tests { - - public class GraphQLResponseTest { - - [Fact] - public void Constructor1Fact() { - var graphQLResponse = new GraphQLResponse(); - Assert.Null(graphQLResponse.Data); - Assert.Null(graphQLResponse.Errors); - } - - [Fact] - public void Constructor2Fact() { - var graphQLResponse = new GraphQLResponse { - Data = new { a = 1 }, - Errors = new[] { new GraphQLError { Message = "message" } } - }; - Assert.NotNull(graphQLResponse.Data); - Assert.NotNull(graphQLResponse.Errors); - } - - [Fact] - public void Equality1Fact() { - var graphQLResponse = new GraphQLResponse(); - Assert.Equal(graphQLResponse, graphQLResponse); - } - - [Fact] - public void Equality2Fact() { - var graphQLResponse1 = new GraphQLResponse(); - var graphQLResponse2 = new GraphQLResponse(); - Assert.Equal(graphQLResponse1, graphQLResponse2); - } - - [Fact] - public void Equality3Fact() { - var graphQLResponse1 = new GraphQLResponse { - Data = new { a = 1 }, - Errors = new[] { new GraphQLError { Message = "message" } } - }; - var graphQLResponse2 = new GraphQLResponse { - Data = new { a = 1 }, - Errors = new[] { new GraphQLError { Message = "message" } } - }; - Assert.Equal(graphQLResponse1, graphQLResponse2); - } - - [Fact] - public void EqualityOperatorFact() { - var graphQLResponse1 = new GraphQLResponse(); - var graphQLResponse2 = new GraphQLResponse(); - Assert.True(graphQLResponse1 == graphQLResponse2); - } - - [Fact] - public void InEqualityFact() { - var graphQLResponse1 = new GraphQLResponse { - Data = new { a = 1 }, - Errors = new[] { new GraphQLError { Message = "message" } } - }; - var graphQLResponse2 = new GraphQLResponse { - Data = new { a = 2 }, - Errors = new[] { new GraphQLError { Message = "message" } } - }; - Assert.NotEqual(graphQLResponse1, graphQLResponse2); - } - - [Fact] - public void InEqualityOperatorFact() { - var graphQLResponse1 = new GraphQLResponse { - Data = new { a = 1 }, - Errors = new[] { new GraphQLError { Message = "message" } } - }; - var graphQLResponse2 = new GraphQLResponse { - Data = new { a = 2 }, - Errors = new[] { new GraphQLError { Message = "message" } } - }; - Assert.True(graphQLResponse1 != graphQLResponse2); - } - - [Fact] - public void GetHashCodeFact() { - var graphQLResponse1 = new GraphQLResponse { - Data = new { a = 1 }, - Errors = new[] { new GraphQLError { Message = "message" } } - }; - var graphQLResponse2 = new GraphQLResponse { - Data = new { a = 1 }, - Errors = new[] { new GraphQLError { Message = "message" } } - }; - Assert.True(graphQLResponse1.GetHashCode() == graphQLResponse2.GetHashCode()); - } - - } - -} diff --git a/tests/GraphQL.Client.Tests/Model/Person.cs b/tests/GraphQL.Client.Tests/Model/Person.cs deleted file mode 100644 index 23b86a43..00000000 --- a/tests/GraphQL.Client.Tests/Model/Person.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace GraphQL.Client.Tests.Model { - - public class Person { - - public string[] AppearsIn { get; set; } - - public Person[] Friends { get; set; } - - public double Height { get; set; } - - public string Name { get; set; } - - public string PrimaryFunction { get; set; } - - } - -} diff --git a/tests/GraphQL.Common.Tests/GraphQL.Common.Tests.csproj b/tests/GraphQL.Common.Tests/GraphQL.Common.Tests.csproj deleted file mode 100644 index 4aae0487..00000000 --- a/tests/GraphQL.Common.Tests/GraphQL.Common.Tests.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - true - netcoreapp3.0 - - - - - - - diff --git a/tests/GraphQL.Integration.Tests/APQ/AutomaticPersistentQueriesTest.cs b/tests/GraphQL.Integration.Tests/APQ/AutomaticPersistentQueriesTest.cs new file mode 100644 index 00000000..0c71a4ee --- /dev/null +++ b/tests/GraphQL.Integration.Tests/APQ/AutomaticPersistentQueriesTest.cs @@ -0,0 +1,110 @@ +using System.Diagnostics.CodeAnalysis; +using FluentAssertions; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Http; +using GraphQL.Client.Tests.Common.StarWars.TestData; +using GraphQL.Integration.Tests.Helpers; +using Xunit; + +namespace GraphQL.Integration.Tests.APQ; + +[SuppressMessage("ReSharper", "UseConfigureAwaitFalse")] +public class AutomaticPersistentQueriesTest : IAsyncLifetime, IClassFixture +{ + public SystemTextJsonAutoNegotiateServerTestFixture Fixture { get; } + protected GraphQLHttpClient StarWarsClient; + protected GraphQLHttpClient StarWarsWebsocketClient; + + public AutomaticPersistentQueriesTest(SystemTextJsonAutoNegotiateServerTestFixture fixture) + { + Fixture = fixture; + } + + public async Task InitializeAsync() + { + await Fixture.CreateServer(); + StarWarsClient = Fixture.GetStarWarsClient(options => options.EnableAutomaticPersistedQueries = _ => true); + StarWarsWebsocketClient = Fixture.GetStarWarsClient(options => + { + options.EnableAutomaticPersistedQueries = _ => true; + options.UseWebSocketForQueriesAndMutations = true; + }); + } + + public Task DisposeAsync() + { + StarWarsClient?.Dispose(); + return Task.CompletedTask; + } + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void After_querying_all_starwars_humans_the_APQDisabledForSession_is_still_false_Async(int id, string name) + { + var query = new GraphQLQuery(""" + query Human($id: String!){ + human(id: $id) { + name + } + } + """); + + var graphQLRequest = new GraphQLRequest(query, new { id = id.ToString() }); + + var response = await StarWarsClient.SendQueryAsync(graphQLRequest, () => new { Human = new { Name = string.Empty } }); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.Human.Name); + StarWarsClient.APQDisabledForSession.Should().BeFalse("if APQ has worked it won't get disabled"); + } + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void After_querying_all_starwars_humans_using_websocket_transport_the_APQDisabledForSession_is_still_false_Async(int id, string name) + { + var query = new GraphQLQuery(""" + query Human($id: String!){ + human(id: $id) { + name + } + } + """); + + var graphQLRequest = new GraphQLRequest(query, new { id = id.ToString() }); + + var response = await StarWarsWebsocketClient.SendQueryAsync(graphQLRequest, () => new { Human = new { Name = string.Empty } }); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.Human.Name); + StarWarsWebsocketClient.APQDisabledForSession.Should().BeFalse("if APQ has worked it won't get disabled"); + } + + [Fact] + public void Verify_the_persisted_query_extension_object() + { + var query = new GraphQLQuery(""" + query Human($id: String!){ + human(id: $id) { + name + } + } + """); + query.Sha256Hash.Should().NotBeNullOrEmpty(); + + var request = new GraphQLRequest(query); + request.Extensions.Should().BeNull(); + request.GeneratePersistedQueryExtension(); + request.Extensions.Should().NotBeNull(); + + string expectedKey = "persistedQuery"; + var expectedExtensionValue = new Dictionary + { + ["version"] = 1, + ["sha256Hash"] = query.Sha256Hash, + }; + + request.Extensions.Should().ContainKey(expectedKey); + request.Extensions![expectedKey].As>() + .Should().NotBeNull().And.BeEquivalentTo(expectedExtensionValue); + } +} diff --git a/tests/GraphQL.Integration.Tests/GraphQL.Integration.Tests.csproj b/tests/GraphQL.Integration.Tests/GraphQL.Integration.Tests.csproj new file mode 100644 index 00000000..4a3f7dc2 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/GraphQL.Integration.Tests.csproj @@ -0,0 +1,34 @@ + + + + + + net8 + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/GraphQL.Integration.Tests/Helpers/IntegrationServerTestFixture.cs b/tests/GraphQL.Integration.Tests/Helpers/IntegrationServerTestFixture.cs new file mode 100644 index 00000000..eca64acc --- /dev/null +++ b/tests/GraphQL.Integration.Tests/Helpers/IntegrationServerTestFixture.cs @@ -0,0 +1,86 @@ +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.Http; +using GraphQL.Client.Http.Websocket; +using GraphQL.Client.Serializer.Newtonsoft; +using GraphQL.Client.Serializer.SystemTextJson; +using GraphQL.Client.Tests.Common; +using GraphQL.Client.Tests.Common.Helpers; + +namespace GraphQL.Integration.Tests.Helpers; + +public abstract class IntegrationServerTestFixture +{ + public int Port { get; private set; } + + public IWebHost? Server { get; private set; } + + public abstract IGraphQLWebsocketJsonSerializer Serializer { get; } + + public abstract string? WebsocketProtocol { get; } + + public IntegrationServerTestFixture() + { + Port = NetworkHelpers.GetFreeTcpPortNumber(); + } + + public async Task CreateServer() + { + if (Server != null) + return; + Server = await WebHostHelpers.CreateServer(Port).ConfigureAwait(false); + } + + public async Task ShutdownServer() + { + if (Server == null) + return; + + await Server.StopAsync(); + Server.Dispose(); + Server = null; + } + + public GraphQLHttpClient GetStarWarsClient(Action? configure = null) + => GetGraphQLClient(Common.STAR_WARS_ENDPOINT, configure); + + public GraphQLHttpClient GetChatClient(Action? configure = null) + => GetGraphQLClient(Common.CHAT_ENDPOINT, configure); + + private GraphQLHttpClient GetGraphQLClient(string endpoint, Action? configure) => + Serializer == null + ? throw new InvalidOperationException("JSON serializer not configured") + : WebHostHelpers.GetGraphQLClient(Port, endpoint, Serializer, options => + { + configure?.Invoke(options); + options.WebSocketProtocol = WebsocketProtocol; + }); +} + +public class NewtonsoftGraphQLWsServerTestFixture : IntegrationServerTestFixture +{ + public override IGraphQLWebsocketJsonSerializer Serializer { get; } = new NewtonsoftJsonSerializer(); + public override string? WebsocketProtocol => WebSocketProtocols.GRAPHQL_WS; +} + +public class SystemTextJsonGraphQLWsServerTestFixture : IntegrationServerTestFixture +{ + public override IGraphQLWebsocketJsonSerializer Serializer { get; } = new SystemTextJsonSerializer(); + public override string? WebsocketProtocol => WebSocketProtocols.GRAPHQL_WS; +} + +public class NewtonsoftGraphQLTransportWsServerTestFixture : IntegrationServerTestFixture +{ + public override IGraphQLWebsocketJsonSerializer Serializer { get; } = new NewtonsoftJsonSerializer(); + public override string? WebsocketProtocol => WebSocketProtocols.GRAPHQL_TRANSPORT_WS; +} + +public class SystemTextJsonGraphQLTransportWsServerTestFixture : IntegrationServerTestFixture +{ + public override IGraphQLWebsocketJsonSerializer Serializer { get; } = new SystemTextJsonSerializer(); + public override string? WebsocketProtocol => WebSocketProtocols.GRAPHQL_TRANSPORT_WS; +} +public class SystemTextJsonAutoNegotiateServerTestFixture : IntegrationServerTestFixture +{ + public override IGraphQLWebsocketJsonSerializer Serializer { get; } = new SystemTextJsonSerializer(); + public override string? WebsocketProtocol => WebSocketProtocols.AUTO_NEGOTIATE; +} diff --git a/tests/GraphQL.Integration.Tests/Helpers/WebHostHelpers.cs b/tests/GraphQL.Integration.Tests/Helpers/WebHostHelpers.cs new file mode 100644 index 00000000..9354f183 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/Helpers/WebHostHelpers.cs @@ -0,0 +1,42 @@ +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.Http; +using GraphQL.Client.Serializer.Newtonsoft; +using IntegrationTestServer; + +namespace GraphQL.Integration.Tests.Helpers; + +public static class WebHostHelpers +{ + public static async Task CreateServer(int port) + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(); + var config = configBuilder.Build(); + config["server.urls"] = $"http://localhost:{port}"; + + var host = new WebHostBuilder() + .ConfigureLogging((ctx, logging) => logging.AddDebug()) + .UseConfiguration(config) + .UseKestrel() + .UseStartup() + .Build(); + + var tcs = new TaskCompletionSource(); + host.Services.GetService().ApplicationStarted.Register(() => tcs.TrySetResult(true)); + await host.StartAsync(); + await tcs.Task; + return host; + } + + public static GraphQLHttpClient GetGraphQLClient( + int port, + string endpoint, + IGraphQLWebsocketJsonSerializer? serializer = null, + Action? configure = null) + { + var options = new GraphQLHttpClientOptions(); + configure?.Invoke(options); + options.EndPoint = new Uri($"http://localhost:{port}{endpoint}"); + return new GraphQLHttpClient(options, serializer ?? new NewtonsoftJsonSerializer()); + } +} diff --git a/tests/GraphQL.Integration.Tests/Properties/launchSettings.json b/tests/GraphQL.Integration.Tests/Properties/launchSettings.json new file mode 100644 index 00000000..3340b96e --- /dev/null +++ b/tests/GraphQL.Integration.Tests/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "GraphQL.Integration.Tests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:51433/" + } + }, + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:55273/", + "sslPort": 44359 + } + } +} \ No newline at end of file diff --git a/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Base.cs b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Base.cs new file mode 100644 index 00000000..e20fdd39 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Base.cs @@ -0,0 +1,204 @@ +using System.Net; +using FluentAssertions; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Http; +using GraphQL.Client.Tests.Common.Chat.Schema; +using GraphQL.Client.Tests.Common.Helpers; +using GraphQL.Client.Tests.Common.StarWars.TestData; +using GraphQL.Integration.Tests.Helpers; +using Xunit; + +namespace GraphQL.Integration.Tests.QueryAndMutationTests; + +public abstract class Base : IAsyncLifetime +{ + protected IntegrationServerTestFixture Fixture; + protected GraphQLHttpClient StarWarsClient; + protected GraphQLHttpClient ChatClient; + + protected Base(IntegrationServerTestFixture fixture) + { + Fixture = fixture; + } + + public async Task InitializeAsync() + { + await Fixture.CreateServer(); + StarWarsClient = Fixture.GetStarWarsClient(); + ChatClient = Fixture.GetChatClient(); + } + + public Task DisposeAsync() + { + ChatClient?.Dispose(); + StarWarsClient?.Dispose(); + return Task.CompletedTask; + } + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void QueryTheory(int id, string name) + { + var graphQLRequest = new GraphQLRequest($"{{ human(id: \"{id}\") {{ name }} }}"); + var response = await StarWarsClient.SendQueryAsync(graphQLRequest, () => new { Human = new { Name = string.Empty } }); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.Human.Name); + } + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void QueryAsHttpResponseTheory(int id, string name) + { + var graphQLRequest = new GraphQLRequest($"{{ human(id: \"{id}\") {{ name }} }}"); + var responseType = new { Human = new { Name = string.Empty } }; + var response = await StarWarsClient.SendQueryAsync(graphQLRequest, () => responseType); + + FluentActions.Invoking(() => response.AsGraphQLHttpResponse()).Should() + .NotThrow("because the returned object is a GraphQLHttpResponse"); + + var httpResponse = response.AsGraphQLHttpResponse(); + + httpResponse.Errors.Should().BeNull(); + httpResponse.Data.Human.Name.Should().Be(name); + + httpResponse.StatusCode.Should().Be(HttpStatusCode.OK); + httpResponse.ResponseHeaders.Date.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromMinutes(1)); + } + + [Theory(Skip = "System.Json.Net deserializes 'dynamic' as JsonElement.")] + [ClassData(typeof(StarWarsHumans))] + public async void QueryWithDynamicReturnTypeTheory(int id, string name) + { + var graphQLRequest = new GraphQLRequest($"{{ human(id: \"{id}\") {{ name }} }}"); + + var response = await StarWarsClient.SendQueryAsync(graphQLRequest); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.human.name.ToString()); + } + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void QueryWitVarsTheory(int id, string name) + { + var query = new GraphQLQuery(""" + query Human($id: String!){ + human(id: $id) { + name + } + } + """); + + var graphQLRequest = new GraphQLRequest(query, new { id = id.ToString() }); + + var response = await StarWarsClient.SendQueryAsync(graphQLRequest, () => new { Human = new { Name = string.Empty } }); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.Human.Name); + } + + [Theory] + [ClassData(typeof(StarWarsHumans))] + public async void QueryWitVarsAndOperationNameTheory(int id, string name) + { + var graphQLRequest = new GraphQLRequest(@" + query Human($id: String!){ + human(id: $id) { + name + } + } + + query Droid($id: String!) { + droid(id: $id) { + name + } + }", + new { id = id.ToString() }, + "Human"); + + var response = await StarWarsClient.SendQueryAsync(graphQLRequest, () => new { Human = new { Name = string.Empty } }); + + Assert.Null(response.Errors); + Assert.Equal(name, response.Data.Human.Name); + } + + [Fact] + public async void SendMutationFact() + { + var mutationRequest = new GraphQLRequest(@" + mutation CreateHuman($human: HumanInput!) { + createHuman(human: $human) { + id + name + homePlanet + } + }", + new { human = new { name = "Han Solo", homePlanet = "Corellia" } }); + + var queryRequest = new GraphQLRequest(@" + query Human($id: String!){ + human(id: $id) { + name + } + }"); + + var mutationResponse = await StarWarsClient.SendMutationAsync(mutationRequest, () => new + { + createHuman = new + { + Id = "", + Name = "", + HomePlanet = "" + } + }); + + Assert.Null(mutationResponse.Errors); + Assert.Equal("Han Solo", mutationResponse.Data.createHuman.Name); + Assert.Equal("Corellia", mutationResponse.Data.createHuman.HomePlanet); + + queryRequest.Variables = new { id = mutationResponse.Data.createHuman.Id }; + var queryResponse = await StarWarsClient.SendQueryAsync(queryRequest, () => new { Human = new { Name = string.Empty } }); + + Assert.Null(queryResponse.Errors); + Assert.Equal("Han Solo", queryResponse.Data.Human.Name); + } + + [Fact] + public async Task PostRequestCanBeCancelled() + { + var graphQLRequest = new GraphQLRequest(@" + query Long { + longRunning + }"); + + var chatQuery = Fixture.Server.Services.GetService(); + var cts = new CancellationTokenSource(); + + var request = + ConcurrentTaskWrapper.New(() => ChatClient.SendQueryAsync(graphQLRequest, () => new { longRunning = string.Empty }, cts.Token)); + + // Test regular request + // start request + request.Start(); + // wait until the query has reached the server + chatQuery.WaitingOnQueryBlocker.Wait(1000).Should().BeTrue("because the request should have reached the server by then"); + // unblock the query + chatQuery.LongRunningQueryBlocker.Set(); + // check execution time + (await request.Invoke()).Data.longRunning.Should().Be("finally returned"); + + // reset stuff + chatQuery.LongRunningQueryBlocker.Reset(); + request.Clear(); + + // cancellation test + request.Start(); + chatQuery.WaitingOnQueryBlocker.Wait(1000).Should().BeTrue("because the request should have reached the server by then"); + cts.Cancel(); + await request.Invoking().Should().ThrowAsync("because the request was cancelled"); + + // let the server finish its query + chatQuery.LongRunningQueryBlocker.Set(); + } +} diff --git a/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Newtonsoft.cs b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Newtonsoft.cs new file mode 100644 index 00000000..78539df0 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/Newtonsoft.cs @@ -0,0 +1,11 @@ +using GraphQL.Integration.Tests.Helpers; +using Xunit; + +namespace GraphQL.Integration.Tests.QueryAndMutationTests; + +public class Newtonsoft : Base, IClassFixture +{ + public Newtonsoft(NewtonsoftGraphQLWsServerTestFixture fixture) : base(fixture) + { + } +} diff --git a/tests/GraphQL.Integration.Tests/QueryAndMutationTests/SystemTextJson.cs b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/SystemTextJson.cs new file mode 100644 index 00000000..d42abb21 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/QueryAndMutationTests/SystemTextJson.cs @@ -0,0 +1,11 @@ +using GraphQL.Integration.Tests.Helpers; +using Xunit; + +namespace GraphQL.Integration.Tests.QueryAndMutationTests; + +public class SystemTextJson : Base, IClassFixture +{ + public SystemTextJson(SystemTextJsonGraphQLWsServerTestFixture fixture) : base(fixture) + { + } +} diff --git a/tests/GraphQL.Integration.Tests/UriExtensionTests.cs b/tests/GraphQL.Integration.Tests/UriExtensionTests.cs new file mode 100644 index 00000000..80d032af --- /dev/null +++ b/tests/GraphQL.Integration.Tests/UriExtensionTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using GraphQL.Client.Http; +using Xunit; + +namespace GraphQL.Integration.Tests; + +public class UriExtensionTests +{ + [Theory] + [InlineData("http://thats-not-a-websocket-url.net", false)] + [InlineData("https://thats-not-a-websocket-url.net", false)] + [InlineData("ftp://thats-not-a-websocket-url.net", false)] + [InlineData("ws://that-is-a-websocket-url.net", true)] + [InlineData("wss://that-is-a-websocket-url.net", true)] + [InlineData("WS://that-is-a-websocket-url.net", true)] + [InlineData("WSS://that-is-a-websocket-url.net", true)] + public void HasWebSocketSchemaTest(string url, bool result) + { + new Uri(url).HasWebSocketScheme().Should().Be(result); + } + + [Theory] + [InlineData("http://this-url-can-be-converted.net", true, "ws://this-url-can-be-converted.net")] + [InlineData("https://this-url-can-be-converted.net", true, "wss://this-url-can-be-converted.net")] + [InlineData("HTTP://this-url-can-be-converted.net", true, "ws://this-url-can-be-converted.net")] + [InlineData("HTTPS://this-url-can-be-converted.net", true, "wss://this-url-can-be-converted.net")] + [InlineData("ws://this-url-can-be-converted.net", true, "ws://this-url-can-be-converted.net")] + [InlineData("wss://this-url-can-be-converted.net", true, "wss://this-url-can-be-converted.net")] + [InlineData("https://this-url-can-be-converted.net/and/all/elements/?are#preserved", true, "wss://this-url-can-be-converted.net/and/all/elements/?are#preserved")] + [InlineData("ftp://this-url-cannot-be-converted.net", false, null)] + // AppSync example + [InlineData("wss://example1234567890000.appsync-realtime-api.us-west-2.amazonaws.com/graphql?header=123456789ABCDEF&payload=e30=", true, "wss://example1234567890000.appsync-realtime-api.us-west-2.amazonaws.com/graphql?header=123456789ABCDEF&payload=e30=")] + public void GetWebSocketUriTest(string input, bool canConvert, string? result) + { + var inputUri = new Uri(input); + if (canConvert) + { + inputUri.GetWebSocketUri().Should().BeEquivalentTo(new Uri(result!)); + } + else + { + inputUri.Invoking(uri => uri.GetWebSocketUri()).Should().Throw(); + } + } +} diff --git a/tests/GraphQL.Integration.Tests/UserAgentHeaderTests.cs b/tests/GraphQL.Integration.Tests/UserAgentHeaderTests.cs new file mode 100644 index 00000000..ba5b7bbf --- /dev/null +++ b/tests/GraphQL.Integration.Tests/UserAgentHeaderTests.cs @@ -0,0 +1,68 @@ +using System.Net.Http.Headers; +using FluentAssertions; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Http; +using GraphQL.Integration.Tests.Helpers; +using Xunit; + +namespace GraphQL.Integration.Tests; + +public class UserAgentHeaderTests : IAsyncLifetime, IClassFixture +{ + private readonly IntegrationServerTestFixture Fixture; + private GraphQLHttpClient? ChatClient; + + public UserAgentHeaderTests(SystemTextJsonAutoNegotiateServerTestFixture fixture) + { + Fixture = fixture; + } + + public async Task InitializeAsync() => await Fixture.CreateServer(); + + public Task DisposeAsync() + { + ChatClient?.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async void Can_set_custom_user_agent() + { + const string userAgent = "CustomUserAgent"; + ChatClient = Fixture.GetChatClient(options => options.DefaultUserAgentRequestHeader = ProductInfoHeaderValue.Parse(userAgent)); + + var graphQLRequest = new GraphQLRequest("query clientUserAgent { clientUserAgent }"); + var response = await ChatClient.SendQueryAsync(graphQLRequest, () => new { clientUserAgent = string.Empty }); + + response.Errors.Should().BeNull(); + response.Data.clientUserAgent.Should().Be(userAgent); + } + + [Fact] + public async void Default_user_agent_is_set_as_expected() + { + string? expectedUserAgent = new ProductInfoHeaderValue( + typeof(GraphQLHttpClient).Assembly.GetName().Name, + typeof(GraphQLHttpClient).Assembly.GetName().Version.ToString()).ToString(); + + ChatClient = Fixture.GetChatClient(); + + var graphQLRequest = new GraphQLRequest("query clientUserAgent { clientUserAgent }"); + var response = await ChatClient.SendQueryAsync(graphQLRequest, () => new { clientUserAgent = string.Empty }); + + response.Errors.Should().BeNull(); + response.Data.clientUserAgent.Should().Be(expectedUserAgent); + } + + [Fact] + public async void No_Default_user_agent_if_set_to_null() + { + ChatClient = Fixture.GetChatClient(options => options.DefaultUserAgentRequestHeader = null); + + var graphQLRequest = new GraphQLRequest("query clientUserAgent { clientUserAgent }"); + var response = await ChatClient.SendQueryAsync(graphQLRequest, () => new { clientUserAgent = string.Empty }); + + response.Errors.Should().HaveCount(1); + response.Errors[0].Message.Should().Be("user agent header not set"); + } +} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTests/Base.cs b/tests/GraphQL.Integration.Tests/WebsocketTests/Base.cs new file mode 100644 index 00000000..98d4226d --- /dev/null +++ b/tests/GraphQL.Integration.Tests/WebsocketTests/Base.cs @@ -0,0 +1,479 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.WebSockets; +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Extensions; +using FluentAssertions.Reactive; +using GraphQL.Client.Abstractions; +using GraphQL.Client.Abstractions.Websocket; +using GraphQL.Client.Http; +using GraphQL.Client.Tests.Common.Chat; +using GraphQL.Client.Tests.Common.Chat.Schema; +using GraphQL.Client.Tests.Common.Helpers; +using GraphQL.Integration.Tests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace GraphQL.Integration.Tests.WebsocketTests; + +public abstract class Base : IAsyncLifetime +{ + protected readonly ITestOutputHelper Output; + protected readonly IntegrationServerTestFixture Fixture; + protected GraphQLHttpClient? ChatClient; + + protected Base(ITestOutputHelper output, IntegrationServerTestFixture fixture) + { + Output = output; + Fixture = fixture; + } + + protected static ReceivedMessage InitialMessage = new() + { + Content = "initial message", + SentAt = DateTime.Now, + FromId = "1" + }; + + public async Task InitializeAsync() + { + await Fixture.CreateServer(); + // make sure the buffer always contains the same message + Fixture.Server.Services.GetService().AddMessage(InitialMessage); + + // then create the chat client + ChatClient ??= Fixture.GetChatClient(options => options.UseWebSocketForQueriesAndMutations = true); + } + + public Task DisposeAsync() + { + ChatClient?.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async void CanSendRequestViaWebsocket() + { + await ChatClient.InitializeWebsocketConnection(); + const string message = "some random testing message"; + var response = await ChatClient.AddMessageAsync(message); + response.Errors.Should().BeNullOrEmpty(); + response.Data.AddMessage.Content.Should().Be(message); + } + + [Fact] + public async void CanUseWebSocketScheme() + { + ChatClient.Options.EndPoint = ChatClient.Options.EndPoint.GetWebSocketUri(); + await ChatClient.InitializeWebsocketConnection(); + const string message = "some random testing message"; + var response = await ChatClient.AddMessageAsync(message); + response.Errors.Should().BeNullOrEmpty(); + response.Data.AddMessage.Content.Should().Be(message); + } + + [Fact] + public async void CanUseDedicatedWebSocketEndpoint() + { + ChatClient.Options.WebSocketEndPoint = ChatClient.Options.EndPoint.GetWebSocketUri(); + ChatClient.Options.EndPoint = new Uri("http://bad-endpoint.test"); + ChatClient.Options.UseWebSocketForQueriesAndMutations = true; + await ChatClient.InitializeWebsocketConnection(); + const string message = "some random testing message"; + var response = await ChatClient.AddMessageAsync(message); + response.Errors.Should().BeNullOrEmpty(); + response.Data.AddMessage.Content.Should().Be(message); + } + + [Fact] + public async void CanUseDedicatedWebSocketEndpointWithoutHttpEndpoint() + { + ChatClient.Options.WebSocketEndPoint = ChatClient.Options.EndPoint.GetWebSocketUri(); + ChatClient.Options.EndPoint = null; + ChatClient.Options.UseWebSocketForQueriesAndMutations = false; + await ChatClient.InitializeWebsocketConnection(); + const string message = "some random testing message"; + var response = await ChatClient.AddMessageAsync(message); + response.Data.AddMessage.Content.Should().Be(message); + } + + [Fact] + public async void WebsocketRequestCanBeCancelled() + { + var graphQLRequest = new GraphQLRequest(@" + query Long { + longRunning + }"); + + var chatQuery = Fixture.Server.Services.GetService(); + var cts = new CancellationTokenSource(); + + await ChatClient.InitializeWebsocketConnection(); + var request = + ConcurrentTaskWrapper.New(() => ChatClient.SendQueryAsync(graphQLRequest, () => new { longRunning = string.Empty }, cts.Token)); + + // Test regular request + // start request + request.Start(); + // wait until the query has reached the server + chatQuery.WaitingOnQueryBlocker.Wait(1000).Should().BeTrue("because the request should have reached the server by then"); + // unblock the query + chatQuery.LongRunningQueryBlocker.Set(); + // check execution time + (await request.Invoke()).Data.longRunning.Should().Be("finally returned"); + + // reset stuff + chatQuery.LongRunningQueryBlocker.Reset(); + request.Clear(); + + // cancellation test + request.Start(); + chatQuery.WaitingOnQueryBlocker.Wait(1000).Should().BeTrue("because the request should have reached the server by then"); + cts.Cancel(); + await request.Invoking().Should().ThrowAsync("because the request was cancelled"); + + // let the server finish its query + chatQuery.LongRunningQueryBlocker.Set(); + } + + [Fact] + public async void CanHandleRequestErrorViaWebsocket() + { + await ChatClient.InitializeWebsocketConnection(); + var response = await ChatClient.SendQueryAsync("this query is formatted quite badly"); + response.Errors.Should().ContainSingle("because the query is invalid"); + } + + private const string SUBSCRIPTION_QUERY = @" + subscription { + messageAdded{ + content + } + }"; + + private readonly GraphQLRequest _subscriptionRequest = new(SUBSCRIPTION_QUERY); + + [Fact] + public async void CanCreateObservableSubscription() + { + var callbackMonitor = ChatClient.ConfigureMonitorForOnWebsocketConnected(); + await ChatClient.InitializeWebsocketConnection(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + + Debug.WriteLine("creating subscription stream"); + var observable = ChatClient.CreateSubscriptionStream(_subscriptionRequest); + + Debug.WriteLine("subscribing..."); + using var observer = observable.Observe(); + await observer.Should().PushAsync(1); + observer.RecordedMessages.Last().Errors.Should().BeNullOrEmpty(); + observer.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(InitialMessage.Content); + + const string message1 = "Hello World"; + var response = await ChatClient.AddMessageAsync(message1); + response.Errors.Should().BeNullOrEmpty(); + response.Data.AddMessage.Content.Should().Be(message1); + await observer.Should().PushAsync(2); + observer.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(message1); + + const string message2 = "lorem ipsum dolor si amet"; + response = await ChatClient.AddMessageAsync(message2); + response.Data.AddMessage.Content.Should().Be(message2); + await observer.Should().PushAsync(3); + observer.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(message2); + + // disposing the client should throw a TaskCanceledException on the subscription + ChatClient.Dispose(); + await observer.Should().CompleteAsync(); + } + + public class MessageAddedSubscriptionResult + { + public MessageAddedContent MessageAdded { get; set; } + + public class MessageAddedContent + { + public string Content { get; set; } + } + } + + [Fact] + public async void CanReconnectWithSameObservable() + { + var callbackMonitor = ChatClient.ConfigureMonitorForOnWebsocketConnected(); + + Debug.WriteLine("creating subscription stream"); + var observable = ChatClient.CreateSubscriptionStream(_subscriptionRequest); + + Debug.WriteLine("subscribing..."); + var observer = observable.Observe(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + await ChatClient.InitializeWebsocketConnection(); + Debug.WriteLine("websocket connection initialized"); + await observer.Should().PushAsync(1); + observer.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(InitialMessage.Content); + + const string message1 = "Hello World"; + Debug.WriteLine($"adding message {message1}"); + var response = await ChatClient.AddMessageAsync(message1); + response.Data.AddMessage.Content.Should().Be(message1); + await observer.Should().PushAsync(2); + observer.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(message1); + + const string message2 = "How are you?"; + response = await ChatClient.AddMessageAsync(message2); + response.Data.AddMessage.Content.Should().Be(message2); + await observer.Should().PushAsync(3); + observer.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(message2); + + Debug.WriteLine("disposing subscription..."); + observer.Dispose(); // does not close the websocket connection + + Debug.WriteLine($"creating new subscription from thread {Environment.CurrentManagedThreadId} ..."); + var observer2 = observable.Observe(); + Debug.WriteLine($"waiting for payload on {Environment.CurrentManagedThreadId} ..."); + await observer2.Should().PushAsync(1); + observer2.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(message2); + + const string message3 = "lorem ipsum dolor si amet"; + response = await ChatClient.AddMessageAsync(message3); + response.Data.AddMessage.Content.Should().Be(message3); + await observer2.Should().PushAsync(2); + observer2.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(message3); + + // disposing the client should complete the subscription + ChatClient.Dispose(); + await observer2.Should().CompleteAsync(); + observer2.Dispose(); + } + + private const string SUBSCRIPTION_QUERY2 = @" + subscription { + userJoined{ + displayName + id + } + }"; + + public class UserJoinedSubscriptionResult + { + public UserJoinedContent UserJoined { get; set; } + + public class UserJoinedContent + { + public string DisplayName { get; set; } + + public string Id { get; set; } + } + + } + + private readonly GraphQLRequest _subscriptionRequest2 = new(SUBSCRIPTION_QUERY2); + + [Fact] + public async void CanConnectTwoSubscriptionsSimultaneously() + { + int port = NetworkHelpers.GetFreeTcpPortNumber(); + var callbackTester = new CallbackMonitor(); + var callbackTester2 = new CallbackMonitor(); + + var callbackMonitor = ChatClient.ConfigureMonitorForOnWebsocketConnected(); + await ChatClient.InitializeWebsocketConnection(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + + Debug.WriteLine("creating subscription stream"); + var observable1 = ChatClient.CreateSubscriptionStream(_subscriptionRequest, callbackTester.Invoke); + var observable2 = ChatClient.CreateSubscriptionStream(_subscriptionRequest2, callbackTester2.Invoke); + + Debug.WriteLine("subscribing..."); + var blocker = new ManualResetEventSlim(false); + FluentTestObserver> messagesMonitor = null; + FluentTestObserver> joinedMonitor = null; + + var tasks = new List + { + Task.Run(() => + { + blocker.Wait(); + messagesMonitor = observable1.Observe(); + }), + Task.Run(() => + { + blocker.Wait(); + joinedMonitor = observable2.Observe(); + }) + }; + + blocker.Set(); + await Task.WhenAll(tasks); + + await messagesMonitor.Should().PushAsync(1); + messagesMonitor.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(InitialMessage.Content); + + const string message1 = "Hello World"; + var response = await ChatClient.AddMessageAsync(message1); + response.Data.AddMessage.Content.Should().Be(message1); + await messagesMonitor.Should().PushAsync(2); + messagesMonitor.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(message1); + + joinedMonitor.Should().NotPush(); + messagesMonitor.Clear(); + joinedMonitor.Clear(); + + var joinResponse = await ChatClient.JoinDeveloperUser(); + joinResponse.Data.Join.DisplayName.Should().Be("developer", "because that's the display name of user \"1\""); + + var payload = await joinedMonitor.Should().PushAsync().GetLastMessageAsync(); + using (new AssertionScope()) + { + payload.Data.UserJoined.Id.Should().Be("1", "because that's the id we sent with our mutation request"); + payload.Data.UserJoined.DisplayName.Should().Be("developer", "because that's the display name of user \"1\""); + } + + messagesMonitor.Should().NotPush(); + messagesMonitor.Clear(); + joinedMonitor.Clear(); + + Debug.WriteLine("disposing subscription..."); + joinedMonitor.Dispose(); + + const string message3 = "lorem ipsum dolor si amet"; + response = await ChatClient.AddMessageAsync(message3); + response.Data.AddMessage.Content.Should().Be(message3); + var msg = await messagesMonitor.Should().PushAsync().GetLastMessageAsync(); + msg.Data.MessageAdded.Content.Should().Be(message3); + + // disposing the client should complete the subscription + ChatClient.Dispose(); + await messagesMonitor.Should().CompleteAsync(); + } + + + [Fact] + public async void CanHandleConnectionTimeout() + { + var errorMonitor = new CallbackMonitor(); + var reconnectBlocker = new ManualResetEventSlim(false); + + var callbackMonitor = ChatClient.ConfigureMonitorForOnWebsocketConnected(); + // configure back-off strategy to allow it to be controlled from within the unit test + ChatClient.Options.BackOffStrategy = i => + { + Debug.WriteLine("back-off strategy: waiting on reconnect blocker"); + reconnectBlocker.Wait(); + Debug.WriteLine("back-off strategy: reconnecting..."); + return TimeSpan.Zero; + }; + + var websocketStates = new ConcurrentQueue(); + + using (ChatClient.WebsocketConnectionState.Subscribe(websocketStates.Enqueue)) + { + websocketStates.Should().ContainSingle(state => state == GraphQLWebsocketConnectionState.Disconnected); + + Debug.WriteLine($"Test method thread id: {Environment.CurrentManagedThreadId}"); + Debug.WriteLine("creating subscription stream"); + var observable = ChatClient.CreateSubscriptionStream(_subscriptionRequest, errorMonitor.Invoke); + + Debug.WriteLine("subscribing..."); + var observer = observable.Observe(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + + websocketStates.Should().ContainInOrder( + GraphQLWebsocketConnectionState.Disconnected, + GraphQLWebsocketConnectionState.Connecting, + GraphQLWebsocketConnectionState.Connected); + // clear the collection so the next tests on the collection work as expected + websocketStates.Clear(); + + await observer.Should().PushAsync(1); + observer.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(InitialMessage.Content); + + const string message1 = "Hello World"; + var response = await ChatClient.AddMessageAsync(message1); + response.Data.AddMessage.Content.Should().Be(message1); + await observer.Should().PushAsync(2); + observer.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(message1); + + Debug.WriteLine("stopping web host..."); + await Fixture.ShutdownServer(); + Debug.WriteLine("web host stopped"); + + errorMonitor.Should().HaveBeenInvokedWithPayload(10.Seconds()) + .Which.Should().BeOfType(); + websocketStates.Should().Contain(GraphQLWebsocketConnectionState.Disconnected); + + Debug.WriteLine("restarting web host..."); + await InitializeAsync(); + Debug.WriteLine("web host started"); + reconnectBlocker.Set(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(3.Seconds()); + await observer.Should().PushAsync(3); + observer.RecordedMessages.Last().Data.MessageAdded.Content.Should().Be(InitialMessage.Content); + + websocketStates.Should().ContainInOrder( + GraphQLWebsocketConnectionState.Disconnected, + GraphQLWebsocketConnectionState.Connecting, + GraphQLWebsocketConnectionState.Connected); + + // disposing the client should complete the subscription + ChatClient.Dispose(); + await observer.Should().CompleteAsync(5.Seconds()); + } + } + + [Fact] + public async void CanHandleSubscriptionError() + { + var callbackMonitor = ChatClient.ConfigureMonitorForOnWebsocketConnected(); + await ChatClient.InitializeWebsocketConnection(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + Debug.WriteLine("creating subscription stream"); + var observable = ChatClient.CreateSubscriptionStream( + new GraphQLRequest(@" + subscription { + failImmediately { + content + } + }") + ); + + Debug.WriteLine("subscribing..."); + + using var observer = observable.Observe(); + + await observer.Should().PushAsync(); + observer.RecordedMessages.Last().Errors.Should().ContainSingle(); + + await observer.Should().CompleteAsync(); + ChatClient.Dispose(); + } + + [Fact] + public async void CanHandleQueryErrorInSubscription() + { + var callbackMonitor = ChatClient.ConfigureMonitorForOnWebsocketConnected(); + await ChatClient.InitializeWebsocketConnection(); + callbackMonitor.Should().HaveBeenInvokedWithPayload(); + Debug.WriteLine("creating subscription stream"); + var observable = ChatClient.CreateSubscriptionStream( + new GraphQLRequest(@" + subscription { + fieldDoesNotExist { + content + } + }") + ); + + Debug.WriteLine("subscribing..."); + + using var observer = observable.Observe(); + + await observer.Should().PushAsync(); + observer.RecordedMessages.Last().Errors.Should().ContainSingle(); + + await observer.Should().CompleteAsync(); + ChatClient.Dispose(); + } +} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTests/NewtonsoftGraphQLTransportWs.cs b/tests/GraphQL.Integration.Tests/WebsocketTests/NewtonsoftGraphQLTransportWs.cs new file mode 100644 index 00000000..0a56d631 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/WebsocketTests/NewtonsoftGraphQLTransportWs.cs @@ -0,0 +1,12 @@ +using GraphQL.Integration.Tests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace GraphQL.Integration.Tests.WebsocketTests; + +public class NewtonsoftGraphQLTransportWs : Base, IClassFixture +{ + public NewtonsoftGraphQLTransportWs(ITestOutputHelper output, NewtonsoftGraphQLTransportWsServerTestFixture fixture) : base(output, fixture) + { + } +} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTests/NewtonsoftGraphQLWs.cs b/tests/GraphQL.Integration.Tests/WebsocketTests/NewtonsoftGraphQLWs.cs new file mode 100644 index 00000000..c20b133c --- /dev/null +++ b/tests/GraphQL.Integration.Tests/WebsocketTests/NewtonsoftGraphQLWs.cs @@ -0,0 +1,12 @@ +using GraphQL.Integration.Tests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace GraphQL.Integration.Tests.WebsocketTests; + +public class NewtonsoftGraphQLWs : Base, IClassFixture +{ + public NewtonsoftGraphQLWs(ITestOutputHelper output, NewtonsoftGraphQLWsServerTestFixture fixture) : base(output, fixture) + { + } +} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJsonAutoNegotiate.cs b/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJsonAutoNegotiate.cs new file mode 100644 index 00000000..e6d72c90 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJsonAutoNegotiate.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using GraphQL.Client.Http.Websocket; +using GraphQL.Integration.Tests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace GraphQL.Integration.Tests.WebsocketTests; + +public class SystemTextJsonAutoNegotiate : Base, IClassFixture +{ + public SystemTextJsonAutoNegotiate(ITestOutputHelper output, SystemTextJsonAutoNegotiateServerTestFixture fixture) : base(output, fixture) + { + } + + [Fact] + public async Task WebSocketProtocolIsAutoNegotiated() + { + await ChatClient.InitializeWebsocketConnection(); + ChatClient.WebSocketSubProtocol.Should().Be(WebSocketProtocols.GRAPHQL_TRANSPORT_WS); + } +} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJsonGraphQLTransportWs.cs b/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJsonGraphQLTransportWs.cs new file mode 100644 index 00000000..50fc92a0 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJsonGraphQLTransportWs.cs @@ -0,0 +1,35 @@ +using FluentAssertions; +using FluentAssertions.Reactive; +using GraphQL.Integration.Tests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace GraphQL.Integration.Tests.WebsocketTests; + +public class SystemTextJsonGraphQLTransportWs : Base, IClassFixture +{ + public SystemTextJsonGraphQLTransportWs(ITestOutputHelper output, SystemTextJsonGraphQLTransportWsServerTestFixture fixture) : base(output, fixture) + { + } + + [Fact] + public async void Sending_a_pong_message_should_not_throw() + { + await ChatClient.InitializeWebsocketConnection(); + + await ChatClient.Awaiting(client => client.SendPongAsync(null)).Should().NotThrowAsync(); + await ChatClient.Awaiting(client => client.SendPongAsync("some payload")).Should().NotThrowAsync(); + } + + [Fact] + public async void Sending_a_ping_message_should_result_in_a_pong_message_from_the_server() + { + await ChatClient.InitializeWebsocketConnection(); + + using var pongObserver = ChatClient.PongStream.Observe(); + + await ChatClient.Awaiting(client => client.SendPingAsync(null)).Should().NotThrowAsync(); + + await pongObserver.Should().PushAsync(1, TimeSpan.FromSeconds(1), "because the server was pinged by the client"); + } +} diff --git a/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJsonGraphQLWs.cs b/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJsonGraphQLWs.cs new file mode 100644 index 00000000..b1efe0b1 --- /dev/null +++ b/tests/GraphQL.Integration.Tests/WebsocketTests/SystemTextJsonGraphQLWs.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using GraphQL.Integration.Tests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace GraphQL.Integration.Tests.WebsocketTests; + +public class SystemTextJsonGraphQLWs : Base, IClassFixture +{ + public SystemTextJsonGraphQLWs(ITestOutputHelper output, SystemTextJsonGraphQLWsServerTestFixture fixture) : base(output, fixture) + { + } + + [Fact] + public async void Sending_a_ping_message_should_throw_not_supported_exception() + { + await ChatClient.InitializeWebsocketConnection(); + + await ChatClient.Awaiting(client => client.SendPingAsync(null)) + .Should().ThrowAsync(); + } + + [Fact] + public async void Sending_a_pong_message_should_throw_not_supported_exception() + { + await ChatClient.InitializeWebsocketConnection(); + + await ChatClient.Awaiting(client => client.SendPongAsync(null)) + .Should().ThrowAsync(); + } + + [Fact] + public async void Subscribing_to_the_pong_stream_should_throw_not_supported_exception() + { + await ChatClient.InitializeWebsocketConnection(); + + ChatClient.Invoking(client => client.PongStream.Subscribe()) + .Should().Throw(); + } +} diff --git a/tests/GraphQL.Primitives.Tests/GraphQL.Primitives.Tests.csproj b/tests/GraphQL.Primitives.Tests/GraphQL.Primitives.Tests.csproj index 99070b4c..d17dbc94 100644 --- a/tests/GraphQL.Primitives.Tests/GraphQL.Primitives.Tests.csproj +++ b/tests/GraphQL.Primitives.Tests/GraphQL.Primitives.Tests.csproj @@ -1,13 +1,26 @@ - + - - netcoreapp3.1 - + + net8 + - - - + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/tests/GraphQL.Primitives.Tests/GraphQLLocationTest.cs b/tests/GraphQL.Primitives.Tests/GraphQLLocationTest.cs new file mode 100644 index 00000000..9f2248c6 --- /dev/null +++ b/tests/GraphQL.Primitives.Tests/GraphQLLocationTest.cs @@ -0,0 +1,61 @@ +using Xunit; + +namespace GraphQL.Primitives.Tests; + +public class GraphQLLocationTest +{ + [Fact] + public void ConstructorFact() + { + var graphQLLocation = new GraphQLLocation { Column = 1, Line = 2 }; + Assert.Equal(1U, graphQLLocation.Column); + Assert.Equal(2U, graphQLLocation.Line); + } + + [Fact] + public void Equality1Fact() + { + var graphQLLocation = new GraphQLLocation { Column = 1, Line = 2 }; + Assert.Equal(graphQLLocation, graphQLLocation); + } + + [Fact] + public void Equality2Fact() + { + var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; + var graphQLLocation2 = new GraphQLLocation { Column = 1, Line = 2 }; + Assert.Equal(graphQLLocation1, graphQLLocation2); + } + + [Fact] + public void EqualityOperatorFact() + { + var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; + var graphQLLocation2 = new GraphQLLocation { Column = 1, Line = 2 }; + Assert.True(graphQLLocation1 == graphQLLocation2); + } + + [Fact] + public void InEqualityFact() + { + var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; + var graphQLLocation2 = new GraphQLLocation { Column = 2, Line = 1 }; + Assert.NotEqual(graphQLLocation1, graphQLLocation2); + } + + [Fact] + public void InEqualityOperatorFact() + { + var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; + var graphQLLocation2 = new GraphQLLocation { Column = 2, Line = 1 }; + Assert.True(graphQLLocation1 != graphQLLocation2); + } + + [Fact] + public void GetHashCodeFact() + { + var graphQLLocation1 = new GraphQLLocation { Column = 1, Line = 2 }; + var graphQLLocation2 = new GraphQLLocation { Column = 1, Line = 2 }; + Assert.True(graphQLLocation1.GetHashCode() == graphQLLocation2.GetHashCode()); + } +} diff --git a/tests/GraphQL.Primitives.Tests/GraphQLLocationTests.cs b/tests/GraphQL.Primitives.Tests/GraphQLLocationTests.cs deleted file mode 100644 index 3b13b068..00000000 --- a/tests/GraphQL.Primitives.Tests/GraphQLLocationTests.cs +++ /dev/null @@ -1,204 +0,0 @@ -using Xunit; - -namespace GraphQL.Primitives.Tests { - - public class GraphQLLocationTests { - - [Fact] - public void Constructor1() { - var graphQLLocation = new GraphQLLocation(); - Assert.NotNull(graphQLLocation); - } - - [Fact] - public void Constructor2() { - var graphQLLocation = new GraphQLLocation { - Column = 10 - }; - Assert.Equal(10U, graphQLLocation.Column); - } - - [Fact] - public void Constructor3() { - var graphQLLocation = new GraphQLLocation { - Line = 10 - }; - Assert.Equal(10U, graphQLLocation.Line); - } - - [Fact] - public void Constructor4() { - var graphQLLocation = new GraphQLLocation { - Column = 10, - Line = 10 - }; - Assert.Equal(10U, graphQLLocation.Column); - Assert.Equal(10U, graphQLLocation.Line); - } - - [Fact] - public void Equality1() { - var graphQLLocation1 = new GraphQLLocation(); - var graphQLLocation2 = new GraphQLLocation(); - Assert.True(graphQLLocation1.Equals(graphQLLocation2)); - Assert.True(graphQLLocation2.Equals(graphQLLocation1)); - } - - [Fact] - public void Equality2() { - var graphQLLocation1 = new GraphQLLocation { - Column = 1, - Line = 2 - }; - var graphQLLocation2 = new GraphQLLocation { - Column = 1, - Line = 2 - }; - Assert.True(graphQLLocation1.Equals(graphQLLocation2)); - Assert.True(graphQLLocation2.Equals(graphQLLocation1)); - } - - [Fact] - public void Equality3() { - var graphQLLocation1 = new GraphQLLocation(); - var graphQLLocation2 = new GraphQLLocation(); - Assert.True(graphQLLocation1 == graphQLLocation2); - Assert.True(graphQLLocation2 == graphQLLocation1); - } - - [Fact] - public void Equality4() { - var graphQLLocation1 = new GraphQLLocation { - Column = 1, - Line = 2 - }; - var graphQLLocation2 = new GraphQLLocation { - Column = 1, - Line = 2 - }; - Assert.True(graphQLLocation1 == graphQLLocation2); - Assert.True(graphQLLocation2 == graphQLLocation1); - } - - [Fact] - public void Equality5() { - var graphQLLocation1 = new GraphQLLocation(); - var graphQLLocation2 = new GraphQLLocation(); - Assert.True(graphQLLocation1.Equals((object)graphQLLocation2)); - Assert.True(graphQLLocation2.Equals((object)graphQLLocation1)); - } - - [Fact] - public void Equality6() { - var graphQLLocation1 = new GraphQLLocation { - Column = 1, - Line = 2 - }; - var graphQLLocation2 = new GraphQLLocation { - Column = 1, - Line = 2 - }; - Assert.True(graphQLLocation1.Equals((object)graphQLLocation2)); - Assert.True(graphQLLocation2.Equals((object)graphQLLocation1)); - } - - [Fact] - public void GetHashCode1() { - var graphQLLocation = new GraphQLLocation(); - Assert.Equal(0, graphQLLocation.GetHashCode()); - } - - [Fact] - public void GetHashCode2() { - var graphQLLocation = new GraphQLLocation { - Column = 1 - }; - Assert.Equal(1.GetHashCode(), graphQLLocation.GetHashCode()); - } - - [Fact] - public void GetHashCode3() { - var graphQLLocation = new GraphQLLocation { - Line = 1 - }; - Assert.Equal(0.GetHashCode() ^ 1.GetHashCode(), graphQLLocation.GetHashCode()); - } - - [Fact] - public void GetHashCode4() { - var graphQLLocation = new GraphQLLocation { - Column = 1, - Line = 2 - }; - Assert.Equal(1.GetHashCode() ^ 2.GetHashCode(), graphQLLocation.GetHashCode()); - } - - [Fact] - public void Inequality1() { - var graphQLLocation1 = new GraphQLLocation(); - var graphQLLocation2 = new GraphQLLocation { Column = 1, Line = 2 }; - Assert.False(graphQLLocation1.Equals(graphQLLocation2)); - Assert.False(graphQLLocation2.Equals(graphQLLocation1)); - } - - [Fact] - public void Inequality2() { - var graphQLLocation1 = new GraphQLLocation { - Column = 1, - Line = 2 - }; - var graphQLLocation2 = new GraphQLLocation { - Column = 2, - Line = 1 - }; - Assert.False(graphQLLocation1.Equals(graphQLLocation2)); - Assert.False(graphQLLocation2.Equals(graphQLLocation1)); - } - - [Fact] - public void Inequality3() { - var graphQLLocation1 = new GraphQLLocation(); - var graphQLLocation2 = new GraphQLLocation { Column = 1, Line = 2 }; - Assert.True(graphQLLocation1 != graphQLLocation2); - Assert.True(graphQLLocation2 != graphQLLocation1); - } - - [Fact] - public void Inequality4() { - var graphQLLocation1 = new GraphQLLocation { - Column = 1, - Line = 2 - }; - var graphQLLocation2 = new GraphQLLocation { - Column = 2, - Line = 1 - }; - Assert.True(graphQLLocation1 != graphQLLocation2); - Assert.True(graphQLLocation2 != graphQLLocation1); - } - - [Fact] - public void Inequality5() { - var graphQLLocation1 = new GraphQLLocation(); - var graphQLLocation2 = new GraphQLLocation { Column = 1, Line = 2 }; - Assert.False(graphQLLocation1.Equals((object)graphQLLocation2)); - Assert.False(graphQLLocation2.Equals((object)graphQLLocation1)); - } - - [Fact] - public void Inequality6() { - var graphQLLocation1 = new GraphQLLocation { - Column = 1, - Line = 2 - }; - var graphQLLocation2 = new GraphQLLocation { - Column = 2, - Line = 1 - }; - Assert.False(graphQLLocation1.Equals((object)graphQLLocation2)); - Assert.False(graphQLLocation2.Equals((object)graphQLLocation1)); - } - - } - -} diff --git a/tests/GraphQL.Primitives.Tests/GraphQLRequestTest.cs b/tests/GraphQL.Primitives.Tests/GraphQLRequestTest.cs new file mode 100644 index 00000000..45560652 --- /dev/null +++ b/tests/GraphQL.Primitives.Tests/GraphQLRequestTest.cs @@ -0,0 +1,183 @@ +using FluentAssertions; +using Xunit; + +namespace GraphQL.Primitives.Tests; + +public class GraphQLRequestTest +{ + [Fact] + public void ConstructorFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}"); + Assert.NotNull(graphQLRequest.Query); + Assert.Null(graphQLRequest.OperationName); + Assert.Null(graphQLRequest.Variables); + } + + [Fact] + public void ConstructorExtendedFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName"); + Assert.NotNull(graphQLRequest.Query); + Assert.NotNull(graphQLRequest.OperationName); + Assert.NotNull(graphQLRequest.Variables); + } + + [Fact] + public void Equality1Fact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}"); + graphQLRequest.Equals(graphQLRequest).Should().BeTrue(); + } + + [Fact] + public void Equality2Fact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name}}"); + var graphQLRequest2 = new GraphQLRequest("{hero{name}}"); + graphQLRequest1.Equals(graphQLRequest2).Should().BeTrue(); + } + + [Fact] + public void Equality3Fact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName"); + var graphQLRequest2 = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName"); + graphQLRequest1.Equals(graphQLRequest2).Should().BeTrue(); + } + + [Fact] + public void Equality4Fact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name}}", new { varName = "varValue1" }, "operationName"); + var graphQLRequest2 = new GraphQLRequest("{hero{name}}", new { varName = "varValue2" }, "operationName"); + Assert.NotEqual(graphQLRequest1, graphQLRequest2); + } + + [Fact] + public void EqualityOperatorFact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName"); + var graphQLRequest2 = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName"); + Assert.True(graphQLRequest1 == graphQLRequest2); + } + + [Fact] + public void InEquality1Fact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name1}}"); + var graphQLRequest2 = new GraphQLRequest("{hero{name2}}"); + graphQLRequest1.Equals(graphQLRequest2).Should().BeFalse(); + } + + [Fact] + public void InEquality2Fact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name}}", new { varName = "varValue1" }, "operationName"); + var graphQLRequest2 = new GraphQLRequest("{hero{name}}", new { varName = "varValue2" }, "operationName"); + graphQLRequest1.Equals(graphQLRequest2).Should().BeFalse(); + } + + [Fact] + public void InEqualityOperatorFact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name1}}"); + var graphQLRequest2 = new GraphQLRequest("{hero{name2}}"); + Assert.True(graphQLRequest1 != graphQLRequest2); + } + + [Fact] + public void GetHashCode1Fact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name}}"); + var graphQLRequest2 = new GraphQLRequest("{hero{name}}"); + Assert.True(graphQLRequest1.GetHashCode() == graphQLRequest2.GetHashCode()); + } + + [Fact] + public void GetHashCode2Fact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName"); + var graphQLRequest2 = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName"); + Assert.True(graphQLRequest1.GetHashCode() == graphQLRequest2.GetHashCode()); + } + + [Fact] + public void GetHashCode3Fact() + { + var graphQLRequest1 = new GraphQLRequest("{hero{name}}", new { varName = "varValue1" }, "operationName"); + var graphQLRequest2 = new GraphQLRequest("{hero{name}}", new { varName = "varValue2" }, "operationName"); + Assert.True(graphQLRequest1.GetHashCode() != graphQLRequest2.GetHashCode()); + } + + [Fact] + public void PropertyQueryGetFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}", new { varName = "varValue1" }, "operationName"); + Assert.Equal("{hero{name}}", graphQLRequest.Query); + } + + [Fact] + public void PropertyQuerySetFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}", new { varName = "varValue1" }, "operationName") + { + Query = "{hero{name2}}" + }; + Assert.Equal("{hero{name2}}", graphQLRequest.Query); + } + + [Fact] + public void PropertyOperationNameGetFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName"); + Assert.Equal("operationName", graphQLRequest.OperationName); + } + + [Fact] + public void PropertyOperationNameNullGetFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}"); + Assert.Null(graphQLRequest.OperationName); + } + + [Fact] + public void PropertyOperationNameSetFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName1") + { + OperationName = "operationName2" + }; + Assert.Equal("operationName2", graphQLRequest.OperationName); + } + + [Fact] + public void PropertyVariableGetFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}", new { varName = "varValue" }, "operationName"); + Assert.Equal(new { varName = "varValue" }, graphQLRequest.Variables); + } + + [Fact] + public void PropertyVariableNullGetFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}"); + Assert.Null(graphQLRequest.Variables); + } + + [Fact] + public void PropertyVariableSetFact() + { + var graphQLRequest = new GraphQLRequest("{hero{name}}", new { varName = "varValue1" }, "operationName1") + { + Variables = new + { + varName = "varValue2" + } + }; + Assert.Equal(new + { + varName = "varValue2" + }, graphQLRequest.Variables); + } +} diff --git a/tests/GraphQL.Primitives.Tests/GraphQLResponseTest.cs b/tests/GraphQL.Primitives.Tests/GraphQLResponseTest.cs new file mode 100644 index 00000000..6db1dcbf --- /dev/null +++ b/tests/GraphQL.Primitives.Tests/GraphQLResponseTest.cs @@ -0,0 +1,113 @@ +using Xunit; + +namespace GraphQL.Primitives.Tests; + +public class GraphQLResponseTest +{ + [Fact] + public void Constructor1Fact() + { + var graphQLResponse = new GraphQLResponse(); + Assert.Null(graphQLResponse.Data); + Assert.Null(graphQLResponse.Errors); + } + + [Fact] + public void Constructor2Fact() + { + var graphQLResponse = new GraphQLResponse + { + Data = new { a = 1 }, + Errors = new[] { new GraphQLError { Message = "message" } } + }; + Assert.NotNull(graphQLResponse.Data); + Assert.NotNull(graphQLResponse.Errors); + } + + [Fact] + public void Equality1Fact() + { + var graphQLResponse = new GraphQLResponse(); + Assert.Equal(graphQLResponse, graphQLResponse); + } + + [Fact] + public void Equality2Fact() + { + var graphQLResponse1 = new GraphQLResponse(); + var graphQLResponse2 = new GraphQLResponse(); + Assert.Equal(graphQLResponse1, graphQLResponse2); + } + + [Fact] + public void Equality3Fact() + { + var graphQLResponse1 = new GraphQLResponse + { + Data = new { a = 1 }, + Errors = new[] { new GraphQLError { Message = "message" } } + }; + var graphQLResponse2 = new GraphQLResponse + { + Data = new { a = 1 }, + Errors = new[] { new GraphQLError { Message = "message" } } + }; + Assert.Equal(graphQLResponse1, graphQLResponse2); + } + + [Fact] + public void EqualityOperatorFact() + { + var graphQLResponse1 = new GraphQLResponse(); + var graphQLResponse2 = new GraphQLResponse(); + Assert.True(graphQLResponse1 == graphQLResponse2); + } + + [Fact] + public void InEqualityFact() + { + var graphQLResponse1 = new GraphQLResponse + { + Data = new { a = 1 }, + Errors = new[] { new GraphQLError { Message = "message" } } + }; + var graphQLResponse2 = new GraphQLResponse + { + Data = new { a = 2 }, + Errors = new[] { new GraphQLError { Message = "message" } } + }; + Assert.NotEqual(graphQLResponse1, graphQLResponse2); + } + + [Fact] + public void InEqualityOperatorFact() + { + var graphQLResponse1 = new GraphQLResponse + { + Data = new { a = 1 }, + Errors = new[] { new GraphQLError { Message = "message" } } + }; + var graphQLResponse2 = new GraphQLResponse + { + Data = new { a = 2 }, + Errors = new[] { new GraphQLError { Message = "message" } } + }; + Assert.True(graphQLResponse1 != graphQLResponse2); + } + + [Fact] + public void GetHashCodeFact() + { + var graphQLResponse1 = new GraphQLResponse + { + Data = new { a = 1 }, + Errors = new[] { new GraphQLError { Message = "message" } } + }; + var graphQLResponse2 = new GraphQLResponse + { + Data = new { a = 1 }, + Errors = new[] { new GraphQLError { Message = "message" } } + }; + Assert.True(graphQLResponse1.GetHashCode() == graphQLResponse2.GetHashCode()); + } +} diff --git a/tests/GraphQL.Primitives.Tests/JsonSerializationTests.cs b/tests/GraphQL.Primitives.Tests/JsonSerializationTests.cs new file mode 100644 index 00000000..14917d58 --- /dev/null +++ b/tests/GraphQL.Primitives.Tests/JsonSerializationTests.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using FluentAssertions; +using Xunit; + +namespace GraphQL.Primitives.Tests; + +public class JsonSerializationTests +{ + [Fact] + public void WebSocketResponseDeserialization() + { + var testObject = new ExtendedTestObject { Id = "test", OtherData = "this is some other stuff" }; + var json = JsonSerializer.Serialize(testObject); + var deserialized = JsonSerializer.Deserialize(json); + deserialized.Id.Should().Be("test"); + var dict = JsonSerializer.Deserialize>(json); + var childObject = (JsonElement)dict["ChildObject"]; + childObject.GetProperty("Id").GetString().Should().Be(testObject.ChildObject.Id); + } + + public class TestObject + { + public string Id { get; set; } + } + + public class ExtendedTestObject : TestObject + { + public string OtherData { get; set; } + + public TestObject ChildObject { get; set; } = new TestObject { Id = "1337" }; + } +} diff --git a/tests/GraphQL.Server.Test/GraphQL.Server.Test.csproj b/tests/GraphQL.Server.Test/GraphQL.Server.Test.csproj index 8ae5718c..191bfd81 100644 --- a/tests/GraphQL.Server.Test/GraphQL.Server.Test.csproj +++ b/tests/GraphQL.Server.Test/GraphQL.Server.Test.csproj @@ -1,13 +1,14 @@ - + - - netcoreapp3.1 - + + net8 + false + - - - - - + + + + + diff --git a/tests/GraphQL.Server.Test/GraphQL/Models/Repository.cs b/tests/GraphQL.Server.Test/GraphQL/Models/Repository.cs index 15889373..7fcfc500 100644 --- a/tests/GraphQL.Server.Test/GraphQL/Models/Repository.cs +++ b/tests/GraphQL.Server.Test/GraphQL/Models/Repository.cs @@ -1,27 +1,29 @@ -using System; using GraphQL.Types; -namespace GraphQL.Server.Test.GraphQL.Models { +namespace GraphQL.Server.Test.GraphQL.Models; - public class Repository { - public int DatabaseId { get; set; } - public string Id { get; set; } - public string Name { get; set; } - public object Owner { get; set; } - public Uri Url { get; set; } - } +public class Repository +{ + public int DatabaseId { get; set; } - public class RepositoryGraphType : ObjectGraphType { + public string? Id { get; set; } - public RepositoryGraphType() { - this.Name = nameof(Repository); - this.Field(expression => expression.DatabaseId); - this.Field>("id"); - this.Field(expression => expression.Name); - //this.Field(expression => expression.Owner); - this.Field>("url"); - } + public string? Name { get; set; } - } + public object? Owner { get; set; } + public Uri? Url { get; set; } +} + +public class RepositoryGraphType : ObjectGraphType +{ + public RepositoryGraphType() + { + Name = nameof(Repository); + Field(expression => expression.DatabaseId); + Field>("id"); + Field(expression => expression.Name); + //this.Field(expression => expression.Owner); + Field>("url"); + } } diff --git a/tests/GraphQL.Server.Test/GraphQL/Storage.cs b/tests/GraphQL.Server.Test/GraphQL/Storage.cs index 175390db..cc8887e0 100644 --- a/tests/GraphQL.Server.Test/GraphQL/Storage.cs +++ b/tests/GraphQL.Server.Test/GraphQL/Storage.cs @@ -1,22 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Linq; using GraphQL.Server.Test.GraphQL.Models; -namespace GraphQL.Server.Test.GraphQL { - - public static class Storage { - - public static IQueryable Repositories { get; } = new List() - .Append(new Repository { - DatabaseId = 113196300, - Id = "MDEwOlJlcG9zaXRvcnkxMTMxOTYzMDA=", - Name = "graphql-client", - Owner = null, - Url = new Uri("https://github.com/graphql-dotnet/graphql-client") - }) - .AsQueryable(); - - } +namespace GraphQL.Server.Test.GraphQL; +public static class Storage +{ + public static IQueryable Repositories { get; } = new List() + .Append(new Repository + { + DatabaseId = 113196300, + Id = "MDEwOlJlcG9zaXRvcnkxMTMxOTYzMDA=", + Name = "graphql-client", + Owner = null, + Url = new Uri("https://github.com/graphql-dotnet/graphql-client") + }) + .AsQueryable(); } diff --git a/tests/GraphQL.Server.Test/GraphQL/TestMutation.cs b/tests/GraphQL.Server.Test/GraphQL/TestMutation.cs index 07e801e4..ffae2332 100644 --- a/tests/GraphQL.Server.Test/GraphQL/TestMutation.cs +++ b/tests/GraphQL.Server.Test/GraphQL/TestMutation.cs @@ -1,12 +1,10 @@ using GraphQL.Types; -namespace GraphQL.Server.Test.GraphQL { - - public class TestMutation : ObjectGraphType { - - public TestMutation() { - } - - } +namespace GraphQL.Server.Test.GraphQL; +public class TestMutation : ObjectGraphType +{ + public TestMutation() + { + } } diff --git a/tests/GraphQL.Server.Test/GraphQL/TestQuery.cs b/tests/GraphQL.Server.Test/GraphQL/TestQuery.cs index 1950c967..4e225a23 100644 --- a/tests/GraphQL.Server.Test/GraphQL/TestQuery.cs +++ b/tests/GraphQL.Server.Test/GraphQL/TestQuery.cs @@ -1,19 +1,20 @@ -using System.Linq; using GraphQL.Server.Test.GraphQL.Models; using GraphQL.Types; -namespace GraphQL.Server.Test.GraphQL { - - public class TestQuery : ObjectGraphType { - - public TestQuery() { - this.Field("repository", arguments: new QueryArguments(new QueryArgument> { Name = "owner" }, new QueryArgument> { Name = "name" }), resolve: context => { - var owner = context.GetArgument("owner"); - var name = context.GetArgument("name"); - return Storage.Repositories.FirstOrDefault(predicate => predicate.Name == name); - }); - } - - } +namespace GraphQL.Server.Test.GraphQL; +public class TestQuery : ObjectGraphType +{ + public TestQuery() + { + Field("repository") + .Argument>("owner") + .Argument>("name") + .Resolve(context => + { + var owner = context.GetArgument("owner"); + var name = context.GetArgument("name"); + return Storage.Repositories.FirstOrDefault(predicate => predicate.Name == name); + }); + } } diff --git a/tests/GraphQL.Server.Test/GraphQL/TestSchema.cs b/tests/GraphQL.Server.Test/GraphQL/TestSchema.cs index 1457cf03..feecf79e 100644 --- a/tests/GraphQL.Server.Test/GraphQL/TestSchema.cs +++ b/tests/GraphQL.Server.Test/GraphQL/TestSchema.cs @@ -1,15 +1,13 @@ using GraphQL.Types; -namespace GraphQL.Server.Test.GraphQL { - - public class TestSchema : Schema { - - public TestSchema() { - this.Query = new TestQuery(); - //this.Mutation = new TestMutation(); - //this.Subscription = new TestSubscription(); - } - - } - +namespace GraphQL.Server.Test.GraphQL; + +public class TestSchema : Schema +{ + public TestSchema() + { + Query = new TestQuery(); + //this.Mutation = new TestMutation(); + //this.Subscription = new TestSubscription(); + } } diff --git a/tests/GraphQL.Server.Test/GraphQL/TestSubscription.cs b/tests/GraphQL.Server.Test/GraphQL/TestSubscription.cs index c85a14ce..d8ebc5de 100644 --- a/tests/GraphQL.Server.Test/GraphQL/TestSubscription.cs +++ b/tests/GraphQL.Server.Test/GraphQL/TestSubscription.cs @@ -1,12 +1,10 @@ using GraphQL.Types; -namespace GraphQL.Server.Test.GraphQL { - - public class TestSubscription : ObjectGraphType { - - public TestSubscription() { - } - - } +namespace GraphQL.Server.Test.GraphQL; +public class TestSubscription : ObjectGraphType +{ + public TestSubscription() + { + } } diff --git a/tests/GraphQL.Server.Test/Program.cs b/tests/GraphQL.Server.Test/Program.cs index 29155449..f205fd5c 100644 --- a/tests/GraphQL.Server.Test/Program.cs +++ b/tests/GraphQL.Server.Test/Program.cs @@ -1,19 +1,14 @@ -using System.Threading.Tasks; using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -namespace GraphQL.Server.Test { +namespace GraphQL.Server.Test; - public class Program { - - public static async Task Main(string[] args) => - await CreateHostBuilder(args).Build().RunAsync(); - - public static IWebHostBuilder CreateHostBuilder(string[] args = null) => - WebHost.CreateDefaultBuilder(args) - .UseKestrel(options => { options.AllowSynchronousIO = true; }) - .UseStartup(); - - } +public static class Program +{ + public static async Task Main(string[] args) => + await CreateHostBuilder(args).Build().RunAsync(); + public static IWebHostBuilder CreateHostBuilder(string[] args = null) => + WebHost.CreateDefaultBuilder(args) + .UseKestrel(options => options.AllowSynchronousIO = true) + .UseStartup(); } diff --git a/tests/GraphQL.Server.Test/Startup.cs b/tests/GraphQL.Server.Test/Startup.cs index f2b257c1..04dddcdc 100644 --- a/tests/GraphQL.Server.Test/Startup.cs +++ b/tests/GraphQL.Server.Test/Startup.cs @@ -1,42 +1,30 @@ using GraphQL.Server.Test.GraphQL; -using GraphQL.Server.Ui.GraphiQL; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace GraphQL.Server.Test { - - public class Startup { - - public IConfiguration Configuration { get; } - - public Startup(IConfiguration configuration) { - this.Configuration = configuration; - } - - public void Configure(IApplicationBuilder app) { - var webHostEnvironment = app.ApplicationServices.GetRequiredService(); - if (webHostEnvironment.IsDevelopment()) { - app.UseDeveloperExceptionPage(); - } - app.UseHttpsRedirection(); - - app.UseWebSockets(); - app.UseGraphQLWebSockets(); - app.UseGraphQL(); - app.UseGraphiQLServer(new GraphiQLOptions { }); - } - - public void ConfigureServices(IServiceCollection services) { - services.AddSingleton(); - services.AddGraphQL(options => { - options.EnableMetrics = true; - options.ExposeExceptions = true; - }).AddWebSockets(); - } - - } +namespace GraphQL.Server.Test; + +public class Startup +{ + public void Configure(IApplicationBuilder app) + { + var webHostEnvironment = app.ApplicationServices.GetRequiredService(); + if (webHostEnvironment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + app.UseHttpsRedirection(); + + app.UseWebSockets(); + app.UseGraphQL(); + app.UseGraphQLGraphiQL(); + } + + public void ConfigureServices(IServiceCollection services) + { + services.AddGraphQL(builder => builder + .AddSchema() + .AddSystemTextJson() + .UseApolloTracing(enableMetrics: true) + .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true) + ); + } } diff --git a/tests/GraphQL.Server.Test/libman.json b/tests/GraphQL.Server.Test/libman.json new file mode 100644 index 00000000..ceee2710 --- /dev/null +++ b/tests/GraphQL.Server.Test/libman.json @@ -0,0 +1,5 @@ +{ + "version": "1.0", + "defaultProvider": "cdnjs", + "libraries": [] +} \ No newline at end of file diff --git a/tests/IntegrationTestServer/IntegrationTestServer.csproj b/tests/IntegrationTestServer/IntegrationTestServer.csproj new file mode 100644 index 00000000..58854750 --- /dev/null +++ b/tests/IntegrationTestServer/IntegrationTestServer.csproj @@ -0,0 +1,23 @@ + + + + net8 + IntegrationTestServer.Program + false + + + + + + + + + + + + + + + + + diff --git a/tests/IntegrationTestServer/Program.cs b/tests/IntegrationTestServer/Program.cs new file mode 100644 index 00000000..3220c40d --- /dev/null +++ b/tests/IntegrationTestServer/Program.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore; + +namespace IntegrationTestServer; + +public static class Program +{ + public static void Main(string[] args) => CreateWebHostBuilder(args).Build().Run(); + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + WebHost.CreateDefaultBuilder(args) + .UseStartup() + .ConfigureLogging((_, logging) => logging.SetMinimumLevel(LogLevel.Debug)); +} diff --git a/tests/IntegrationTestServer/Properties/launchSettings.json b/tests/IntegrationTestServer/Properties/launchSettings.json new file mode 100644 index 00000000..987589b7 --- /dev/null +++ b/tests/IntegrationTestServer/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:55069/ui/graphiql/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IntegrationTestServer": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "ui/graphiql", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5005" + } + } +} diff --git a/tests/IntegrationTestServer/Startup.cs b/tests/IntegrationTestServer/Startup.cs new file mode 100644 index 00000000..a907d623 --- /dev/null +++ b/tests/IntegrationTestServer/Startup.cs @@ -0,0 +1,60 @@ +using GraphQL; +using GraphQL.Client.Tests.Common; +using GraphQL.Client.Tests.Common.Chat.Schema; +using GraphQL.Client.Tests.Common.StarWars; +using GraphQL.Server.Ui.Altair; +using GraphQL.Server.Ui.GraphiQL; +using Microsoft.AspNetCore.Server.Kestrel.Core; + +namespace IntegrationTestServer; + +public class Startup +{ + public Startup(IConfiguration configuration, IWebHostEnvironment environment) + { + Configuration = configuration; + Environment = environment; + } + + public IConfiguration Configuration { get; } + + public IWebHostEnvironment Environment { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public void ConfigureServices(IServiceCollection services) + { + services.Configure(options => options.AllowSynchronousIO = true); + services.AddHttpContextAccessor(); + services.AddChatSchema(); + services.AddStarWarsSchema(); + services.AddGraphQL(builder => builder + .UseApolloTracing(enableMetrics: true) + .ConfigureExecutionOptions(opt => opt.UnhandledExceptionDelegate = ctx => + { + var logger = ctx.Context.RequestServices.GetRequiredService>(); + logger.LogError("{Error} occurred", ctx.OriginalException.Message); + return Task.CompletedTask; + }) + .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = Environment.IsDevelopment()) + .AddSystemTextJson() + .UseAutomaticPersistedQueries(options => options.TrackLinkedCacheEntries = true) + .AddGraphTypes(typeof(ChatSchema).Assembly)); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + app.UseWebSockets(); + + app.UseGraphQL(Common.CHAT_ENDPOINT); + app.UseGraphQL(Common.STAR_WARS_ENDPOINT); + + app.UseGraphQLGraphiQL(options: new GraphiQLOptions { GraphQLEndPoint = Common.STAR_WARS_ENDPOINT }); + app.UseGraphQLAltair(options: new AltairOptions { GraphQLEndPoint = Common.CHAT_ENDPOINT }); + } +} diff --git a/tests/tests.props b/tests/tests.props index 46e995fc..f2765e66 100644 --- a/tests/tests.props +++ b/tests/tests.props @@ -1,17 +1,19 @@ - - + + false + $(NoWarn);NU1701;IDE1006 + - - false - true - - - - - - - + + + + + + + all + runtime;build;native;contentfiles;analyzers;buildtransitive + +