diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3c8af84 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,254 @@ +# http://EditorConfig.org + +root = true + +# Default settings ------------------------------------------------------------- + +[*] +insert_final_newline = true +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 + +# C# files --------------------------------------------------------------------- + +[*.cs] + +# .NET code style settings ----------------------------------------------------- + +## "This." and "Me." qualifiers + +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +## Language keywords instead of framework type names for type references + +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +## Modifier preferences + +dotnet_style_require_accessibility_modifiers = always:suggestion +dotnet_style_readonly_field = true:warning + +## Parentheses preferences + +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent + +## Expression-level preferences + +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_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 = false:suggestion + +## Null-checking preferences + +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:warning + +## Formatting conventions + +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = true + +# C# code style settings ------------------------------------------------------- + +## Using statements + +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_style_namespace_declarations = file_scoped:warning + +## Expression-bodied members + +csharp_style_expression_bodied_methods = unset +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = false:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion + +## Pattern matching + +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_prefer_switch_expression = true:suggestion + +## Inlined variable declarations + +csharp_style_inlined_variable_declaration = true:suggestion + +## Expression-level preferences + +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion + +## "Null" checking preferences + +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = false:suggestion + +## Code block preferences + +csharp_prefer_braces = true:error + +## C# formatting settings + +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:warning + +## Indentation options + +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +csharp_indent_block_contents = true +csharp_indent_braces = false + +## Spacing options + +csharp_space_after_cast = true +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false +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_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_after_semicolon_in_for_statement = true +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_before_open_square_brackets = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false + +## Wrapping options + +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = true + + +# Naming rules ------------------------------------------------------------------------------------ +# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-naming-conventions?view=vs-2019 + +## Private fields + +dotnet_naming_rule.camel_case_for_private_fields.severity = warning +dotnet_naming_rule.camel_case_for_private_fields.symbols = private_fields_symbols +dotnet_naming_rule.camel_case_for_private_fields.style = private_fields_style + +dotnet_naming_symbols.private_fields_symbols.applicable_kinds = field +dotnet_naming_symbols.private_fields_symbols.applicable_accessibilities = private +dotnet_naming_style.private_fields_style.required_prefix = _ +dotnet_naming_style.private_fields_style.capitalization = camel_case + +## Async methods + +dotnet_naming_rule.async_method_name.severity = suggestion +dotnet_naming_rule.async_method_name.symbols = async_method_name_symbols +dotnet_naming_rule.async_method_name.style = async_method_name_style + +dotnet_naming_symbols.async_method_name_symbols.applicable_kinds = method,delegate +dotnet_naming_symbols.async_method_name_symbols.applicable_accessibilities = * +dotnet_naming_symbols.async_method_name_symbols.required_modifiers = async + +dotnet_naming_style.async_method_name_style.required_suffix = Async +dotnet_naming_style.async_method_name_style.capitalization = pascal_case + +## Async local functions + +dotnet_naming_rule.async_local_function_name.severity = warning +dotnet_naming_rule.async_local_function_name.symbols = async_local_function_name_symbols +dotnet_naming_rule.async_local_function_name.style = async_local_function_name_style + +dotnet_naming_symbols.async_local_function_name_symbols.applicable_kinds = local_function +dotnet_naming_symbols.async_local_function_name_symbols.applicable_accessibilities = * +dotnet_naming_symbols.async_local_function_name_symbols.required_modifiers = async + +dotnet_naming_style.async_local_function_name_style.required_suffix = Async +dotnet_naming_style.async_local_function_name_style.capitalization = camel_case + +## Sync local functions + +dotnet_naming_rule.sync_local_function_name.severity = warning +dotnet_naming_rule.sync_local_function_name.symbols = sync_local_function_name_symbols +dotnet_naming_rule.sync_local_function_name.style = sync_local_function_name_style + +dotnet_naming_symbols.sync_local_function_name_symbols.applicable_kinds = local_function +dotnet_naming_symbols.sync_local_function_name_symbols.applicable_accessibilities = * + +dotnet_naming_style.sync_local_function_name_style.capitalization = camel_case + +## Internal data + +dotnet_naming_rule.pascal_case_for_internal_data.severity = suggestion +dotnet_naming_rule.pascal_case_for_internal_data.symbols = internal_data_symbols +dotnet_naming_rule.pascal_case_for_internal_data.style = internal_data_style + +dotnet_naming_symbols.internal_data_symbols.applicable_kinds = field, property +dotnet_naming_symbols.internal_data_symbols.applicable_accessibilities = internal +dotnet_naming_style.internal_data_style.capitalization = pascal_case + + +# Analyzers ---------------------------------------------------------------------- + +dotnet_diagnostic.ca1032.severity = none # Default exception constructors. +dotnet_diagnostic.ca1062.severity = none # Validate arguments of public methods. +dotnet_diagnostic.ca1303.severity = none # Do not pass literals as localized parameters. +dotnet_diagnostic.ca1308.severity = none # Normalize strings to uppercase. +dotnet_diagnostic.ca1707.severity = none # Identifiers should not contain underscores. +dotnet_diagnostic.ca1710.severity = none # Name of type must end with 'Collection'. +dotnet_diagnostic.ca1716.severity = none # Identifiers should not match keywords. +dotnet_diagnostic.ca1724.severity = none # Type names should not match namespaces. +dotnet_diagnostic.ca1812.severity = none # Class is an internal class that is apparently never instantiated. +dotnet_diagnostic.ca2007.severity = none # Consider calling ConfigureAwait on the awaited task. +dotnet_diagnostic.ca2016.severity = warning # Consider calling ConfigureAwait on the awaited task. +dotnet_diagnostic.ca5377.severity = none # Use Container Level Access Policy. +dotnet_diagnostic.ca1805.severity = none # Do not initialize unnecessarily. +dotnet_diagnostic.cs8509.severity = error # The switch expression does not handle all possible inputs. +dotnet_diagnostic.cs8524.severity = none # The switch expression does not handle some values of its input type. + + +# Visual Studio - Analyzer settings --------------------------------------------------------- + +dotnet_diagnostic.ide0022.severity = none # Use expression body for methods +dotnet_diagnostic.ide0052.severity = warning # Remove unused members +# Rider/ReShaper - Inspections settings ----------------------------------------------------- + +resharper_csharp_max_line_length = 180 +resharper_c_sharp_warnings_cs8509_highlighting = none +resharper_wrap_object_and_collection_initializer_style = chop_always +resharper_csharp_wrap_after_declaration_lpar = true +resharper_inconsistent_naming_highlighting = none + +# Solution-specific settings/overrides ------------------------------------------------------ + +## TBD + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7144c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,131 @@ +name: CI + +on: + push: + branches: + - main + tags: + - v* + paths-ignore: + - '*.md' + - 'docs/**' + - '.github/workflows/purge-packages.yml' + pull_request: + paths-ignore: + - '*.md' + - 'docs/**' + - '.github/workflows/purge-packages.yml' + +jobs: + init: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.resolve-version.outputs.version }} + publish-nuget-org: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }} + publish-github: ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/tags/v') }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install dotnet-gitversion + run: | + echo ":/github/home/.dotnet/tools" >> $GITHUB_PATH + dotnet tool install -g GitVersion.Tool --version "5.12.0" --ignore-failed-sources + shell: bash + - name: Resolve Version + id: resolve-version + shell: pwsh + working-directory: ./ + run: | + $rawVersion = dotnet-gitversion | ConvertFrom-Json + Write-Output $rawVersion + $semVer = $rawVersion.SemVer + Write-Output "Resolved version $semVer" + Write-Output "version=$semVer" >> $env:GITHUB_OUTPUT + Write-Output "Version = $semVer" >> $env:GITHUB_STEP_SUMMARY + + build: + runs-on: ubuntu-latest + needs: init + env: + Configuration: Release + TreatWarningsAsErrors: true + Version: ${{ needs.init.outputs.version }} + steps: + - uses: actions/checkout@v4 + - name: Restore + run: dotnet restore + - name: Check Format + run: dotnet format --no-restore --verify-no-changes + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-restore + working-directory: ./tests/Tests + - name: Pack + run: dotnet pack --no-build -o nugets/ + - name: Validate NuGets + run: | + echo ':/github/home/.dotnet/tools' >> $env:GITHUB_PATH + dotnet tool install -g dotnet-validate --version '0.0.1-preview.304' --ignore-failed-sources + Get-ChildItem ./nugets -Filter '*.nupkg' | ForEach-Object { dotnet-validate package local $_ } + shell: pwsh + - name: Upload Nugets as Artifacts + uses: actions/upload-artifact@v4 + if: ${{ needs.init.outputs.publish-nuget-org == 'true' || needs.init.outputs.publish-github == 'true'}} + with: + name: nugets + path: nugets + + test-with-azure-infra: + runs-on: ubuntu-latest + environment: azure + permissions: + contents: read + id-token: write + env: + Configuration: Release + TreatWarningsAsErrors: true + steps: + - uses: actions/checkout@v4 + - uses: azure/login@v2 + with: + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + client-id: ${{ secrets.AZURE_CLIENT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - run: dotnet test + env: + SPOTFLOW_USE_AZURE: true + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_RESOURCE_GROUP_NAME: ${{ secrets.AZURE_RESOURCE_GROUP_NAME }} + AZURE_STORAGE_ACCOUNT_NAME: ${{ secrets.AZURE_STORAGE_ACCOUNT_NAME }} + AZURE_SERVICE_BUS_NAMESPACE_NAME: ${{ secrets.AZURE_SERVICE_BUS_NAMESPACE_NAME }} + AZURE_KEY_VAULT_NAME: ${{ secrets.AZURE_KEY_VAULT_NAME }} + + publish-nuget-org: + runs-on: ubuntu-latest + needs: [init, build, test-with-azure-infra] + if: ${{ needs.init.outputs.publish-nuget-org == 'true' }} + environment: nuget-org + steps: + - name: Download Nugets + uses: actions/download-artifact@v4 + with: + name: nugets + - name: Push Nugets + run: dotnet nuget push '*.nupkg' --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + + publish-github: + runs-on: ubuntu-latest + needs: [init, build, test-with-azure-infra] + permissions: + packages: write + if: ${{ needs.init.outputs.publish-github == 'true' }} + steps: + - name: Download Nugets + uses: actions/download-artifact@v4 + with: + name: nugets + - name: Push Nugets + run: dotnet nuget push '*.nupkg' --api-key ${{ secrets.GITHUB_TOKEN }} --source https://nuget.pkg.github.com/spotflow-io/index.json --skip-duplicate \ No newline at end of file diff --git a/.github/workflows/purge-packages.yml b/.github/workflows/purge-packages.yml new file mode 100644 index 0000000..06d8e4c --- /dev/null +++ b/.github/workflows/purge-packages.yml @@ -0,0 +1,56 @@ +name: Purge Packages + +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + push: + branches: + - main + paths: + - .github/workflows/purge-packages.yml + +permissions: + packages: write + contents: read + +jobs: + list-packages: + runs-on: ubuntu-latest + outputs: + nugets: ${{ steps.nugets.outputs.packages }} + permissions: + contents: read + packages: read + steps: + - id: nugets + run: | + all_packages=$(gh api '/orgs/${{ github.repository_owner }}/packages?package_type=nuget' -H 'Accept: application/vnd.github+json' -H 'X-GitHub-Api-Version: 2022-11-28') + + echo $all_packages | jq + + packages=$(echo $all_packages | jq -c '[.[].name]') + + echo $packages | jq + echo $packages | jq >> $GITHUB_STEP_SUMMARY + echo "packages=$packages" >> $GITHUB_OUTPUT + + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + purge-nugets: + runs-on: ubuntu-latest + needs: list-packages + permissions: + packages: write + strategy: + matrix: + package: ${{ fromJson(needs.list-packages.outputs.nugets) }} + steps: + - uses: actions/delete-package-versions@v5 + with: + owner: ${{ github.repository_owner }} + package-name: ${{ matrix.package }} + package-type: nuget + min-versions-to-keep: 10 + delete-only-pre-release-versions: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0228ea2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,262 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +.acrbuild + +*.ldb + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +coverage.json +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +#*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml +/Configuration/DatabaseCredentials.txt +/Backend/Datamole.Lely.Lssa/Automation.LegacyContentConverter/Configuration/DatabaseCredentials.txt +/Datamole.Dsa/SystemTests/Credentials.json + +.ipynb_checkpoints/ +sln/Api/Properties/launchSettings.json diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..66095a7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Contributor Code of Conduct + +This project accepts contributions from anyone who's intentions are to improve the project and help the community. We are committed to providing a friendly, safe and welcoming environment for all, regardless of background and level of experience. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b80074a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# Contributing guidelines + +By contributing to `In-Memory Azure Test SDKs`, you declare that: + +* You are entitled to assign the copyright for the work, provided it is not owned by your employer or you have received a written copyright assignment. +* You license your contribution under the same terms that apply to the rest of the `In-Memory Azure Test SDKs` project. +* You pledge to follow the [Code of Conduct](./CODE_OF_CONDUCT.md). + +## Contribution process + +Please, always create an [Issue](https://github.com/spotflow-io/in-memory-azure-test-sdk/issues/new) before starting to work on a new feature or bug fix. This way, we can discuss the best approach and avoid duplicated or lost work. Without discussing the issue first, there is a risk that your PR will not be accepted because e.g.: + +* It does not fit the project's goals. +* It is not implemented in the way that we would like to see. +* It is already being worked on by someone else. + +### Commits & Pull Requests + +We do not put any specific requirements on individual commits. However, we expect that the Pull Request (PR) is a logical unit of work that is easily understandable & reviewable. The PRs should also contain expressive title and description. + +Few general rules are: + +* Do not mix multiple unrelated changes in a single PR. +* Do not mix formatting changes with functional changes. +* Do not mix refactoring with functional changes. +* Do not create huge PRs that are hard to review. In case that your change is logically cohesive but still large, consider splitting it into multiple PRs. + +### Code style + +This project generally follows usual code style for .NET projects as described in [Framework Design Guidelines](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/). In situations where Guideline is not clear, applicable or strict adherence to the Guideline obviously causes more harm than good, deviating from the Guideline is acceptable. + +We use `dotnet-format` to format the code. Code formatting is checked in the CI pipeline and failing to pass the formatting check will result in a failed build. + +### Testing + +All new code must be covered by tests. Prefer adding new tests specific for the new code, but feel free to extend existing tests if it makes sense. + +### Documentation & changelog + +All new features and changes must be reflected in the documentation [README.md](./README.md) and/or [docs](./docs). Also, make sure to update the [CHANGELOG.md](./CHANGELOG.md) file with a brief description of the changes. The changelog follows the [Keep a Changelog](https://keepachangelog.com) format. + +## Architecture of in-memory clients + +#### General rules + +* No static state should be used in the in-memory clients. + +#### Client constructors + +* The in-memory clients should have constructors that are as similar to the real clients as possible. +* In-general, the constructors should omit authentication-related parameters. If the parameters are needed for constructor signature to be unique, these parameters can be included, but no-op/dummy implementation of the related type must be provided. (e.g. [NoOpTokenCredential](./src/Spotflow.InMemory.Azure/Auth/NoOpTokenCredential.cs)). + +#### Supported client methods & properties + +* All supported methods must also support their async counterparts if they exist in the real client. +* All supported methods must be truly async (currently, this is mostly ensured by calling `Task.Yield()` at the start of each method). +* If a supported method accepts a parameter that is related to a unsupported feature, the parameter should be ignored and the should not fail because of it. +* No supported method should not return dummy constant value. Instead, they should return a value that reflects the current state of the in-memory provider. + +#### Unsupported client methods & properties + +* All unsupported methods should be explicitly overwritten and throw `NotSupportedException` exception. This way, user is not exposed to confusing behavior coming from inherited real clients which are (by design) not properly configured. + +#### Thread-safety + +* Clients must be thread-safe. + +#### In-memory providers & related types + +* Root in-memory provider should be always public and should have a public parameterless constructor if possible. +* Visibility of related types representing the current in-memory state should be determined by how the concepts that the types represent are usually managed in Azure: + * If the concept mostly managed via Azure management-plane, the related type should be public and should expose relevant properties and methods to manage the concept. E.g. [InMemoryEventHub](./src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHub.cs). + * If the concept mostly managed via Azure data-plane, the related type should be internal. E.g. [InMemoryBlockBlob](./src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlockBlob.cs). \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..7326691 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,70 @@ + + + + Spotflow;Azure;Testing;Mocks;Fakes + PackageIcon.png + MIT + true + true + embedded + true + true + true + snupkg + + + + + + + + + + net8.0 + latest + enable + strict + enable + false + false + + + + + + true + + + + + + true + true + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..96dfcdd --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..e91f8b2 --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,7 @@ +build-metadata-padding: 4 +mode: ContinuousDeployment +branches: + main: + tag: beta + pull-request: + tag: alpha \ No newline at end of file diff --git a/HeroImage.jpg b/HeroImage.jpg new file mode 100644 index 0000000..43d04a3 Binary files /dev/null and b/HeroImage.jpg differ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..eeebf9d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2024 Datamole + +Copyright (c) 2024 Spotflow + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PackageIcon.png b/PackageIcon.png new file mode 100644 index 0000000..73f83ee Binary files /dev/null and b/PackageIcon.png differ diff --git a/README.md b/README.md index 50cf19e..1142b06 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,112 @@ -# in-memory-azure-test-sdk -Drop-in fakes of Azure .NET SDKs to make your test blazing-fast and reliable. +![](HeroImage.jpg) + +

Azure In-Memory SDKs for Testing

+

Drop-in fakes of Azure .NET SDKs to make your test blazing-fast and reliable.

+ +

+ Supported SDKs | + Example | + Key features | + Why Should I Use It? | + How It Works | + License +

+ +![CI status](https://github.com/spotflow-io/in-memory-azure-test-sdk/actions/workflows/ci.yml/badge.svg?branch=main) + +## Supported SDKs + +| Package | Relevant Azure SDK | NuGet | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [**Spotflow.InMemory.Azure.Storage**](./docs/storage.md) | [Azure.Storage.Blobs](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/storage.blobs-readme), [Azure.Data.Tables](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/data.tables-readme) | [![NuGet](https://img.shields.io/nuget/v/Spotflow.InMemory.Azure.Storage.svg)](https://www.nuget.org/packages/Spotflow.InMemory.Azure.Storage) | +| [**Spotflow.InMemory.Azure.Storage.FluentAssertions**](./docs/storage.md) | | [![NuGet](https://img.shields.io/nuget/v/Spotflow.InMemory.Azure.Storage.FluentAssertions.svg)](https://www.nuget.org/packages/Spotflow.InMemory.Azure.Storage.FluentAssertions) | +| [**Spotflow.InMemory.Azure.EventHubs**](./docs/event-hubs.md) | [Azure.Messaging.EventHubs](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/event-hubs) | [![NuGet](https://img.shields.io/nuget/v/Spotflow.InMemory.Azure.EventHubs.svg)](https://www.nuget.org/packages/Spotflow.InMemory.Azure.EventHubs) | +| [**Spotflow.InMemory.Azure.ServiceBus**](./docs/service-bus.md) | [Azure.Messaging.ServiceBus](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/microsoft.servicebus-readme) | [![NuGet](https://img.shields.io/nuget/v/Spotflow.InMemory.Azure.ServiceBus.svg)](https://www.nuget.org/packages/Spotflow.InMemory.Azure.ServiceBus) | +| [**Spotflow.InMemory.Azure.ServiceBus.FluentAssertions**](./docs/service-bus.md) | | [![NuGet](https://img.shields.io/nuget/v/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions.svg)](https://www.nuget.org/packages/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions) | +| [**Spotflow.InMemory.Azure.KeyVault**](./docs/key-vault.md) | [Azure.Security.KeyVault.Secrets](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/security.keyvault.secrets-readme) | [![NuGet](https://img.shields.io/nuget/v/Spotflow.InMemory.Azure.KeyVault.svg)](https://www.nuget.org/packages/Spotflow.InMemory.Azure.KeyVault) | + +## Example + +See how the in-memory Azure SDKs can be used in your code, for example with Azure Storage: + +![Design](./docs/images/intro.excalidraw.svg) + +```csharp +var storageAccount = new InMemoryStorageProvider().AddAccount(); + +// The InMemoryBlobContainerClient inherits from BlobContainerClient (from the official SDK) +// So it can be used as a drop-in replacement for the real BlobContainerClient in your tests +var containerClient = InMemoryBlobContainerClient.FromAccount(storageAccount, "test-container"); + +// From now on, you can use the BlobContainerClient methods as you're used to: +containerClient.Create(); + +await containerClient.UploadBlobAsync("my-blob", BinaryData.FromString("Hello World!")); + +// The `containerClient` can now be used in your code as if it was a real BlobContainerClient: + +await PrintBlobAsync(containerClient); + +async Task PrintBlobAsync(BlobContainerClient container) +{ + var blob = container.GetBlobClient("my-blob"); + + var response = await blob.DownloadContentAsync(); + + Console.WriteLine(response.Value.Content.ToString()); + // Output: Hello World! +} +``` + +This design allows you to create a factory for SDK clients with two implementations: one that provides the official Azure SDK clients and another that provides in-memory clients. +By selecting the appropriate factory, you can use the real implementation in your production code and the in-memory implementation in your tests. + +You can learn how we recommend to use this library in [the documentations for each SDK](#supported-sdks). + +## Key Features + +- **Drop-in Replacement** of the official Azure SDKs. +- **Blazing-fast** thanks to the in-memory implementation. +- **No external dependencies**, not even Docker. +- **Fault Injection**: build resilient code thanks to simulated Azure outages in your tests. +- **Delay Injection**: Examine behavior of your system under pressure of slowed-down operations. +- **`TimeProvider` Support**: avoid flaky tests thanks to the time abstraction. +- **Fluent Assertions** to conveniently test common scenarios. +- **Customizable**: you can easily extend the functionality of the in-memory providers via [before and after hooks](./docs/hooks.md). + +## Why Should I Use It? + +There's been a lot written on why to prefer fakes over mocks in tests. +Mocks are test-doubles that return pre-programmed responses to method calls. +This can tightly couple your tests to implementation details, making them brittle and hard to maintain. +Fakes, on the other hand, are lightweight implementations of real services that can seamlessly integrate into your tests. +Using real services in tests is another approach, which is reasonable in many cases but can result in tests that are slow and harder to manage. + +**One major drawback of fakes is the initial effort required to create them. +We have solved this problem by implementing them for you.** +This way, you can use the same interfaces and methods as in the real SDKs, but with the benefits of in-memory implementation. + +## How It Works + +The Azure SDKs are [designed](https://learn.microsoft.com/en-us/dotnet/azure/sdk/unit-testing-mocking?tabs=csharp) for inheritance-based testability: + +- Important methods are `virtual`. +- There are parameterless protected constructor available for all clients. +- There are static factories for creating most models. + +The in-memory clients (e.g. `InMemoryBlobContainerClient` or `InMemoryEventHubConsumerClient`) provided by this library are inheriting the Azure SDK clients so that they can be injected to any code that expected the actual Azure SDK clients (the `BlobContainerClient` or `EventHubConsumerClient` the previous example). The tested code can therefore depend directly on Azure SDK clients and only abstract away creation of these clients. This removes the need to design and implement custom client interfaces. + +The in-memory clients have similar constructors as real clients but they all also require a so-called in-memory provider (e.g. `InMemoryStorageProvider` or `InMemoryEventHubProvider`). The in-memory providers emulate the functionality of the actual services for which the SDK clients are created for (e.g. Azure Storage or Azure Event Hubs). The providers allows to read, change and assert the internal state during testing. For most Azure SDK clients, the in-memory providers are exposing corresponding types representing actual state. For example for `InMemoryBlobContainerClient: BlobContainerClient`, there is `InMemoryBlobContainer` type exposed by the provider. The important difference is that the `InMemoryBlobContainer` is representing the actual state (in this case an existing Azure Storage Blob Container) while `InMemoryBlobContainerClient` might be representing container that does not yet exist. + +## Maintainers + +- [Tomáš Pajurek](https://github.com/tomas-pajurek) (Spotflow) +- [David Nepožitek](https://github.com/DavidNepozitek) (Spotflow) + +## Contributing + +Please read our [Contributing Guidelines](./CONTRIBUTING.md) to learn how you can contribute to this project. + +## License + +This project is licensed under the [MIT license](./LICENSE.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6351e88 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Reporting a Vulnerability + +If a newly discovered vulnerability or security issue is discovered, we kindly ask our users and security researchers to disclose it privately and securely via the [GitHub Security Advisories (GHSA)](https://github.com/spotflow-io/in-memory-azure-test-sdk/security/advisories/new) feature on this repository. Please do not report vulnerabilities via GitHub issues or other public channels. Disclosing a vulnerability publicly might lead to a situation where a vulnerability is widely known, but no fix is yet available, thus harming other users. + +Alternatively, the report can be sent via email to `security@spotflow.io`. However, we prefer GHSA for security reasons. + +Ultimately, we will publish all vulnerabilities publicly and credit the reporter appropriately for the discovery, but only after a fix is available. + +## Bug Bounty Programs + +Currently, we are not running any bug bounty programs. \ No newline at end of file diff --git a/Spotflow.InMemory.Azure.sln b/Spotflow.InMemory.Azure.sln new file mode 100644 index 0000000..68b0790 --- /dev/null +++ b/Spotflow.InMemory.Azure.sln @@ -0,0 +1,76 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34525.116 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{2C6BE026-65BE-4D7A-8E4D-F8CF8E2C300E}" + ProjectSection(SolutionItems) = preProject + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + LICENSE.md = LICENSE.md + nuget.config = nuget.config + README.md = README.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spotflow.InMemory.Azure.EventHubs", "src\Spotflow.InMemory.Azure.EventHubs\Spotflow.InMemory.Azure.EventHubs.csproj", "{BA34B3BD-E616-4835-AEA8-6D6A97E637BC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spotflow.InMemory.Azure", "src\Spotflow.InMemory.Azure\Spotflow.InMemory.Azure.csproj", "{5B3ECEB4-0E24-4040-9429-BA63333C6B66}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spotflow.InMemory.Azure.ServiceBus", "src\Spotflow.InMemory.Azure.ServiceBus\Spotflow.InMemory.Azure.ServiceBus.csproj", "{1C052B5D-CA36-4AF0-89B3-C563F62D1B6C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spotflow.InMemory.Azure.Storage", "src\Spotflow.InMemory.Azure.Storage\Spotflow.InMemory.Azure.Storage.csproj", "{DB49720B-C0E5-42BB-A9C6-77D3DC065D09}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "tests\Tests\Tests.csproj", "{BFF0A04C-5057-4A05-9122-CBF98C25DA33}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spotflow.InMemory.Azure.ServiceBus.FluentAssertions", "src\Spotflow.InMemory.Azure.ServiceBus.FluentAssertions\Spotflow.InMemory.Azure.ServiceBus.FluentAssertions.csproj", "{49091FD7-4936-42B3-9189-C18796B84269}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spotflow.InMemory.Azure.Storage.FluentAssertions", "src\Spotflow.InMemory.Azure.Storage.FluentAssertions\Spotflow.InMemory.Azure.Storage.FluentAssertions.csproj", "{5D749882-6633-415A-9DC3-5C7C208B29CB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spotflow.InMemory.Azure.KeyVault", "src\Spotflow.InMemory.Azure.KeyVault\Spotflow.InMemory.Azure.KeyVault.csproj", "{BFC359F7-8FE7-4ABC-B192-8E9DE43460A5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BA34B3BD-E616-4835-AEA8-6D6A97E637BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA34B3BD-E616-4835-AEA8-6D6A97E637BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA34B3BD-E616-4835-AEA8-6D6A97E637BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA34B3BD-E616-4835-AEA8-6D6A97E637BC}.Release|Any CPU.Build.0 = Release|Any CPU + {5B3ECEB4-0E24-4040-9429-BA63333C6B66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B3ECEB4-0E24-4040-9429-BA63333C6B66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B3ECEB4-0E24-4040-9429-BA63333C6B66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B3ECEB4-0E24-4040-9429-BA63333C6B66}.Release|Any CPU.Build.0 = Release|Any CPU + {1C052B5D-CA36-4AF0-89B3-C563F62D1B6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C052B5D-CA36-4AF0-89B3-C563F62D1B6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C052B5D-CA36-4AF0-89B3-C563F62D1B6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C052B5D-CA36-4AF0-89B3-C563F62D1B6C}.Release|Any CPU.Build.0 = Release|Any CPU + {DB49720B-C0E5-42BB-A9C6-77D3DC065D09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB49720B-C0E5-42BB-A9C6-77D3DC065D09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB49720B-C0E5-42BB-A9C6-77D3DC065D09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB49720B-C0E5-42BB-A9C6-77D3DC065D09}.Release|Any CPU.Build.0 = Release|Any CPU + {BFF0A04C-5057-4A05-9122-CBF98C25DA33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BFF0A04C-5057-4A05-9122-CBF98C25DA33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFF0A04C-5057-4A05-9122-CBF98C25DA33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BFF0A04C-5057-4A05-9122-CBF98C25DA33}.Release|Any CPU.Build.0 = Release|Any CPU + {49091FD7-4936-42B3-9189-C18796B84269}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49091FD7-4936-42B3-9189-C18796B84269}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49091FD7-4936-42B3-9189-C18796B84269}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49091FD7-4936-42B3-9189-C18796B84269}.Release|Any CPU.Build.0 = Release|Any CPU + {5D749882-6633-415A-9DC3-5C7C208B29CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D749882-6633-415A-9DC3-5C7C208B29CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D749882-6633-415A-9DC3-5C7C208B29CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D749882-6633-415A-9DC3-5C7C208B29CB}.Release|Any CPU.Build.0 = Release|Any CPU + {BFC359F7-8FE7-4ABC-B192-8E9DE43460A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BFC359F7-8FE7-4ABC-B192-8E9DE43460A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFC359F7-8FE7-4ABC-B192-8E9DE43460A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BFC359F7-8FE7-4ABC-B192-8E9DE43460A5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {9CC5413C-0C78-4D2C-9945-97D4F28C9A40} + EndGlobalSection +EndGlobal diff --git a/docs/event-hubs.md b/docs/event-hubs.md new file mode 100644 index 0000000..a18522f --- /dev/null +++ b/docs/event-hubs.md @@ -0,0 +1,268 @@ +

Azure Event Hubs

+ +

This library provides in-memory SDK for Azure Event Hubs which can be used as a drop-in replacement for the official +Azure.Messaging.EventHubs SDK in your tests.

+ +

+ Recommended Usage | + Fault Injection | + Features | + Available APIs +

+ +> [!TIP] +> See the whole [In-Memory Azure Test SDK](../README.md) suite if you are interested in other Azure services. + +## Recommended Usage + +To get started, add `Spotflow.InMemory.Azure.EventHubs` package to your project. + +```shell +dotnet add Spotflow.InMemory.Azure.EventHubs +``` + +Create non-static factory class for creating the real Azure SDK clients. Relevant methods should be virtual to allow overriding as well as there should be a protected parameterless constructor for testing purposes. + +```cs +class AzureClientFactory(TokenCredential tokenCredential) +{ + protected AzureClientFactory(): this(null!) {} // Testing-purposes only + + public virtual EventHubProducerClient CreateProducerClient(string fullyQualifiedNamespace, string eventHubName) + { + return new EventHubProducerClient(fullyQualifiedNamespace, eventHubName, tokenCredential); + } +} +``` + +Use this class to obtain EventHub clients in the tested code: + +```cs +class ExampleService(AzureClientFactory clientFactory, string fullyQualifiedNamespace, string eventHubName) +{ + private readonly EventHubProducerClient _client = clientFactory.CreateProducerClient(fullyQualifiedNamespace, eventHubName); + + public async Task SendEventAsync(BinaryData payload) + { + await _client.SendAsync(new EventData(payload)); + } +} +``` + +Create `InMemoryAzureClientFactory` by inheriting `AzureClientFactory` and override relevant factory methods to return in-memory clients: + +```cs +class InMemoryAzureClientFactory(InMemoryEventHubProvider provider): AzureClientFactory +{ + public override EventHubProducerClient CreateProducerClient(string fullyQualifiedNamespace, string eventHubName) + { + return new InMemoryEventHubProducerClient(fullyQualifiedNamespace, eventHubName, NoOpTokenCredential.Instance, provider); + } +} +``` + +When testing, it is now enough to initialize `InMemoryEventHubProvider` and inject `InMemoryAzureClientFactory` to the tested code (e.g. via Dependency Injection): + +```cs +var provider = new InMemoryEventHubProvider(); +var eventHub = provider.AddNamespace().AddEventHub("test-event-hub", numberOfPartitions: 4); + +var services = new ServiceCollection(); + +services.AddSingleton(); +services.AddSingleton(provider); +services.AddSingleton(); + +var exampleService = services.BuildServiceProvider().GetRequiredService(); + +var payload = BinaryData.FromString("test-data"); + +await exampleService.SendEventAsync(eventHub.Namespace.Hostname, eventHub.Name, payload); + +var receiver = InMemoryPartitionReceiver.FromEventHub(partitionId: "0", eventHub); + +var batch = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + +batch.Should().ContainSingle(e => e.EventBody.ToString() == "test-data"); +``` + +## Fault Injection + +Fault injections let you simulate transient and persistent faults in Azure Event Hub. +Thanks to that you can test how your application behaves in case of Azure outages, network issues, timeouts, etc. + +To inject a fault, you need to use the [concept of hooks](./hooks.md) - functions that are called before or after the actual operation is executed. +A new hook can be registered by calling the `AddHook` method on the `InMemoryEventHubProvider` instance. +You can build fault hook by calling the `Faults` method on the hook context and then calling the appropriate method, e.g. `ServiceIsBusy`: + +For overview of available hooks, please see the [Hooks](#hooks) section. + +```cs +var provider = new InMemoryEventHubProvider(); +var hook = provider.AddHook(hookBuilder => hookBuilder.Before(ctx => ctx.Faults().ServiceIsBusy())); +``` + +The `AddHook` method gives you a builder that lets you define which operations the hook should apply to. +In the example above, the hook affects all Event Hub operations. +However, you can limit it to specific operations, like `Send`, or target specific scopes, such as operation on Event Hub called `my-eventhub`: + +```cs +var hook = provider.AddHook(hookBuilder => hookBuilder.ForProducer(eventHubName: "my-event-hub") + .BeforeSend(ctx => ctx.Faults().ServiceIsBusy())); +``` + +You can control when the hook should execute via the `IHookRegistration` interface returned by the `AddHook` method. +By default, the hook is enabled, but you can disable it by calling the `Disable` method. +To simulate temporary outages, use the `DisableAfter` method to limit the number of fault occurrences. + +See a full example of fault injection below: + +```cs +var provider = new InMemoryEventHubProvider(); + +var hook = provider.AddHook(hook => hook.Before(ctx => ctx.Faults().ServiceIsBusy())); + +var eventHub = provider.AddNamespace("test-ns").AddEventHub("test-eh", 1); + +var producerClient = InMemoryEventHubProducerClient.FromEventHub(eventHub); + +var act = () => producerClient.SendAsync([new EventData()]); + +await act.Should().ThrowAsync().WithMessage("Event hub 'test-eh' in namespace 'test-ns' is busy. (test-eh). *"); + +hook.Disable(); + +await act.Should().NotThrowAsync(); +``` + +## Delay Simulation + +Delay simulation is currently not supported for Azure Event Hub. + +However, [hooks](hooks.md) can be used to simulate custom delays. For overview of available hooks, please see the [Hooks](#hooks) section. + +## Supported APIs and features + +### SDK clients & methods + +Following SDK clients and their method groups and properties are supported. + +Async versions of these methods are also supported. All supported async methods starts with [Task.Yield()](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.yield) to force the method to complete asynchronously. + +Other methods and properties are not supported and will throw `NotSupportedException`. + +Clients are thread-safe. + +#### `InMemoryEventHubProducerClient : EventHubProducerClient` + +| Property | Note | +| ------------------------- | ---- | +| `EventHubName` | | +| `FullyQualifiedNamespace` | | +| `Identifier` | | +| `IsClosed` | | + +| Method group | +| ----------------------------- | +| `CloseAsync` | +| `CreateBatchAsync` | +| `DisposeAsync` | +| `CreateBatchAsync` | +| `GetEventHubPropertiesAsync` | +| `GetPartitionIdsAsync` | +| `GetPartitionPropertiesAsync` | + +| Constructors & factory methods | Note | +| ---------------------------------------------------------------------------------------- | ---- | +| `(string connectionString)` | | +| `(string fullyQualifiedNamespace, string eventHubName, TokenCredential tokenCredential)` | | +| `(EventHubConnection connection)` | | +| `FromEventHub(InMemoryEventHub eventHub)` | | +| `FromNamespace(InMemoryEventHubNamespace eventHubNamespace, string eventHubName)` | | + +#### `InMemoryEventHubConsumerClient : EventHubConsumerClient` + +| Property | Note | +| ------------------------- | ---- | +| `ConsumerGroup` | | +| `EventHubName` | | +| `FullyQualifiedNamespace` | | +| `Identifier` | | +| `IsClosed` | | + +| Method group | +| ----------------------------- | +| `CloseAsync` | +| `DisposeAsync` | +| `GetEventHubPropertiesAsync` | +| `GetPartitionIdsAsync` | +| `GetPartitionPropertiesAsync` | + +| Constructors & factory methods | Note | +| -------------------------------------------------------------------------------------------------------------- | ---- | +| `(string consumerGroup, string connectionString)` | | +| `(string consumerGroup, string fullyQualifiedNamespace, string eventHubName, TokenCredential tokenCredential)` | | +| `(string consumerGroup, EventHubConnection connection)` | | +| `FromEventHub(string consumerGroup, InMemoryEventHub eventHub)` | | +| `FromNamespace(string consumerGroup, InMemoryEventHubNamespace eventHubNamespace, string eventHubName)` | | + +#### `InMemoryPartitionReceiver : PartitionReceiver` + +| Property | Note | +| ------------------------- | ---- | +| `ConsumerGroup` | | +| `EventHubName` | | +| `FullyQualifiedNamespace` | | +| `Identifier` | | +| `InitialPosition` | | +| `IsClosed` | | +| `PartitionId` | | + +| Method group | +| --------------------------------- | +| `GetPartitionPropertiesAsync` | +| `ReadLastEnqueuedEventProperties` | +| `ReceiveBatchAsync` | +| `DisposeAsync` | +| `CloseAsync` | + +| Constructors & factory methods | Note | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `(string consumerGroup, string partitionId, EventPosition eventPosition, string connectionString)` | No credentials are validated. | +| `(string consumerGroup, string partitionId, EventPosition eventPosition, string connectionString, string eventHubName)` | No credentials are validated. | +| `(string consumerGroup, string partitionId, EventPosition eventPosition, string fullyQualifiedNamespace, string eventHubName, TokenCredential credential)` | No credentials are validated. | +| `(string consumerGroup, string partitionId, EventPosition eventPosition, EventHubConnection connection)` | No credentials are validated. | +| `FromNamespace(string consumerGroup, string partitionId, EventPosition startingPosition, InMemoryEventHubNamespace eventHubNamespace, string eventHubName)` | No credentials are validated. | +| `FromEventHub(string consumerGroup, string partitionId, EventPosition startingPosition, InMemoryEventHub eventHub)` | No credentials are validated. | +| `FromEventHub(string partitionId, EventPosition startingPosition, InMemoryEventHub eventHub)` | Default consumer group is used. No credentials are validated. | + +### Features + +For the supported methods enumerated above, not all features are fully implemented. + +If the given feature is not supported, than the method will just ignore any parameters related to that feature. + +| Feature | Is Supported | +| ---------------------------------------------------------------------------- | ------------ | +| Batches | ✅ | +| Event System Property - Content Type | ✅ | +| Event System Property - Correlation Id | ✅ | +| Event System Property - Message Id | ✅ | +| Offset-based starting positions | ❌ | +| Partition keys | ✅ | +| Properties - Event Hub | ✅ | +| Properties - Partition | ✅ | +| Randomization of initial sequence numbers for event hub partitions | ✅ | +| Sequence number based starting positions (including `Earliest` and `Latest`) | ✅ | + +## Hooks + +Following hooks are supported in both `Before` and `After` variants: + +- All `Event Hub` operations + - All `Producer` operations + - `Send` + - All `Consumer` operations + - `ReceiveBatch` + +For details about concept of hooks, please see the [Hooks](./hooks.md) page. diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000..c4a0b46 --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,55 @@ +# Hooks + +Hooks are a way to extend the functionality of the core library. Hooks enable to inject some custom behavior before and after client operations. + +User can register before and after hooks by calling `AddHook` method on resource providers such as [`InMemoryStorageProvider`](./storage.md). When adding a hook, user targets one or more client operations to which the hook should be applied and a function that should be executed before or after the operation. This function receives a context which contains information about the currently executed operation. + +```csharp +var provider = new InMemoryStorageProvider(); + +// Register hook + +// Before all storage operations: + +provider.AddHook(hook => hook.Before(context => { ... })); + +// Before creating any container in any storage account: + +provider.AddHook(hook => hook.ForBlobService().ForContainerOperations().BeforeCreate(context => { ... })); + +// Before creating specific container in any storage account: + +provider.AddHook(hook => hook + .ForBlobService() + .ForContainerOperations(containerName: "container") + .BeforeCreate(context => { ... })); + +// After creating any container or uploading any blob within a specific storage account: + +provider.AddHook(hook => hook + .ForBlobService(storageAccountName: "account") + .ForContainerOperations() + .After(context => { ... }), containerOperations: ContainerOperations.Create, blobOperations: BlobOperations.Upload); +``` + +## Contexts + +The contexts passed to the hook functions are organized into an inheritance hierarchy. For example, after hook for blob upload receives `BlobUploadAfterHookContext` type which inherits from the `BlobAfterHookContext` -> `BlobServiceAfterHookContext` -> `StorageAfterHookContext` hierarchy. + +This enabled the hook functions to access specific information about the currently executed operation even if the hook targets a more general group of operations (e.g. all storage operations). This can be done e.g via pattern matching: + +```csharp +provider.AddHook(hook => hook.Before(hookFunc)); + +static Task hookFunc(StorageAfterHookContext context) +{ + if(context is BlobUploadAfterHookContext blobUpload) + { + ... + } + else + { + ... + } +} +``` diff --git a/docs/images/intro.excalidraw.svg b/docs/images/intro.excalidraw.svg new file mode 100644 index 0000000..e72bbd5 --- /dev/null +++ b/docs/images/intro.excalidraw.svg @@ -0,0 +1,21 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daVPb2Fx1MDAxMv2eX0ExX8eauy9T9eqVQ1x1MDAwMjjsMVx1MDAxOJKpKUq2hS0sS0aWMTCV//5aJliyLHlcdJJcdTAwMTFTj1SxaLFaV336nL7dV/nnw9bWdvA4sLb/3Nq2XHUwMDFlWqZjt31zvP17uP3e8oe258IuMvl76I381uTIblx1MDAxMFxmhn/+8Uff9HtWMHDMlmXc28OR6Vxmg1Hb9oyW1//DXHUwMDBlrP7wv+H3Y7Nv/Wfg9duBb0RcdTAwMTepWG078Pzna1mO1bfcYFxin/5cdTAwMTf8vbX1z+R7zDrfalx1MDAwNabbcazJXHSTXZGBkrHk1mPPnVx1MDAxOKtcYpJcZmNcdTAwMTRcdTAwMWRgXHUwMDBmP8HlXHUwMDAyq1xye2/AZCvaXHUwMDEzbtpcdTAwMWU93u11zfH46WFcXGk89PZcdTAwMWGVq9FedNVcdTAwMWLbcerBozOxaujBzUT7hoHv9axLu1x1MDAxZHRhL05sn57V9oLw8tPdvjfqdF1rXHUwMDE43j6abvVcdTAwMDZmy1x1MDAwZVx1MDAxZcNcdTAwMGZC0dbnMYhcdTAwMWb3XHUwMDAwf3FCXHJGNCacSYpcdTAwMDRV073h+Vx1MDAxNcq0QVx1MDAxNGKUY1xuP6VKWLbjOfAkwLLfsFx1MDAxNf6LTGuarV5cdTAwMDfsc9vRMZa2WtZNdMz45X6JUIYmXGJcdTAwMTGOXHUwMDEwJ0SS6SFdy+50g9CVMDNcdTAwMTDWQjLFONNYRpZYkydcIiiXhGFEpzvCy1x1MDAwZmrtiW/8XHUwMDFkPVx1MDAwNlx1MDAxZryqXHUwMDE2nuGOXHUwMDFjJz6UbvvnUL74UORF9OeWXHUwMDFm0f2Fx39Oel/cXHUwMDAzZ7wwsFx1MDAxZYLpjcd8pod0c/fBcvaa3s7w4PLwqfY0vN2eXHUwMDFl9+Pnb5H5o0HbfHZBLLGGh0K1RCzyXHUwMDE4x3Z7yXtzvFYv8tpcdTAwMGaxXHUwMDFimUPLjJ0xoFxiTrKAglx0hlx1MDAwN0KUQisjJf2u80RK4qzXXHUwMDAzXHUwMDA1XHUwMDBioVxyjiTlVCBcbohJXHUwMDAyheVcdTAwMDeUwDfd4cD0wbVSwIKloVx1MDAxOJZaKMlTcEKTwMCEXHUwMDAyrlx1MDAxOVc5IGNmx1x1MDAxY1x1MDAwNPL00sgqz1xy6vZT+ERcYprZumv2befxhWae3Th0X1x1MDAxOMFT32uPWlx1MDAwMXjpZPPM/qpjd9xcdDNMhizu1oFcciQz3Vx1MDAxZniDaG9cdTAwMGIuaNqu5ddWifVcdTAwMWV8tu2azvlcdTAwMTJ7YFx1MDAxY6z9l0eHXHLMY24xtMK94Xa1XHUwMDEwslx1MDAwYlx0Timc3DolOEU0h8hcdTAwMWK56jLYXHUwMDBlmmNRuTtcdTAwMWScOk7QXHUwMDFh7e1cdTAwMWbfoFGj3LDlXGZcdTAwMWKEYFx1MDAwNbxAXHUwMDAwuyhcdTAwMDFbXCKwwbXEkiqMqSQxts9cdTAwMWS3XHUwMDAyXHUwMDE5mlx0RqVcdTAwMTCIXHUwMDAwWc1jl81hV2pGMEU68ov3QGr8lprf6iNxNzxcdTAwMTaB6ezcM+vjaYzUfk//2OeT2e6Bazkn1reT+sVu48pl15f797NXebm+6fveuIRkiVx1MDAxMc6UleBiXG50XG5hq7Nl+nD+XHUwMDAy7MjmYMeFITjXQlxiLplIqErCN4c6ilxmYGPQt6tQJWOSU8Tz0JDvhSkvhpa/ZVx1MDAwZVx1MDAwNlstr21lXHUwMDEwZVx1MDAwYsy2/Fx1MDAwNUzZt9vtOP8kyHJcdG8kyTLDpMK5UlOa3DrVuFx1MDAxMIg5XCKgcldGbVxy37X6ncPmyUmtQars62mld9spN2ohMFx1MDAxOYgqXHUwMDAwLVxuXHUwMDA1Y8RQz6jVXGIkLpVIXG7IXHUwMDE0UdKs/DBLtTIwZIFcdTAwMDLAXGJwiFx0lCl04V5cZlx1MDAxYSasclwibbWeV71cXGiONIj1d0WdVXfgXHUwMDA31lxie/tiZ1BtnN6Per5XXHUwMDFl6iRxNFx1MDAxNkOdSGWCkHOIjFiK1akzfThLXHJCLamBOMFaXHUwMDExxsGLWVx1MDAwMoXArDjOnbIwXHUwMDFjYi1cZpCeXHUwMDEy0VT6pFx1MDAwNFxmiX+JJFx1MDAwNpXSnNJYzHwjNl3Ha1PZVM1sXcCmXHUwMDFmw1x1MDAwZoNvzVx1MDAxZMeeXHUwMDE501xc+XRcdLUk+TTTqJVcdTAwMTiV8F9nVIxcdTAwMTFPbo6mjSA6M7xcdTAwMDaY+yf3TUdV9E3r8qJ/e3zBhX2jyp1+Yk6YISDJhlx1MDAxY06GdFx1MDAxNlxyR1x1MDAwMfnnb4SopiVyyj0xXHUwMDEzXHUwMDE01LtcdTAwMTY54HeTyefVQbDXOKnfPl7wx/uPzrj/vVtdlUFcdTAwMWba7bvPzl77qNptjf3K+Tm6Oui+M1x1MDAwNiVMZmFOcvBcdTAwMTDAnVhcdTAwMTlz6aNZalx1MDAwMsVcXCpcdTAwMDPQhkCvU1wiUlx1MDAxOHRj2ScnXHUwMDA2YVx1MDAwMuLcajO1XHUwMDA0XHUwMDEzzDScwV6PuDdmzJXzz+rTyC8q71xcQlx1MDAxOEmeTJiSR775XHUwMDFjJNJQKrKJUSCFOOi/1SuPi4V/KZlcdTAwMTGczICjXHUwMDEw01xuXHUwMDEyynmQMlxm2jM3YsysPIpcYoRTYEaGTplQMamVLiaXJEUx4TXVwzb7dtw73z2sXHUwMDBmm61gTK97xTJW7CGZfvDRdtu225m9659cdTAwMTX7VWaGJtGjNVxuh6CCXGbEXHUwMDA0x1hcdTAwMTCpkdBSxEr34fMwXHUwMDA3XHUwMDEzlGqKXHUwMDE1wsBxSFwiQeeH1nLby61arK9jVqGwJK0hJVx1MDAxM1x1MDAxNHRcdTAwMTLoPCbmbGJgk1x1MDAxMmA3XHUwMDA1WCMu5m1yzGGw4/X7dljcP/VsN0hcdTAwMGXyZDSrYSzpWuace8E9xfdcdTAwMDEl2Vx0RT5cYj901pP+ivlcdTAwMGKKO1x1MDAwZpr+/vfvqUfHMJM4/kP85/qZf2ZAZFxuKXjua7RipLt+uVx1MDAwM1wi5NNcdTAwMTRcdTAwMGLFhFCpqoXkmSlkXHUwMDA1RMZccvBmhsRqXHUwMDEz5kpw8GqFI0H5r1x1MDAxNyxcdTAwMTdDa1iQXllC4ynz5MNNyVx1MDAxNZ5Z0MJcXFx1MDAxMSUl46snXHUwMDE1i7OsUqJcdTAwMTND+CmBXiE8Mitbr1xiXHSgIKyQvL0wtXJi68+Bq/fsM99/7KH+o1s7oG+jVpLsulxcMSzONGZcdTAwMTXDclx1MDAxOcNcZk7gXHUwMDE5wlFaKYK5eO+SIea2ieNfJVx1MDAxOXRmTJJcXIBcdTAwMWWja6RQ6e5X7phEMVxyq2BMSZpcdTAwMWGTNiRcdTAwMTmYwXh6iSClXHUwMDE5TVx1MDAwYs2ATVhcdTAwMGWp1HtRXGY7puNcdTAwMTQlXHUwMDE5llBpUjIkTCm+/YxmzkVcdTAwMTKMOceSkdUx2iPu1WGTXHUwMDFj74w94vYvXHUwMDBm8eNNXZe9wVx1MDAxYWtcdTAwMDP0NFxuq9VcdTAwMWO8LtFcbiNcdTAwMTWoXG6JMCY0LG3qV1XVX9dgLfXiXHUwMDA2a1x1MDAwMmcyTXRcdTAwMGVaf4PlgMdPvdFcdTAwMTH+XmNXT09H54PPjqza12vJXG7GuI4lp1x1MDAwNU3bS5LNZlx1MDAxY1x1MDAxMaIlX4PN0u+63GxcdTAwMDZqyNCCUVx1MDAxOG3FSaL5RNJcdTAwMWNxsnDOXkFcdTAwMDacXfRO6Vx1MDAxOZNcdTAwMDSyXHUwMDFm/aZV7rVd9HWEdm5cclx1MDAwM9DDk23ptPaa1uolUT5JaanGXHUwMDE03ytcdTAwMTbN9M3BVSuMOF2H2E5u9MGwgyqDfsP6XCIvOndP1S8lXHUwMDE3n5xKg1FgXHUwMDEyyE/AXHUwMDA1Y4B8Pt/gUoRsXHUwMDAz2lx1MDAxNEslS9ZXjYVCkIKxXHUwMDE4XHUwMDExvlx1MDAwNzJ7ap7dnnf36t8v6r2ng5uznYaoilVr2zu3tD9+cN1PtYuzfXxoXHUwMDFlOqQ3nr3Kr9e2XHUwMDE5pTS2wKSw7jCZzZJaUYH4XHUwMDFh/STpo/lcdTAwMGKo22RfNTGUXHUwMDEwSiqmNGNydjlcdTAwMDPZIOrW66vGQNpA629Z1554KFvDQ1/HkZvoq15CXHUwMDFipemr1pmghaSDKCzY6m3VqjVqdfv3mjZqn1x1MDAxY+407YOWe1Ru0CpIviBz4lx1MDAxMlx1MDAxMIlcdTAwMTiNhMNE2WLIXHUwMDAwMVxuXHUwMDFiqylAhGBW3NLBXFxcdTAwMWGrOaFIXHUwMDBiVczscmHM2Wn5Z8NcdTAwMTHvVN3ve/Tm7ohcdTAwMWM36N1cdTAwMDaYc+HnNp5uXHUwMDAzPbJcdTAwMDeO0917sL82R61dVsmVkdeJd7/KyJmVIYGUXHUwMDA2osKrXHUwMDE3htKfUqnBrYU2iKBcdTAwMDBhJCWmfLbBXHUwMDEzc0PHXHUwMDE5OU6NZevWXHUwMDA2epaCXHUwMDEzQd4/S5esX3tcdGeVqF+biOwlUEojXHUwMDA2ae1cdTAwMWHrhb+7nYPH8U6lg4LvfvB5l7ri6y+tvtjgLFx1MDAxNJPMXHUwMDAwLpaMKEBcdTAwMTOWs/NQNCGw6atWX/zWRs2bm7QyrySzOW2EiCmgeVxugFx0WCRkXHUwMDFlMnuTM7SD23vZaVX3XHUwMDAzq3f8VHVP9lljZVxu/dJccq57u1x1MDAwN/uX9klwMTy9XHUwMDFl4eN+bkueNkShjGW/W4ND6sTVOutcdTAwMGXTh7PUXHUwMDFjiplmYaVcdTAwMTJBSstcdKaJlYckTDU3k9ZcdTAwMTJcdTAwMWG+XHUwMDBlR0rKVmzZhsRcdTAwMTbksoy/R+i9kubKqW3NPbL6nv9YXHUwMDBmPN/sWKe+d2+34/yYK3kuIZEkeS41rtA+KSqzVy9cdTAwMTIgXHUwMDEzrcRcdTAwMWFcdTAwMGL/XHUwMDE351x1MDAxZKXkT1x1MDAxOeaPXHUwMDE4XHUwMDEyRCyoXHUwMDE2STlMcZ5IfmVXN1x0XHUwMDE3WlDFXHUwMDBiqWdcdTAwMTbWJlXfaV99/jbosNFcdTAwMWQ67Vx1MDAxZY7Pa6Sxuy6rsZj6z7+pe/G01NZcdTAwMWI1dS9cdTAwMTbhW1uZTd08toJ16/9d3euJXHUwMDFikp1PiPClPJysMfeX7vuljoeg3sCfQFFwxcNcdTAwMDWViYBYXHUwMDExXGL0XHUwMDA2lUzrsEeDwLdCXCLimm3dXHUwMDE0XHUwMDBiTHW8yv6vXHUwMDE3NVx1MDAwNbZ1L6HxN2zrpiqzPSt8XHUwMDA3XGJEQLK6WlmcipVcdTAwMTOdRIi3lyvxxsxIrlx1MDAxOEzR0DTOXHUwMDEwg1x1MDAwMIJiyH3JPcIsRcBR70vB7H5TXHUwMDBmta/V9m4tXHUwMDEwziDwrq+uOX5cdTAwMWJcdTAwMDWzfqP34mxkVkXMSlx1MDAxYk557LB/a6M3YvHjKylenJewyJwzgVx1MDAxY1x1MDAxZYWNjKvrinSPLHXkXCJcYpRcdTAwMDNcdTAwMDIkgHBIiVxcXHUwMDFi01x1MDAxNev0fsPj5zqXlpv3XCIqiuz8XkK3b935jeNSc/5cdTAwMWSI4JtoXHUwMDFkceG0nlx1MDAwZVxc01x1MDAxZFx1MDAxZJFr1Sfk9mQ8XHUwMDFjl/w9XHUwMDE0SilcdTAwMThXXHUwMDFhrssggojYS1x1MDAwNcPzOSBcdTAwMDdcdTAwMTFFXHUwMDA0VuHaVlx1MDAxZFx1MDAwM3CelVx1MDAwNMqxoTSRSsjwKiRlYkTOXHUwMDBiXHUwMDBiJuFcdTAwMWNN89D/m3yX9n5cdTAwMWRcdTAwMWarO6Vq7Orsrns+qt18Oi5HLZ5hSFx1MDAwNNdcYju/RIlYykw5XHUwMDBmXHUwMDE0XGZUIdVcdTAwMWGzj+nDWWrIaURccjUps6e9O01yQ8UhV1xcLZ6GnXiCZVHjXG61eGBsjTWJvVP9LbrKcbxcdTAwMWKp6Fr8y8z9ZmryS1xiJausUGhtPjNPj2eISWBTwonWdPUmm8WRrpyrqDAoXHUwMDA2I5xmXHUwMDA1VFx0XHTqPtFuTqmh49Au6P+pgFxiosFcYiRcdTAwMDSHXHUwMDExJyn9czFx+Vx1MDAwMmWkMGBcdOVcdTAwMDHlXHJm6afVu0bduXR3XHUwMDFi3oW++HJaaVln9XXXTcVnTPKvMyyG8NZMnSGsLiOu4Fx1MDAxZlJcXKO0XHR9jmJfRM1ccmy+VVx1MDAwNmwgcGomwvq+ZFLS+TfHXHUwMDAwSzDNyWSlgtRcdTAwMDLpOZPe2exAXHUwMDA1S5ZcdTAwMGWg8ItcYmOGXHUwMDE05VxuXHUwMDFml4HHyV4ujfhcdTAwMTNFNPq8XHUwMDBm8Z/ryiyavfJHUnAurtZY+ZNcdTAwMGWzclx1MDAwN2NcYmaGgrRGsrRXYWDIOGQ8XHUwMDE2v65FKitcdTAwMTZzYrD0/1x1MDAwMVx1MDAwNStjLlxiM8rDPvO3lVN5zD3EmmmXyqmu5dtBUdNcdTAwMGZLVMS8fkpas9pcZlx1MDAwNHuG6IefQWDbXHUwMDFjXGbqXHUwMDAxjOs0XHUwMDA0w4O02z9cdTAwMDcnXHUwMDFhy+1721x1MDAxYX9McaebyVfIY1x1MDAxM9iHXHUwMDAwsybM+uPDj/9cdTAwMDH6V8/5In0= + + + + + Production User app codeBlockBlobClientAzureUsesCallsTesting User app codeBlockBlobClientInMemoryStorageProviderUsesCallsInMemoryBlockBlobClientInherits \ No newline at end of file diff --git a/docs/key-vault.md b/docs/key-vault.md new file mode 100644 index 0000000..864718b --- /dev/null +++ b/docs/key-vault.md @@ -0,0 +1,153 @@ +

Azure Key Vault

+ +

This library provides in-memory SDK for Azure Key Vault which can be used as a drop-in replacement for the official +`Azure.Security.KeyVault.*` SDKs in your tests.

+ +

+ Recommended Usage | + Features | + Available APIs +

+ +> [!TIP] +> See the whole [In-Memory Azure Test SDK](../README.md) suite if you are interested in other Azure services. + +## Recommended Usage + +To get started, add `Spotflow.InMemory.Azure.KeyVault` package to your project. + +```shell +dotnet add Spotflow.InMemory.Azure.KeyVault +``` + +Create non-static factory class for creating the real Azure SDK clients. Relevant methods should be virtual to allow overriding as well as there should be a protected parameterless constructor for testing purposes. + +```cs +class AzureClientFactory(TokenCredential tokenCredential) +{ + protected AzureClientFactory(): this(null!) {} // Testing-purposes only + + public virtual SecretClient CreateSecretClient(Uri vaultUri) => new(vaultUri, tokenCredential); +} +``` + +Use this class to obtain Key Vault clients in the tested code: + +```cs +class ExampleService(AzureClientFactory clientFactory, Uri vaultUri) +{ + private readonly SecretClient _client = clientFactory.CreateSecretClient(vaultUri); + + public async Task GetSecretAsync(string secretName) + { + var response = await _client.GetSecretAsync(secretName); + return response.Value.Value; + } +} +``` + +Create `InMemoryAzureClientFactory` by inheriting `AzureClientFactory` and override relevant factory methods to return in-memory clients: + +```cs +class InMemoryAzureClientFactory(InMemoryEventHubProvider provider): AzureClientFactory +{ + public override SecretClient CreateSecretClient(Uri vaultUri) + { + return new InMemorySecretClient(vaultUri, provider); + } +} +``` + +When testing, it is now enough to initialize `InMemoryKeyVaultProvider` and inject `InMemoryAzureClientFactory` to the tested code (e.g. via Dependency Injection): + +```cs +var provider = new InMemoryKeyVaultProvider(); +var vault = provider.AddVault(); + +var services = new ServiceCollection(); + +services.AddSingleton(); +services.AddSingleton(provider); +services.AddSingleton(); + +var exampleService = services.BuildServiceProvider().GetRequiredService(); + +var secret = exampleService.GetSecretAsync("my-secret"); +``` + +## Fault Injection + +Fault injection is currently not supported for Azure Key Vault. + +However, [hooks](hooks.md) can be used to simulate custom faults. For overview of available hooks, please see the [Hooks](#hooks) section. + +## Delay Simulation + +Delay simulation is currently not supported for Azure Key Vault. + +However, [hooks](hooks.md) can be used to simulate custom delays. For overview of available hooks, please see the [Hooks](#hooks) section. + +## Supported APIs and features + +### SDK clients & methods + +Following SDK clients and their method groups and properties are supported. + +Async versions of these methods are also supported. All supported async methods are guaranteed executed truly asynchronously by using [Task.Yield()](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.yield) or [ConfigureAwaitOptions.ForceYielding](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.configureawaitoptions). + +Other methods and properties are not supported and will throw `NotSupportedException`. + +Clients are thread-safe. + +#### `InMemorySecretClient : SecretClient` + +| Property | Note | +| ---------- | ---- | +| `VaultUri` | | + +| Method group | +| ------------------------------- | +| `GetSecret` | +| `SetSecret` | +| `GetPropertiesOfSecrets` | +| `GetPropertiesOfSecretVersions` | +| `StartDeleteSecret` | +| `UpdateSecretProperties` | + +| Constructors & factory methods | Note | +| ----------------------------------- | ---- | +| `(Uri vaultUri)` | | +| `FromVault(InMemoryKeyVault vault)` | | + +### Features + +For the supported methods enumerated above, not all features are fully implemented. + +If the given feature is not supported, than the method will just ignore any parameters related to that feature. + +| Feature | Is Supported | +| ---------------------------------------- | ------------ | +| Secrets - Properties - `ContentType` | ✅ | +| Secrets - Properties - `CreatedOn` | ✅ | +| Secrets - Properties - `Enabled` | ✅ | +| Secrets - Properties - `ExpiresOn` | ✅ | +| Secrets - Properties - `NotBefore` | ✅ | +| Secrets - Properties - `RecoverableDays` | ❌ | +| Secrets - Properties - `RecoveryLevel` | ❌ | +| Secrets - Properties - `Tags` | ✅ | +| Secrets - Properties - `UpdatedOn` | ✅ | +| Secrets - Purge | ❌ | +| Secrets - Recovery | ❌ | +| Secrets - Soft-delete | ✅ | +| Secrets - Versioning | ✅ | + +## Hooks + +Following hooks are supported in both `Before` and `After` variants: + +- All `Key Vault` operations + - All `Secret` operations + - `GetSecret` + - `SetSecret` + +For details about concept of hooks, please see the [Hooks](./hooks.md) page. diff --git a/docs/service-bus.md b/docs/service-bus.md new file mode 100644 index 0000000..e581a15 --- /dev/null +++ b/docs/service-bus.md @@ -0,0 +1,271 @@ +

Azure Service Bus

+ +

This library provides in-memory SDK for Azure Event Hubs which can be used as a drop-in replacement for the official +Azure.Messaging.ServiceBus in your tests.

+ +

+ Recommended Usage | + Fault Injection | + Features | + Available APIs | + Fluent Assertions +

+ +> [!TIP] +> See the whole [In-Memory Azure Test SDK](../README.md) suite if you are interested in other Azure services. + +## Recommended Usage + +To get started, add `Spotflow.InMemory.Azure.EventHubs` package to your project. + +```shell +dotnet add Spotflow.InMemory.Azure.ServiceBus +``` + +Create non-static factory class for creating the real Azure SDK clients. Relevant methods should be virtual to allow overriding as well as there should be a protected parameterless constructor for testing purposes. + +```cs +class AzureClientFactory(TokenCredential tokenCredential) +{ + protected AzureClientFactory(): this(null!) {} // Testing-purposes only + + public virtual ServiceBusSender CreateServiceBusSender(string fullyQualifiedNamespace, string queueOrTopicName) + { + return new ServiceBusClient(fullyQualifiedNamespace, tokenCredential).CreateSender(queueOrTopicName); + } +} +``` + +Use this class to obtain ServiceBus clients in the tested code: + +```cs +class ExampleService(AzureClientFactory clientFactory, string fullyQualifiedNamespace, string queueName) +{ + private readonly ServiceBusSender _client = clientFactory.CreateServiceBusSender(fullyQualifiedNamespace, queueName); + + public async Task SendMessageAsync(BinaryData payload) + { + await _client.SendMessageAsync(new ServiceBusMessage(payload)); + } +} +``` + +Create `InMemoryAzureClientFactory` by inheriting `AzureClientFactory` and override relevant factory methods to return in-memory clients: + +```cs +class InMemoryAzureClientFactory(InMemoryServiceBusProvider provider) : AzureClientFactory +{ + public override ServiceBusSender CreateServiceBusSender(string fullyQualifiedNamespace, string queueOrTopicName) + { + return new InMemoryServiceBusClient(fullyQualifiedNamespace, NoOpTokenCredential.Instance, provider).CreateSender(queueOrTopicName); + } +} +``` + +When testing, it is now enough to initialize `InMemoryServiceBusProvider` and inject `InMemoryAzureClientFactory` to the tested code (e.g. via Dependency Injection): + +```csharp +var provider = new InMemoryServiceBusProvider(); +var queue = serviceBusProvider.AddNamespace().AddQueue("my-queue"); + +var services = new ServiceCollection(); + +services.AddSingleton(); +services.AddSingleton(provider); +services.AddSingleton(); + +var exampleService = services.BuildServiceProvider().GetRequiredService(); + +var payload = BinaryData.FromString("test-data"); + +await exampleService.SendMessageAsync(queue.Namespace.FullyQualifiedNamespace, queue.QueueName, payload); + +await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace, provider); + +await using var receiver = client.CreateReceiver(queue.QueueName); + +var message = await receiver.ReceiveMessageAsync(); + +message.Body.ToString().Should().Be("test-data"); + +await receiver.CompleteMessageAsync(message); +``` + +## Fault Injection + +Fault injection is currently not supported for Azure Service Bus. + +However, [hooks](hooks.md) can be used to simulate custom faults. For overview of available hooks, please see the [Hooks](#hooks) section. + +## Delay Simulation + +Delay simulation is currently not supported for Azure Service Bus. + +However, [hooks](hooks.md) can be used to simulate custom delays. For overview of available hooks, please see the [Hooks](#hooks) section. + +## Supported APIs and features + +### SDK clients & methods + +Following SDK clients and their method groups and properties are supported. + +Async versions of these methods are also supported. All supported async methods starts with [Task.Yield()](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.yield) to force the method to complete asynchronously. + +Other methods and properties are not supported and will throw `NotSupportedException`. + +Clients are thread-safe. + +#### `InMemoryServiceBusClient: ServiceBusClient` + +| Property | Note | +| ------------------------- | ---- | +| `FullyQualifiedNamespace` | | +| `Identifier` | | +| `IsClosed` | | +| `TransportType` | | + +| Method group | +| ------------------------ | +| `AcceptNextSessionAsync` | +| `AcceptSessionAsync` | +| `CreateSender` | +| `CreateReceiver` | +| `DisposeAsync` | + +| Constructors & factory methods | Note | +| --------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `(string connectionString)` | No credentials are validated. | +| `(string connectionString, ServiceBusClientOptions options)` | No credentials are validated. | +| `(string fullyQualifiedNamespace, TokenCredential credential)` | No credentials are validated. | +| `(string fullyQualifiedNamespace, TokenCredential credential, ServiceBusClientOptions options)` | No credentials are validated. | +| `FromNamespace(InMemoryServiceBusNamespace serviceBusNamespace, ServiceBusClientOptions? options = null)` | | + +#### `InMemoryServiceBusSender : ServiceBusSender` + +| Property | Note | +| ------------------------- | ---- | +| `EntityPath` | | +| `FullyQualifiedNamespace` | | +| `Identifier` | | +| `IsClosed` | | + +| Method group | +| ------------------------- | +| `SendMessageAsync` | +| `SendMessagesAsync` | +| `CreateMessageBatchAsync` | +| `DisposeAsync` | +| `CloseAsync` | + +| Constructors & factory methods | Note | +| --------------------------------------------------------------------------------------------- | ----------------------------- | +| `(InMemoryServiceBusClient client, string queueOrTopicName)` | No credentials are validated. | +| `(InMemoryServiceBusClient client, string queueOrTopicName, ServiceBusSenderOptions options)` | No credentials are validated. | +| `FromQueue(InMemoryServiceBusQueue queue, ServiceBusClientOptions? options = null)` | | +| `FromTopic(InMemoryServiceBusTopic topic, ServiceBusClientOptions? options = null)` | | + +#### `InMemoryServiceBusReceiver: ServiceBusReceiver` + +| Property | Note | +| ------------------------- | ---- | +| `EntityPath` | | +| `FullyQualifiedNamespace` | | +| `Identifier` | | +| `IsClosed` | | +| `PrefetchCount` | | +| `ReceiveMode` | | + +| Method group | +| ----------------------- | +| `AbandonMessageAsync` | +| `CloseAsync` | +| `CompleteMessageAsync` | +| `DisposeAsync` | +| `ReceiveMessageAsync` | +| `ReceiveMessagesAsync` | +| `RenewMessageLockAsync` | + +| Constructors & factory methods | Note | +| --------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `(InMemoryServiceBusClient client, string queueName)` | No credentials are validated. | +| `(InMemoryServiceBusClient client, string queueName, ServiceBusSenderOptions options)` | No credentials are validated. | +| `(InMemoryServiceBusClient client, string queueName, string subscriptionName)` | No credentials are validated. | +| `(InMemoryServiceBusClient client, string queueName, string subscriptionName, ServiceBusSenderOptions options)` | No credentials are validated. | +| `FromQueue(InMemoryServiceBusQueue queue, ServiceBusClientOptions? options = null)` | | +| `FromSubscription(InMemoryServiceBusTopicSubscription subscription, ServiceBusClientOptions? options = null)` | | + +#### `InMemoryServiceBusSessionReceiver : ServiceBusSessionReceiver` + +| Property | Note | +| ------------------------- | ---- | +| `EntityPath` | | +| `FullyQualifiedNamespace` | | +| `Identifier` | | +| `IsClosed` | | +| `PrefetchCount` | | +| `ReceiveMode` | | +| `SessionId` | | +| `SessionLockedUntil` | | + +| Method group | +| ----------------------- | +| `AbandonMessageAsync` | +| `CloseAsync` | +| `CompleteMessageAsync` | +| `DisposeAsync` | +| `GetSessionStateAsync` | +| `ReceiveMessageAsync` | +| `ReceiveMessagesAsync` | +| `RenewMessageLockAsync` | +| `RenewSessionLockAsync` | +| `SetSessionStateAsync` | + +No public constructors are available. + +### Features + +For the supported methods enumerated above, not all features are fully implemented. + +If the given feature is not supported, than the method will just ignore any parameters related to that feature. + +| Feature | Is Supported | +| ------------------------------- | ------------ | +| Deferred messages | ❌ | +| Dead-letter queues | ❌ | +| `PeekLock` receive mode | ✅ | +| Processors | ❌ | +| Queues | ✅ | +| `ReceiveAndDelete` receive mode | ✅ | +| Rules | ❌ | +| Scheduled messages | ❌ | +| Sessions | ✅ | +| Session states | ✅ | +| Sequence numbers | ✅ | +| Subscriptions | ✅ | +| Topics | ✅ | + +## Available Fluent Assertions + +There are following assertions available for in-memory service bus types: + +### `InMemoryServiceBusQueue` + +- `.Should().BeEmptyAsync()` + +### `InMemoryServiceBusTopicSubscription` + +- `.Should().BeEmptyAsync()` + +## Hooks + +Following hooks are supported in both `Before` and `After` variants: + +- All `Service Bus` operations + - All `Producer` operations + - `SendMessage` + - `SendBatch` + - All `Consumer` operations + - `ReceiveMessage` + - `ReceiveBatch` + +For details about concept of hooks, please see the [Hooks](./hooks.md) page. diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..25e8128 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,465 @@ +

Azure Storage

+ +

This library provides in-memory SDK for Azure Storage which can be used as a drop-in replacement for the official +Azure.Storage.Blobs and +Azure.Data.Tables SDKs in your tests.

+ +

+ Recommended Usage | + Fault Injection | + Supported APIs and features for Blobs | + Supported APIs and features for Tables | + Fluent Assertions +

+ +> [!TIP] +> See the whole [In-Memory Azure Test SDK](../README.md) suite if you are interested in other Azure services. + +## Recommended Usage + +To get started, add `Spotflow.InMemory.Azure.EventHubs` package to your project. + +```shell +dotnet add Spotflow.InMemory.Azure.Storage +``` + +Create non-static factory class for creating the real Azure SDK clients. Relevant methods should be virtual to allow overriding as well as there should be a protected parameterless constructor for testing purposes. + +```cs +class AzureClientFactory(TokenCredential tokenCredential) +{ + protected AzureClientFactory(): this(null!) {} // Testing-purposes only + + public virtual BlobContainerClient CreateBlobContainerClient(Uri uri) => new(uri, tokenCredential); +} +``` + +Use this class to obtain Storage clients in the tested code: + +```cs +class ExampleService(AzureClientFactory clientFactory, Uri containerUri) +{ + private readonly BlobContainerClient _containerCLient = clientFactory.CreateBlobContainerClient(containerUri); + + public async Task AddBlobToContainerAsync(BinaryData content, string blobName) + { + var blobClient = _containerCLient.GetBlobClient(blobName); + await blobClient.UploadAsync(content); + } +} +``` + +Create `InMemoryAzureClientFactory` by inheriting `AzureClientFactory` and override relevant factory methods to return in-memory clients: + +```cs +class InMemoryAzureClientFactory(InMemoryStorageProvider provider): AzureClientFactory +{ + public override BlobContainerClient CreateBlobContainerClient(Uri uri) + { + return new InMemoryBlobContainerClient(uri, provider); + } +} +``` + +When testing, it is now enough to initialize `InMemoryStorageProvider` to desired state and inject `InMemoryAzureClientFactory` to the tested code (e.g. via Dependency Injection): + +```cs +var provider = new InMemoryStorageProvider(); +var storageAccount = provider.AddAccount(); + +var containerClient = InMemoryBlobContainerClient.FromAccount(storageAccount, "test-container"); + +containerClient.Create(); + +var services = new ServiceCollection(); + +services.AddSingleton(); +services.AddSingleton(provider); +services.AddSingleton(); + +var exampleService = services.BuildServiceProvider().GetRequiredService(); + +var content = BinaryData.FromString("data"); + +await exampleService.AddBlobToContainerAsync(content, containerClient.Uri, "test-blob"); + +containerClient.GetBlobClient("test-blob").Exists().Value.Should.BeTrue(); +``` + +## Fault Injection + +Fault injections let you simulate transient and persistent faults in Azure Storage. +Thanks to that you can test how your application behaves in case of Azure outages, network issues, timeouts, etc. + +To inject a fault, you need to use the [concept of hooks](hooks.md) - functions that are called before or after the actual operation is executed. +A new hook can be registered by calling the `AddHook` method on the `InMemoryStorageProvider` instance. +You can build fault hook by calling the `Faults` method on the hook context and then calling the appropriate method, e.g. `ServiceIsBusy`: + +For overview of available hooks, please see the [Hooks](#hooks) section. + +```cs +var provider = new InMemoryStorageProvider(); +var hook = provider.AddHook(hookBuilder => hookBuilder.Before(ctx => ctx.Faults().ServiceIsBusy())) +``` + +The `AddHook` method gives you a builder that lets you define which operations the hook should apply to. +In the example above, the hook affects all storage operations. +However, you can limit it to specific operations, like `Download`, or target specific assets, such as blobs in a container named `my-container`: + +```cs +var hook = provider.AddHook( + hookBuilder => hookBuilder.ForBlobService() + .ForBlobOperations(containerName: "my-container") + .BeforeDownload(ctx => ctx.Faults().ServiceIsBusy()) + ); +``` + +You can control when the hook should execute via the `IHookRegistration` interface returned by the `AddHook` method. +By default, the hook is enabled, but you can disable it by calling the `Disable` method. +To simulate temporary outages, use the `DisableAfter` method to limit the number of fault occurrences. + +See a full example of fault injection below: + +```cs +var provider = new InMemoryStorageProvider(); + +var hook = provider.AddHook(hook => hook.Before(ctx => ctx.Faults().ServiceIsBusy())); + +var account = provider.AddAccount("test-account"); + +var containerClient = InMemoryBlobContainerClient.FromAccount(account, "test-container"); +var tableClient = InMemoryTableClient.FromAccount(account, "test-table"); + +var actBlob = () => containerClient.Create(); +var actTable = () => tableClient.Create(); + +actBlob.Should().Throw().WithMessage("Blob service in account 'test-account' is busy."); +actTable.Should().Throw().WithMessage("Table service in account 'test-account' is busy."); + +hook.Disable(); + +actBlob.Should().NotThrow(); +actTable.Should().NotThrow(); +``` + +## Delay Simulation + +You can test how your application handles slow Azure responses by simulating delays. + +Similar to fault injections, you can use [hooks](./hooks.md) to simulate delays. +To add a delay, call the `DelayAsync` method on the hook context. +The simplest way is to call the `DelayAsync` method with `TimeSpan` parameter, which specifies the duration of the delay. +Alternatively, you can use the `DelayAsync` method with a `IDelayGenerator` parameter, which allows you to specify the duration of the delay dynamically. + +For overview of available hooks, please see the [Hooks](#hooks) section. + +```cs +var provider = new InMemoryStorageProvider(); + +// Use static delay +provider.AddHook(hook => hook.Before(ctx => ctx.DelayAsync(TimeSpan.FromMilliseconds(100)))); + +// Use the built-in exponential delay generator +var delayGenerator = new ExponentialDelayGenerator(); +provider.AddHook(hook => hook.Before(ctx => ctx.DelayAsync(delayGenerator))); +``` + +The simulated delays consider the time provider used when creating the `InMemoryStorageProvider`. +That way, you have full control over the time in your tests. +See a full example of delay simulation below: + +```cs +var timeProvider = new FakeTimeProvider(); + +var provider = new InMemoryStorageProvider(timeProvider: timeProvider); + +provider.AddHook(hook => hook.Before(ctx => ctx.DelayAsync(TimeSpan.FromMilliseconds(100)))); + +var account = provider.AddAccount("test-account"); + +var containerClient = InMemoryBlobContainerClient.FromAccount(account, "test-container"); + +var task = Task.Run(() => containerClient.Create()); + +while (task.Status != TaskStatus.Running) +{ + await Task.Delay(10); +} + +await Task.Delay(1000); + +task.Status.Should().Be(TaskStatus.Running); + +timeProvider.Advance(TimeSpan.FromSeconds(32)); + +var response = await task; + +response.Value.LastModified.Should().Be(timeProvider.GetUtcNow()); +``` + +## Supported APIs and features for Blobs + +### SDK clients & methods + +Following SDK clients and their method groups and properties are supported. + +Async versions of these methods are also supported. All supported async methods starts with [Task.Yield()](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.yield) to force the method to complete asynchronously. + +Other methods and properties are not supported and will throw `NotSupportedException`. + +Clients are thread-safe. + +#### `InMemoryBlobServiceClient: BlobServiceClient` + +| Property | Note | +| -------------------------- | ----------------------- | +| `AccountName` | | +| `CanGenerateAccountSasUri` | Always returns `false`. | +| `Name` | | +| `Uri` | | + +| Method group | +| ------------------------ | +| `GetBlobContainerClient` | + +| Constructors & factory methods | Note | +| --------------------------------------------- | ----------------------------- | +| `(string connectionString)` | No credentials are validated | +| `(Uri serviceUri)` | No credentials are validated. | +| `FromAccount(InMemoryStorageAccount account)` | | + +#### `InMemoryBlobContainerClient: BlobContainerClient` + +| Property | Note | +| ------------------- | ----------------------- | +| `AccountName` | | +| `CanGenerateSasUri` | Always returns `false`. | +| `Name` | | +| `Uri` | | + +| Method group | Note | +| ---------------------------- | ---- | +| `Create` | | +| `CreateIfNotExists` | | +| `DeleteBlob` | | +| `DeleteBlobIfExists` | | +| `Exists` | | +| `GetBlobs` | | +| `GetBlobClient` | | +| `GetBlockBlobClient` | | +| `GetParentBlobServiceClient` | | +| `GetProperties` | | +| `UploadBlob` | | + +| Constructors & factory methods | Note | +| ----------------------------------------------------------------------- | ----------------------------- | +| `(string connectionString, string blobContainerName)` | No credentials are validated | +| `(Uri blobContainerUri)` | No credentials are validated. | +| `FromAccount(InMemoryStorageAccount account, string blobContainerName)` | | + +#### `InMemoryBlobClient: BlobClient` + +| Property | Note | +| ------------------- | ----------------------- | +| `AccountName` | | +| `BlobContainerName` | | +| `CanGenerateSasUri` | Always returns `false`. | +| `Name` | | +| `Uri` | | + +| Method group | Note | +| ------------------------------ | ----------------------------------------------------------- | +| `Delete` | Only supported for `DeleteSnapshotsOption.None` | +| `DeleteIfExistsAsync` | Only supported for `DeleteSnapshotsOption.None` | +| `Download` | Overloads with `HttpRange` parameter are not supported. | +| `DownloadStreaming` | Overloads with `HttpRange` parameter are not supported. | +| `DownloadContent` | Overloads with `HttpRange` parameter are not supported. | +| `Exists` | | +| `GetParentBlobContainerClient` | | +| `GetProperties` | | +| `OpenWrite` | | +| `Upload` | Overloads accepting path to a local file are not supported. | + +| Constructors & factory methods | Note | +| ---------------------------------------------------------------------------------------- | ----------------------------- | +| `(string connectionString, string blobContainerName, string blobName)` | No credentials are validated | +| `(Uri blobUri)` | No credentials are validated. | +| `FromAccount(InMemoryStorageAccount account, string blobContainerName, string blobName)` | | + +#### `InMemoryBlockBlobClient: BlockBlobClient` + +| Property | Note | +| --------------------------------- | ----------------------- | +| `AccountName` | | +| `BlobContainerName` | | +| `BlockBlobMaxUploadBlobBytes` | | +| `BlockBlobMaxUploadBlobLongBytes` | | +| `BlockBlobMaxStageBlockBytes` | | +| `BlockBlobMaxStageBlockLongBytes` | | +| `BlockBlobMaxBlocks` | | +| `CanGenerateSasUri` | Always returns `false`. | +| `Name` | | +| `Uri` | | + +| Method group | Note | +| ------------------------------ | ------------------------------------------------------- | +| `CommitBlockList` | | +| `Delete` | Only supported for `DeleteSnapshotsOption.None` | +| `DeleteIfExistsAsync` | Only supported for `DeleteSnapshotsOption.None` | +| `Download` | Overloads with `HttpRange` parameter are not supported. | +| `DownloadContent` | Overloads with `HttpRange` parameter are not supported. | +| `DownloadStreaming` | Overloads with `HttpRange` parameter are not supported. | +| `Exists` | | +| `GetBlockList` | | +| `GetParentBlobContainerClient` | | +| `GetProperties` | | +| `OpenWrite` | | +| `StageBlock` | | +| `Upload` | | + +| Constructors & factory methods | Note | +| ---------------------------------------------------------------------------------------- | ----------------------------- | +| `(string connectionString, string blobContainerName, string blobName)` | No credentials are validated | +| `(Uri blobUri)` | No credentials are validated. | +| `FromAccount(InMemoryStorageAccount account, string blobContainerName, string blobName)` | | + +### Features + +For the supported methods enumerated above, not all features are fully implemented. + +If the given feature is not supported, than the method will just ignore any parameters related to that feature. + +| Feature | Is Supported | +| -------------------------------- | ------------ | +| Access tiers | ❌ | +| Client-side encryption | ❌ | +| Condition - `IfMatch` | ✅ | +| Condition - `IfModifiedSince` | ❌ | +| Condition - `IfNoneMatch` | ✅ | +| Condition - `IfUnmodifiedSince` | ❌ | +| Connection string key validation | ❌ | +| CORS | ❌ | +| Encryption scopes | ❌ | +| Header - `Content-Encoding` | ✅ | +| Header - `Content-Type` | ✅ | +| Header - Others | ❌ | +| Immutability policies | ❌ | +| Leases | ❌ | +| Legal holds | ❌ | +| Metadata (blob) | ✅ | +| Metadata (container) | ✅ | +| Progress handling | ❌ | +| Public access | ❌ | +| Query | ❌ | +| Ranges | ❌ | +| SAS URI signature validation | ❌ | +| Server-side copy | ❌ | +| Snapshots | ❌ | +| Soft delete | ❌ | +| Static website | ❌ | +| Tags | ❌ | +| Transfer (validation) options | ❌ | +| Transfer options | ❌ | +| Versions | ❌ | + +## Supported APIs and features for Tables + +### SDK clients & methods + +Following SDK clients and their method groups and properties are supported. + +Async versions of these methods are also supported. All supported async methods starts with [Task.Yield()](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.yield) to force the method to complete asynchronously. + +Other methods and properties are not supported and will throw `NotSupportedException`. + +Clients are thread-safe. + +#### `InMemoryTableServiceClient : TableServiceClient` + +| Property | Note | +| ------------- | ---- | +| `AccountName` | | +| `Uri` | | + +| Method group | Note | +| ---------------- | ---- | +| `GetTableClient` | | + +| Constructor | Note | +| --------------------------- | ----------------------------- | +| `(string connectionString)` | No credentials are validated | +| `(Uri tableServiceUri)` | No credentials are validated. | + +#### `InMemoryTableClient : TableClient` + +| Property | Note | +| ------------- | ---- | +| `AccountName` | | +| `Name` | | +| `Uri` | | + +| Method group | Note | +| ------------------- | ---- | +| `Create` | | +| `CreateIfNotExists` | | +| `GetSasBuilder` | | +| `GenerateSasUri` | | +| `Query` | | +| `AddEntity` | | +| `UpsertEntity` | | +| `UpdateEntity` | | +| `DeleteEntity` | | +| `SubmitTransaction` | | + +| Constructor | Note | +| --------------------------------------------- | ----------------------------- | +| `(string connectionString, string tableName)` | No credentials are validated | +| `(Uri tableServiceUri, string tableName)` | No credentials are validated. | +| `(Uri tableUri)` | No credentials are validated. | + +### Features + +For the supported methods enumerated above, not all features are fully implemented. + +If the given feature is not supported, than the method will just ignore any parameters related to that feature. + +| Feature | Is Supported | +| -------------------------------- | ------------ | +| Access policy | ❌ | +| Condition - `IfMatch` | ✅ | +| Connection string key validation | ❌ | +| Query - String | ✅ | +| Query - LINQ | ✅ | +| Query - Property selectors | ❌ | +| SAS URI signature validation | ❌ | +| Transactions | ✅ | +| Update mode - Merge | ✅ | +| Update mode - Replace | ✅ | + +## Available Fluent Assertions + +Namespace: `Spotflow.InMemory.Azure.Storage.FluentAssertions` + +### `BlobClientBase` + +- `.Should().ExistAsync(...)`: returns immediately if the blob exists or waits for some time for the blob to be created before failing. + +## Hooks + +Following hooks are supported in both `Before` and `After` variants: + +- All `Storage` operations + - All `Blob Service` operations + - All `Blob` operations + - `Download` + - `Upload` + - All `Container` operations + - `Create` + - All `Table Service` operations + - All `Entity` operations + - `Add` + - `Upsert` + - All `Table` operations + - `Create` + +For details about concept of hooks, please see the [Hooks](./hooks.md) page. \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..0973dc2 --- /dev/null +++ b/nuget.config @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ConsumerEventHubScope.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ConsumerEventHubScope.cs new file mode 100644 index 0000000..359c72c --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ConsumerEventHubScope.cs @@ -0,0 +1,4 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks; + +public record ConsumerEventHubScope(string EventHubNamespaceName, string EventHubName, string ConsumerGroup, string PartitionId) + : EventHubScope(EventHubNamespaceName, EventHubName); diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ConsumerOperations.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ConsumerOperations.cs new file mode 100644 index 0000000..d7ceb95 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ConsumerOperations.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks; + +[Flags] +public enum ConsumerOperations +{ + None = 0, + ReceiveBatch = 1, + All = ReceiveBatch +} + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ConsumerAfterHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ConsumerAfterHookContext.cs new file mode 100644 index 0000000..7f9b5c3 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ConsumerAfterHookContext.cs @@ -0,0 +1,13 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public abstract class ConsumerAfterHookContext(ConsumerBeforeHookContext before) + : EventHubAfterHookContext(before), IConsumerOperation +{ + public ConsumerOperations Operation => before.Operation; + + public string ConsumerGroup => before.ConsumerGroup; + + public string PartitionId => before.PartitionId; +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ConsumerBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ConsumerBeforeHookContext.cs new file mode 100644 index 0000000..002b234 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ConsumerBeforeHookContext.cs @@ -0,0 +1,13 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public abstract class ConsumerBeforeHookContext(ConsumerEventHubScope scope, ConsumerOperations operation, InMemoryEventHubProvider provider, CancellationToken cancellationToken) + : EventHubBeforeHookContext(scope, provider, cancellationToken), IConsumerOperation +{ + public ConsumerOperations Operation => operation; + + public string ConsumerGroup => scope.ConsumerGroup; + + public string PartitionId => scope.PartitionId; +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/EventHubAfterHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/EventHubAfterHookContext.cs new file mode 100644 index 0000000..c89b5b7 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/EventHubAfterHookContext.cs @@ -0,0 +1,4 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public abstract class EventHubAfterHookContext(EventHubBeforeHookContext before) + : EventHubHookContext(before.EventHubNamespaceName, before.EventHubName, before.ResouceProvider, before.CancellationToken); diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/EventHubBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/EventHubBeforeHookContext.cs new file mode 100644 index 0000000..f7bfacd --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/EventHubBeforeHookContext.cs @@ -0,0 +1,5 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public abstract class EventHubBeforeHookContext(EventHubScope scope, InMemoryEventHubProvider provider, CancellationToken cancellationToken) + : EventHubHookContext(scope.EventHubNamespaceName, scope.EventHubName, provider, cancellationToken) +{ } diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/EventHubHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/EventHubHookContext.cs new file mode 100644 index 0000000..c548e3f --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/EventHubHookContext.cs @@ -0,0 +1,12 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public abstract class EventHubHookContext(string eventHubNamespaceName, string eventHubName, InMemoryEventHubProvider provider, CancellationToken cancellationToken) +{ + public string EventHubNamespaceName => eventHubNamespaceName; + public string EventHubName => eventHubName; + public InMemoryEventHubProvider ResouceProvider => provider; + public TimeProvider TimeProvider => provider.TimeProvider; + public CancellationToken CancellationToken => cancellationToken; + + public EventHubFaultsBuilder Faults() => new(this); +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ProducerAfterHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ProducerAfterHookContext.cs new file mode 100644 index 0000000..4932fae --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ProducerAfterHookContext.cs @@ -0,0 +1,11 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public abstract class ProducerAfterHookContext(ProducerBeforeHookContext before) + : EventHubAfterHookContext(before), IProducerOperation +{ + public ProducerOperations Operation => before.Operation; + public string? TargetPartitionId => before.TargetPartitionId; + public string? TargetPartitionKey => before.TargetPartitionKey; +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ProducerBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ProducerBeforeHookContext.cs new file mode 100644 index 0000000..55ce226 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ProducerBeforeHookContext.cs @@ -0,0 +1,11 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public abstract class ProducerBeforeHookContext(ProducerEventHubScope scope, ProducerOperations operation, InMemoryEventHubProvider provider, CancellationToken cancellationToken) + : EventHubBeforeHookContext(scope, provider, cancellationToken), IProducerOperation +{ + public ProducerOperations Operation => operation; + public string? TargetPartitionId => scope.TargetPartitionId; + public string? TargetPartitionKey => scope.TargetPartitionKey; +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ReceiveBatchAfterHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ReceiveBatchAfterHookContext.cs new file mode 100644 index 0000000..09d17a4 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ReceiveBatchAfterHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Messaging.EventHubs; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public class ReceiveBatchAfterHookContext(ReceiveBatchBeforeHookContext before) : ConsumerAfterHookContext(before) +{ + public required IReadOnlyList EventBatch { get; init; } + public ReceiveBatchBeforeHookContext BeforeContext => before; +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ReceiveBatchBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ReceiveBatchBeforeHookContext.cs new file mode 100644 index 0000000..825f570 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/ReceiveBatchBeforeHookContext.cs @@ -0,0 +1,5 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public class ReceiveBatchBeforeHookContext(ConsumerEventHubScope scope, InMemoryEventHubProvider provider, CancellationToken cancellationToken) + : ConsumerBeforeHookContext(scope, ConsumerOperations.ReceiveBatch, provider, cancellationToken); + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/SendAfterHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/SendAfterHookContext.cs new file mode 100644 index 0000000..5a015d7 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/SendAfterHookContext.cs @@ -0,0 +1,12 @@ +using Azure.Messaging.EventHubs; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public class SendAfterHookContext(SendBeforeHookContext before) : ProducerAfterHookContext(before) +{ + public required IReadOnlyList EventBatch { get; init; } + public required string PartitionId { get; init; } + + public SendBeforeHookContext BeforeContext => before; + +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/SendBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/SendBeforeHookContext.cs new file mode 100644 index 0000000..d22b8f9 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Contexts/SendBeforeHookContext.cs @@ -0,0 +1,11 @@ +using Azure.Messaging.EventHubs; +using Azure.Messaging.EventHubs.Producer; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +public class SendBeforeHookContext(ProducerEventHubScope scope, InMemoryEventHubProvider provider, CancellationToken cancellationToken) + : ProducerBeforeHookContext(scope, ProducerOperations.Send, provider, cancellationToken) +{ + public required IReadOnlyList EventBatch { get; init; } + public required SendEventOptions? SendEventOptions { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubFaultsBuilder.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubFaultsBuilder.cs new file mode 100644 index 0000000..8067d19 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubFaultsBuilder.cs @@ -0,0 +1,21 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; +using Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; +using Spotflow.InMemory.Azure.EventHubs.Internals; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks; + +public class EventHubFaultsBuilder(EventHubHookContext context) +{ + public Task ServiceIsBusy() + { + string? partitionId = null; + + if (context is IConsumerOperation consumer) + { + partitionId = consumer.PartitionId; + } + + throw EventHubExceptionFactory.ServiceIsBusy(context.EventHubNamespaceName, context.EventHubName, partitionId); + } +} + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubHook.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubHook.cs new file mode 100644 index 0000000..60e7652 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubHook.cs @@ -0,0 +1,17 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; +using Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; +using Spotflow.InMemory.Azure.Hooks; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks; + +public class EventHubHook where TContext : EventHubHookContext +{ + public HookFunc HookFunction { get; } + internal EventHubHookFilter Filter { get; } + + internal EventHubHook(HookFunc hookFunction, EventHubHookFilter filter) + { + HookFunction = hookFunction; + Filter = filter; + } +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubHookBuilder.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubHookBuilder.cs new file mode 100644 index 0000000..275b275 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubHookBuilder.cs @@ -0,0 +1,72 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; +using Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; +using Spotflow.InMemory.Azure.Hooks; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks; + +public class EventHubHookBuilder +{ + private readonly EventHubHookFilter _filter; + + internal EventHubHookBuilder(EventHubHookFilter? filter = null) + { + _filter = filter ?? new(); + } + public ProducerHookBuilder ForProducer(string? eventHubNamespaceName = null, string? eventHubName = null) + { + var producerFilter = new ProducerHookFilter(_filter.With(eventHubNamespaceName, eventHubName)); + return new(producerFilter); + } + + public ConsumerHookBuilder ForConsumer(string? eventHubNamespaceName = null, string? eventHubName = null, string? consumerGroupName = null, string? partitionId = null) + { + var consumerFilter = new ConsumerHookFilter(_filter.With(eventHubNamespaceName, eventHubName), consumerGroupName, partitionId); + return new(consumerFilter); + } + + public EventHubHook Before(HookFunc hook, string? eventHubNamespaceName = null, string? eventHubName = null) + { + return new(hook, _filter.With(eventHubNamespaceName, eventHubName)); + } + + public EventHubHook After(HookFunc hook, string? eventHubNamespaceName = null, string? eventHubName = null) + { + return new(hook, _filter.With(eventHubNamespaceName, eventHubName)); + } + + public class ProducerHookBuilder + { + private readonly ProducerHookFilter _filter; + + internal ProducerHookBuilder(ProducerHookFilter? filter = null) + { + _filter = filter ?? new(); + } + + public EventHubHook Before(HookFunc hook, ProducerOperations? operations = null) => new(hook, _filter.With(operations)); + + public EventHubHook After(HookFunc hook, ProducerOperations? operations = null) => new(hook, _filter.With(operations)); + + public EventHubHook BeforeSend(HookFunc hook) => new(hook, _filter); + + public EventHubHook AfterSend(HookFunc hook) => new(hook, _filter); + } + + public class ConsumerHookBuilder + { + private readonly ConsumerHookFilter _filter; + + internal ConsumerHookBuilder(ConsumerHookFilter? filter) + { + _filter = filter ?? new(); + } + + public EventHubHook Before(HookFunc hook, ConsumerOperations? operations = null) => new(hook, _filter.With(operations)); + + public EventHubHook After(HookFunc hook, ConsumerOperations? operations = null) => new(hook, _filter.With(operations)); + + public EventHubHook BeforeReceiveBatch(HookFunc hook) => new(hook, _filter); + + public EventHubHook AfterReceiveBatch(HookFunc hook) => new(hook, _filter); + } +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubScope.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubScope.cs new file mode 100644 index 0000000..5a399d0 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/EventHubScope.cs @@ -0,0 +1,3 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks; + +public record EventHubScope(string EventHubNamespaceName, string EventHubName); diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/ConsumerHookFilter.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/ConsumerHookFilter.cs new file mode 100644 index 0000000..079e0aa --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/ConsumerHookFilter.cs @@ -0,0 +1,39 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; + +internal record ConsumerHookFilter : EventHubHookFilter +{ + public ConsumerHookFilter() { } + + public ConsumerHookFilter(EventHubHookFilter filter, string? consumerGroup, string? partitionId) : base(filter) + { + ConsumerGroup = consumerGroup; + PartitionId = partitionId; + } + + public string? ConsumerGroup { get; private init; } + public string? PartitionId { get; private init; } + + public ConsumerOperations Operations { get; private init; } = ConsumerOperations.All; + + public override bool Covers(EventHubHookContext context) + { + var result = base.Covers(context); + + if (context is IConsumerOperation consumer) + { + result &= ConsumerGroup is null || ConsumerGroup == consumer.ConsumerGroup; + result &= PartitionId is null || PartitionId == consumer.PartitionId; + result &= Operations.HasFlag(consumer.Operation); + + return result; + } + + throw new InvalidOperationException($"Unexpected context: {context}"); + } + + public EventHubHookFilter With(ConsumerOperations? operations) => this with { Operations = operations ?? Operations }; + +} + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/EventHubHookFilter.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/EventHubHookFilter.cs new file mode 100644 index 0000000..99ca172 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/EventHubHookFilter.cs @@ -0,0 +1,29 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; +using Spotflow.InMemory.Azure.Hooks; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; + +internal record EventHubHookFilter : BaseHookFilter +{ + public string? EventHubNamespaceName { get; private init; } + public string? EventHubName { get; private init; } + + public override bool Covers(EventHubHookContext context) + { + var result = true; + + result &= EventHubNamespaceName is null || EventHubNamespaceName == context.EventHubNamespaceName; + result &= EventHubName is null || EventHubName == context.EventHubName; + + return result; + } + + public EventHubHookFilter With(string? eventHubNamespaceName, string? eventHubName) + { + return this with + { + EventHubNamespaceName = eventHubNamespaceName ?? EventHubNamespaceName, + EventHubName = eventHubName ?? EventHubName + }; + } +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/IConsumerOperation.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/IConsumerOperation.cs new file mode 100644 index 0000000..e9b12e2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/IConsumerOperation.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; + +internal interface IConsumerOperation +{ + + string ConsumerGroup { get; } + string PartitionId { get; } + ConsumerOperations Operation { get; } +} + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/IProducerOperation.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/IProducerOperation.cs new file mode 100644 index 0000000..bcb13a7 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/IProducerOperation.cs @@ -0,0 +1,8 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; + +internal interface IProducerOperation +{ + ProducerOperations Operation { get; } + string? TargetPartitionId { get; } + string? TargetPartitionKey { get; } +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/ProducerHookFilter.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/ProducerHookFilter.cs new file mode 100644 index 0000000..2c18762 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/Internals/ProducerHookFilter.cs @@ -0,0 +1,34 @@ +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; + +internal record ProducerHookFilter : EventHubHookFilter +{ + public ProducerHookFilter() { } + + public ProducerHookFilter(EventHubHookFilter filter) : base(filter) { } + + public string? TargetPartitionId { get; private init; } + public string? TargetPartitionKey { get; private init; } + + public ProducerOperations Operations { get; private init; } = ProducerOperations.All; + + public override bool Covers(EventHubHookContext context) + { + var result = base.Covers(context); + + if (context is IProducerOperation producer) + { + result &= TargetPartitionId is null || TargetPartitionId == producer.TargetPartitionId; + result &= TargetPartitionKey is null || TargetPartitionKey == producer.TargetPartitionKey; + result &= Operations.HasFlag(producer.Operation); + + return result; + } + + throw new InvalidOperationException($"Unexpected context: {context}"); + } + + public ProducerHookFilter With(ProducerOperations? operations) => this with { Operations = operations ?? Operations }; +} + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ProducerEventHubScope.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ProducerEventHubScope.cs new file mode 100644 index 0000000..5677c4b --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ProducerEventHubScope.cs @@ -0,0 +1,14 @@ +using Azure.Messaging.EventHubs.Producer; + +namespace Spotflow.InMemory.Azure.EventHubs.Hooks; + +public record ProducerEventHubScope(string EventHubNamespaceName, string EventHubName, string? TargetPartitionKey, string? TargetPartitionId) + : EventHubScope(EventHubNamespaceName, EventHubName) +{ + public ProducerEventHubScope(EventHubScope eventHubScope, SendEventOptions? options) + : this(eventHubScope.EventHubNamespaceName, eventHubScope.EventHubName, options?.PartitionKey, options?.PartitionId) + { + } +} + + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ProducerOperations.cs b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ProducerOperations.cs new file mode 100644 index 0000000..4403c0d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Hooks/ProducerOperations.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Hooks; + +[Flags] +public enum ProducerOperations +{ + None = 0, + Send = 1, + All = Send +} + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHubConsumerClient.cs b/src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHubConsumerClient.cs new file mode 100644 index 0000000..a57a796 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHubConsumerClient.cs @@ -0,0 +1,131 @@ +using Azure.Messaging.EventHubs; +using Azure.Messaging.EventHubs.Consumer; + +using Spotflow.InMemory.Azure.EventHubs.Internals; +using Spotflow.InMemory.Azure.EventHubs.Resources; + +namespace Spotflow.InMemory.Azure.EventHubs; + +public class InMemoryEventHubConsumerClient : EventHubConsumerClient +{ + #region Constructors + + public InMemoryEventHubConsumerClient( + string consumerGroup, + string connectionString, + InMemoryEventHubProvider provider) + : this(consumerGroup, EventHubClientUtils.ConnectionFromConnectionString(connectionString), provider) { } + + public InMemoryEventHubConsumerClient( + string consumerGroup, + string fullyQualifiedNamespace, + string eventHubName, + InMemoryEventHubProvider provider) + : this(consumerGroup, EventHubClientUtils.Connection(fullyQualifiedNamespace, eventHubName), provider) { } + + + public InMemoryEventHubConsumerClient( + string consumerGroup, + EventHubConnection connection, + InMemoryEventHubProvider provider) + : base(consumerGroup, connection) + { + Provider = provider; + } + + public static InMemoryEventHubConsumerClient FromEventHub(string consumerGroup, InMemoryEventHub eventHub) + { + return FromNamespace(consumerGroup, eventHub.Namespace, eventHub.Name); + } + + public static InMemoryEventHubConsumerClient FromNamespace(string consumerGroup, InMemoryEventHubNamespace eventHubNamespace, string eventHubName) + { + return new(consumerGroup, eventHubNamespace.FullyQualifiedNamespace, eventHubName, eventHubNamespace.Provider); + } + + #endregion + + public InMemoryEventHubProvider Provider { get; } + + #region Get Properties & IDs + + public override async Task GetEventHubPropertiesAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var eventHub = GetEventHub(); + + return eventHub.Properties; + } + + public override async Task GetPartitionIdsAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var eventHub = GetEventHub(); + + return eventHub.Properties.PartitionIds; + } + + public override async Task GetPartitionPropertiesAsync(string partitionId, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var eventHub = GetEventHub(); + return eventHub.GetPartition(partitionId).GetProperties(); + } + + #endregion + + #region Dispose & Close + + public override async ValueTask DisposeAsync() + { + await Task.Yield(); + IsClosed = true; + } + + public override async Task CloseAsync(CancellationToken cancellationToken = default) => await DisposeAsync(); + + #endregion + + private InMemoryEventHub GetEventHub() + { + var eventHub = EventHubClientUtils.GetEventHub(Provider, FullyQualifiedNamespace, EventHubName); + + EventHubClientUtils.HasConsumerGroupOrThrow(eventHub, ConsumerGroup); + + return eventHub; + } + + + #region Unsupported + + public override IAsyncEnumerable ReadEventsFromPartitionAsync(string partitionId, EventPosition startingPosition, CancellationToken cancellationToken = default) + { + throw EventHubExceptionFactory.MethodNotSupported(); + } + + public override IAsyncEnumerable ReadEventsFromPartitionAsync(string partitionId, EventPosition startingPosition, ReadEventOptions readOptions, CancellationToken cancellationToken = default) + { + throw EventHubExceptionFactory.MethodNotSupported(); + } + + public override IAsyncEnumerable ReadEventsAsync(CancellationToken cancellationToken = default) + { + throw EventHubExceptionFactory.MethodNotSupported(); + } + + public override IAsyncEnumerable ReadEventsAsync(ReadEventOptions readOptions, CancellationToken cancellationToken = default) + { + throw EventHubExceptionFactory.MethodNotSupported(); + } + + public override IAsyncEnumerable ReadEventsAsync(bool startReadingAtEarliestEvent, ReadEventOptions? readOptions = null, CancellationToken cancellationToken = default) + { + throw EventHubExceptionFactory.MethodNotSupported(); + } + + #endregion + +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHubProducerClient.cs b/src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHubProducerClient.cs new file mode 100644 index 0000000..f159a2f --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHubProducerClient.cs @@ -0,0 +1,221 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +using Azure.Core; +using Azure.Messaging.EventHubs; +using Azure.Messaging.EventHubs.Producer; + +using Spotflow.InMemory.Azure.Auth; +using Spotflow.InMemory.Azure.EventHubs.Hooks; +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; +using Spotflow.InMemory.Azure.EventHubs.Internals; +using Spotflow.InMemory.Azure.EventHubs.Resources; + +namespace Spotflow.InMemory.Azure.EventHubs; + +public class InMemoryEventHubProducerClient : EventHubProducerClient +{ + private readonly ConcurrentDictionary Events, SendEventOptions Options)> _batches + = new(ReferenceEqualityComparer.Instance); + + private readonly EventHubScope _scope; + + #region Constructors + + public InMemoryEventHubProducerClient(string connectionString, InMemoryEventHubProvider provider) + : this(EventHubClientUtils.ConnectionFromConnectionString(connectionString), provider) { } + + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Distinguishing from other constructors")] + public InMemoryEventHubProducerClient(string fullyQualifiedNamespace, string eventHubName, TokenCredential tokenCredential, InMemoryEventHubProvider provider) + : this(EventHubClientUtils.Connection(fullyQualifiedNamespace, eventHubName), provider) { } + + + public InMemoryEventHubProducerClient(EventHubConnection connection, InMemoryEventHubProvider provider) : base(connection) + { + Provider = provider; + _scope = new(provider.GetNamespaceNameFromHostname(FullyQualifiedNamespace), EventHubName); + } + + public static InMemoryEventHubProducerClient FromEventHub(InMemoryEventHub eventHub) + { + return FromNamespace(eventHub.Namespace, eventHub.Name); + } + + public static InMemoryEventHubProducerClient FromNamespace(InMemoryEventHubNamespace eventHubNamespace, string eventHubName) + { + return new(eventHubNamespace.FullyQualifiedNamespace, eventHubName, NoOpTokenCredential.Instance, eventHubNamespace.Provider); + } + + #endregion + + public InMemoryEventHubProvider Provider { get; } + + #region Dispose & Close + + public override async ValueTask DisposeAsync() + { + await Task.Yield(); + IsClosed = true; + } + public override async Task CloseAsync(CancellationToken cancellationToken = default) => await DisposeAsync(); + + #endregion + + #region Create Batch + + public override ValueTask CreateBatchAsync(CancellationToken cancellationToken = default) + { + return CreateBatchAsync(new CreateBatchOptions(), cancellationToken); + } + + public override async ValueTask CreateBatchAsync(CreateBatchOptions options, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var events = new List(); + + var batch = EventHubsModelFactory.EventDataBatch(42, events, options, ed => events.Count < 64); + + _batches[batch] = (events, options); + + return batch; + } + + #endregion + + #region Get properties & IDs + + public override async Task GetEventHubPropertiesAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var eventHub = GetEventHub(); + + return eventHub.Properties; + } + + public override async Task GetPartitionIdsAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var eventHub = GetEventHub(); + + return eventHub.Properties.PartitionIds; + } + + public override async Task GetPartitionPropertiesAsync(string partitionId, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var eventHub = GetEventHub(); + + return eventHub.GetPartition(partitionId).GetProperties(); + + } + + #endregion + + #region Send + + public override async Task SendAsync(IEnumerable eventBatch, SendEventOptions sendEventOptions, CancellationToken cancellationToken = default) + { + await SendCoreAsync(eventBatch, sendEventOptions, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + public override async Task SendAsync(IEnumerable eventBatch, CancellationToken cancellationToken = default) + { + await SendCoreAsync(eventBatch, null, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + public override async Task SendAsync(EventDataBatch eventBatch, CancellationToken cancellationToken = default) + { + if (!_batches.TryRemove(eventBatch, out var data)) + { + throw EventHubExceptionFactory.FeatureNotSupported($"Batches from different instance of '{GetType()}' cannot be sent."); + } + + var (events, options) = data; + + await SendCoreAsync(events, options, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + private async Task SendCoreAsync(IEnumerable eventBatch, SendEventOptions? sendEventOptions, CancellationToken cancellationToken) + { + var enumeratedBatch = eventBatch.ToList(); + + var scope = new ProducerEventHubScope(_scope, sendEventOptions); + + var beforeContext = new SendBeforeHookContext(scope, Provider, cancellationToken) + { + EventBatch = enumeratedBatch, + SendEventOptions = sendEventOptions, + }; + + await ExecuteBeforeHooksAsync(beforeContext, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.None); + + var partition = ResolvePartitionToSend(sendEventOptions); + + foreach (var e in enumeratedBatch) + { + partition.SendEvent(e, sendEventOptions?.PartitionKey); + } + + var afterContext = new SendAfterHookContext(beforeContext) + { + EventBatch = enumeratedBatch, + PartitionId = partition.PartitionId + }; + + await ExecuteAfterHooksAsync(afterContext, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.None); + } + + private InMemoryPartition ResolvePartitionToSend(SendEventOptions? sendEventOptions) + { + var eventHub = GetEventHub(); + + if (sendEventOptions?.PartitionId is not null) + { + if (!eventHub.TryGetPartition(sendEventOptions.PartitionId, out var partition)) + { + throw EventHubExceptionFactory.PartitionNotFound(eventHub, sendEventOptions.PartitionId); + } + + return partition; + } + + if (sendEventOptions?.PartitionKey is not null) + { + return eventHub.GetPartitionByKey(sendEventOptions.PartitionKey); + } + + return eventHub.GetRoundRobinPartition(); + } + + #endregion + + private InMemoryEventHub GetEventHub() + { + return EventHubClientUtils.GetEventHub(Provider, FullyQualifiedNamespace, EventHubName); + } + + private Task ExecuteBeforeHooksAsync(TContext context, CancellationToken cancellationToken) where TContext : ProducerBeforeHookContext + { + return Provider.ExecuteHooksAsync(context, cancellationToken); + } + + private Task ExecuteAfterHooksAsync(TContext context, CancellationToken cancellationToken) where TContext : ProducerAfterHookContext + { + return Provider.ExecuteHooksAsync(context, cancellationToken); + } + + + #region Unsupported + + protected override Task GetPartitionPublishingPropertiesAsync(string partitionId, CancellationToken cancellationToken = default) + { + throw EventHubExceptionFactory.MethodNotSupported(); + } + + #endregion + +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHubProvider.cs b/src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHubProvider.cs new file mode 100644 index 0000000..dc39557 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/InMemoryEventHubProvider.cs @@ -0,0 +1,113 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +using Spotflow.InMemory.Azure.EventHubs.Hooks; +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; +using Spotflow.InMemory.Azure.EventHubs.Hooks.Internals; +using Spotflow.InMemory.Azure.EventHubs.Resources; +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.EventHubs; + +public class InMemoryEventHubProvider(TimeProvider? timeProvider = null, string? hostnameSuffix = null) +{ + private readonly ConcurrentDictionary _namespaces = new(StringComparer.OrdinalIgnoreCase); + + private readonly HooksExecutor _hooksExecutor = new(); + + public string HostnameSuffix { get; } = hostnameSuffix ?? "eventhub.in-memory.example.com"; + + internal TimeProvider TimeProvider { get; } = timeProvider ?? TimeProvider.System; + + + public InMemoryEventHubNamespace AddNamespace(string? namespaceName = null) + { + if (namespaceName is not null && namespaceName.Contains('.')) + { + throw new ArgumentException($"Namespace name cannot contain dots. Got '{namespaceName}'"); + } + + + namespaceName ??= GenerateNamespaceName(); + + var ns = new InMemoryEventHubNamespace(namespaceName, this); + + if (!_namespaces.TryAdd(ns.FullyQualifiedNamespace, ns)) + { + throw new InvalidOperationException($"Event Hub namespace '{namespaceName}' already exists."); + } + + return ns; + } + + public bool TryGetNamespace(string name, [NotNullWhen(true)] out InMemoryEventHubNamespace? result) + { + foreach (var (_, ns) in _namespaces) + { + if (ns.Name == name) + { + result = ns; + return true; + } + } + + result = null; + return false; + } + + public InMemoryEventHubNamespace GetNamespace(string name) + { + if (!TryGetNamespace(name, out var result)) + { + throw new InvalidOperationException($"Event Hub namespace '{name}' not found."); + } + + return result; + } + + public bool TryGetFullyQualifiedNamespace(string fullyQualifiedNamespace, [NotNullWhen(true)] out InMemoryEventHubNamespace? result) + { + return _namespaces.TryGetValue(fullyQualifiedNamespace, out result); + } + + + public InMemoryEventHubNamespace GetFullyQualifiedNamespace(string fullyQualifiedNamespace) + { + if (TryGetFullyQualifiedNamespace(fullyQualifiedNamespace, out var result)) + { + return result; + } + + throw new InvalidOperationException($"Event Hub namespace with hostname '{fullyQualifiedNamespace}' not found."); + } + + public IHookRegistration AddHook(Func> buildHook) where TContext : EventHubHookContext + { + var hook = buildHook(new()); + + return _hooksExecutor.AddHook(hook.HookFunction, hook.Filter); + } + + internal Task ExecuteHooksAsync(TContext context, CancellationToken cancellationToken) where TContext : EventHubHookContext + { + return _hooksExecutor.ExecuteHooksAsync(context); + } + + internal string GetNamespaceNameFromHostname(string hostname) + { + if (!hostname.EndsWith(HostnameSuffix)) + { + throw new FormatException($"Event Hub namespace host name is expected to end with '{HostnameSuffix}'. Got {hostname}."); + } + + + return hostname[..(hostname.Length - HostnameSuffix.Length - 1)]; + } + + private static string GenerateNamespaceName() => Guid.NewGuid().ToString(); +} + + + + diff --git a/src/Spotflow.InMemory.Azure.EventHubs/InMemoryPartitionReceiver.cs b/src/Spotflow.InMemory.Azure.EventHubs/InMemoryPartitionReceiver.cs new file mode 100644 index 0000000..6bb07d3 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/InMemoryPartitionReceiver.cs @@ -0,0 +1,304 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; + +using Azure.Core; +using Azure.Messaging.EventHubs; +using Azure.Messaging.EventHubs.Consumer; +using Azure.Messaging.EventHubs.Primitives; + +using Spotflow.InMemory.Azure.Auth; +using Spotflow.InMemory.Azure.EventHubs.Hooks; +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; +using Spotflow.InMemory.Azure.EventHubs.Internals; +using Spotflow.InMemory.Azure.EventHubs.Resources; + +namespace Spotflow.InMemory.Azure.EventHubs; + +public class InMemoryPartitionReceiver : PartitionReceiver +{ + private readonly SemaphoreSlim _receiveLock = new(1, 1); + private readonly object _lastEnqueuedEventPropertiesLock = new(); + + + private readonly StartingPosition _startingPosition; + private readonly TimeProvider _timeProvider; + + private Position? _position; + + private LastEnqueuedEventProperties? _lastEnqueuedEventProperties; + + private readonly ConsumerEventHubScope _scope; + + #region Constructors + + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Distinguishing from other constructors")] + public InMemoryPartitionReceiver( + string consumerGroup, + string partitionId, + EventPosition startingPosition, + string fullyQualifiedNamespace, + string eventHubName, + TokenCredential tokenCredential, + InMemoryEventHubProvider provider) + : this(consumerGroup, partitionId, startingPosition, EventHubClientUtils.Connection(fullyQualifiedNamespace, eventHubName), provider) { } + + public InMemoryPartitionReceiver( + string consumerGroup, + string partitionId, + EventPosition startingPosition, + string connectionString, + InMemoryEventHubProvider provider) + : this(consumerGroup, partitionId, startingPosition, EventHubClientUtils.ConnectionFromConnectionString(connectionString), provider) { } + + public InMemoryPartitionReceiver( + string consumerGroup, + string partitionId, + EventPosition startingPosition, + string connectionString, + string eventHubName, + InMemoryEventHubProvider provider) + : this(consumerGroup, partitionId, startingPosition, EventHubClientUtils.ConnectionFromConnectionString(connectionString, eventHubName), provider) { } + + + public InMemoryPartitionReceiver( + string consumerGroup, + string partitionId, + EventPosition startingPosition, + EventHubConnection connection, + InMemoryEventHubProvider provider) + : base(consumerGroup, partitionId, startingPosition, connection) + { + Provider = provider; + _timeProvider = provider.TimeProvider; + _startingPosition = ResolveStartingPosition(startingPosition); + _scope = new(provider.GetNamespaceNameFromHostname(FullyQualifiedNamespace), EventHubName, ConsumerGroup, PartitionId); + } + + public static InMemoryPartitionReceiver FromEventHub(string partitionId, EventPosition startingPosition, InMemoryEventHub eventHub) + { + return FromEventHub(InMemoryEventHub.DefaultConsumerGroupName, partitionId, startingPosition, eventHub); + } + + public static InMemoryPartitionReceiver FromEventHub(string consumerGroup, string partitionId, EventPosition startingPosition, InMemoryEventHub eventHub) + { + return FromNamespace(consumerGroup, partitionId, startingPosition, eventHub.Namespace, eventHub.Name); + } + + public static InMemoryPartitionReceiver FromNamespace(string consumerGroup, string partitionId, EventPosition startingPosition, InMemoryEventHubNamespace eventHubNamespace, string eventHubName) + { + return new(consumerGroup, partitionId, startingPosition, eventHubNamespace.FullyQualifiedNamespace, eventHubName, NoOpTokenCredential.Instance, eventHubNamespace.Provider); + } + + #endregion + + public InMemoryEventHubProvider Provider { get; } + + #region Dispose & Close + + public override async ValueTask DisposeAsync() + { + await Task.Yield(); + IsClosed = true; + } + + public override async Task CloseAsync(CancellationToken cancellationToken = default) => await DisposeAsync(); + + #endregion + + #region Get Properties + + public override async Task GetPartitionPropertiesAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var partition = GetPartition(); + + return partition.GetProperties(); + } + + public override LastEnqueuedEventProperties ReadLastEnqueuedEventProperties() + { + lock (_lastEnqueuedEventPropertiesLock) + { + if (_lastEnqueuedEventProperties is null) + { + return default; + } + + return _lastEnqueuedEventProperties.Value; + } + + } + + #endregion + + #region Receive Batch + + public override async Task> ReceiveBatchAsync(int maximumEventCount, TimeSpan maximumWaitTime, CancellationToken cancellationToken = default) + { + return await ReceiveBatchCoreAsync(maximumEventCount, maximumWaitTime, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + public override async Task> ReceiveBatchAsync(int maximumEventCount, CancellationToken cancellationToken = default) + { + return await ReceiveBatchCoreAsync(maximumEventCount, TimeSpan.FromSeconds(60), cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + private async Task> ReceiveBatchCoreAsync(int maximumEventCount, TimeSpan maximumWaitTime, CancellationToken cancellationToken = default) + { + var beforeContext = new ReceiveBatchBeforeHookContext(_scope, Provider, cancellationToken); + + await ExecuteBeforeHooksAsync(beforeContext, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.None); + + var partition = GetPartition(); + + var startTime = _timeProvider.GetTimestamp(); + + IReadOnlyList events = []; + + await _receiveLock.WaitAsync(cancellationToken); + + try + { + if (_position is null) + { + _position = partition.ResolvePosition(_startingPosition); + } + + + while (!cancellationToken.IsCancellationRequested) + { + events = partition.GetEvents(_position.Value, maximumEventCount); + + var elapsedTime = _timeProvider.GetElapsedTime(startTime); + + if (events.Count > 0 || elapsedTime > maximumWaitTime) + { + break; + } + + await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationToken); + } + + var partitionProperties = partition.GetProperties(); + + if (events.Count > 0) + { + _position = Position.FromSequenceNumber(events[^1].SequenceNumber, false); + } + + lock (_lastEnqueuedEventPropertiesLock) + { + if (partitionProperties.IsEmpty) + { + _lastEnqueuedEventProperties = null; + } + else + { + _lastEnqueuedEventProperties = new( + partitionProperties.LastEnqueuedSequenceNumber, + partitionProperties.LastEnqueuedOffset, + partitionProperties.LastEnqueuedTime, + _timeProvider.GetUtcNow()); + } + } + } + finally + { + _receiveLock.Release(); + } + + var afterContext = new ReceiveBatchAfterHookContext(beforeContext) + { + EventBatch = events + }; + + await ExecuteAfterHooksAsync(afterContext, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.None); + + return events; + } + + #endregion + + private static StartingPosition ResolveStartingPosition(EventPosition position) + { + if (position == EventPosition.Earliest) + { + return StartingPosition.Earliest; + } + + if (position == EventPosition.Latest) + { + return StartingPosition.Latest; + } + + long? sequencenceNumber = null; + bool? isInclusive = null; + + foreach (var property in position.GetType().GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (property.Name == "SequenceNumber") + { + var sequencenceNumberObj = property.GetValue(position); + + sequencenceNumber = sequencenceNumberObj switch + { + long l => l, + null => null, + string s => long.Parse(s, CultureInfo.InvariantCulture), + _ => throw new InvalidOperationException($"SequenceNumber property with value '{sequencenceNumberObj}' has unexpected type: {sequencenceNumberObj?.GetType()}.") + }; + } + + if (property.Name == "IsInclusive") + { + isInclusive = (bool?) property.GetValue(position); + } + + if (property.Name == "Offset" && property.GetValue(position) is not null) + { + throw new NotSupportedException("EventPosition with offset is not supported."); + } + } + + if (sequencenceNumber is null) + { + throw new InvalidOperationException("SequenceNumber property not available."); + } + + if (isInclusive is null) + { + throw new InvalidOperationException("IsInclusive property not available."); + } + + + return StartingPosition.FromSequenceNumber(sequencenceNumber.Value, isInclusive.Value); + } + + private InMemoryPartition GetPartition() + { + var eh = EventHubClientUtils.GetEventHub(Provider, FullyQualifiedNamespace, EventHubName); + + EventHubClientUtils.HasConsumerGroupOrThrow(eh, ConsumerGroup); + + if (!eh.TryGetPartition(PartitionId, out var partition)) + { + throw EventHubExceptionFactory.PartitionNotFound(eh, PartitionId); + } + + return partition; + + } + + private Task ExecuteBeforeHooksAsync(TContext context, CancellationToken cancellationToken) where TContext : ConsumerBeforeHookContext + { + return Provider.ExecuteHooksAsync(context, cancellationToken); + } + + private Task ExecuteAfterHooksAsync(TContext context, CancellationToken cancellationToken) where TContext : ConsumerAfterHookContext + { + return Provider.ExecuteHooksAsync(context, cancellationToken); + } + +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Internals/EventHubClientUtils.cs b/src/Spotflow.InMemory.Azure.EventHubs/Internals/EventHubClientUtils.cs new file mode 100644 index 0000000..6191a74 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Internals/EventHubClientUtils.cs @@ -0,0 +1,49 @@ +using Azure.Messaging.EventHubs; + +using Spotflow.InMemory.Azure.Auth; +using Spotflow.InMemory.Azure.EventHubs.Resources; + +namespace Spotflow.InMemory.Azure.EventHubs.Internals; + +internal static class EventHubClientUtils +{ + public static InMemoryEventHub GetEventHub(InMemoryEventHubProvider provider, string namespaceHostname, string eventHubName) + { + if (!provider.TryGetFullyQualifiedNamespace(namespaceHostname, out var ns)) + { + throw EventHubExceptionFactory.NamespaceNotFound(namespaceHostname); + } + + if (!ns.TryGetEventHub(eventHubName, out var eh)) + { + throw EventHubExceptionFactory.EventHubNotFound(ns, eventHubName); + } + + return eh; + } + + public static void HasConsumerGroupOrThrow(InMemoryEventHub eventHub, string consumerGroupName) + { + if (!eventHub.HasConsumerGroup(consumerGroupName)) + { + throw EventHubExceptionFactory.ConsumerGroupNotFound(eventHub, consumerGroupName); + } + } + + public static EventHubConnection ConnectionFromConnectionString(string connectionString, string? eventHubName = null) + { + if (string.IsNullOrWhiteSpace(eventHubName)) + { + return new EventHubConnection(connectionString); + } + else + { + return new EventHubConnection(connectionString, eventHubName); + } + } + + public static EventHubConnection Connection(string fullyQualifiedNamespace, string eventHubName) + { + return new EventHubConnection(fullyQualifiedNamespace, eventHubName, NoOpTokenCredential.Instance); + } +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Internals/EventHubConnectionStringUtils.cs b/src/Spotflow.InMemory.Azure.EventHubs/Internals/EventHubConnectionStringUtils.cs new file mode 100644 index 0000000..58c6d3a --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Internals/EventHubConnectionStringUtils.cs @@ -0,0 +1,27 @@ +using System.Security.Cryptography; +using System.Text; + +using Spotflow.InMemory.Azure.EventHubs.Resources; + +namespace Spotflow.InMemory.Azure.EventHubs.Internals; + +internal static class EventHubConnectionStringUtils +{ + public static string ForEventHub(InMemoryEventHub eventHub, string? keyName = null) + { + var namespaceConnectionString = ForNamespace(eventHub.Namespace, keyName).TrimEnd(';'); + return $"{namespaceConnectionString};EntityPath={eventHub.Name};"; + } + + public static string ForNamespace(InMemoryEventHubNamespace ns, string? keyName = null) + { + keyName ??= "test-key"; + + var keySeed = $"{keyName}|{ns.FullyQualifiedNamespace}"; + + var key = Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(keySeed))); + + return $"Endpoint=sb://{ns.FullyQualifiedNamespace};SharedAccessKey={key};SharedAccessKeyName={keyName};"; + } + +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Internals/EventHubExceptionFactory.cs b/src/Spotflow.InMemory.Azure.EventHubs/Internals/EventHubExceptionFactory.cs new file mode 100644 index 0000000..b438330 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Internals/EventHubExceptionFactory.cs @@ -0,0 +1,69 @@ +using System.Net.Sockets; +using System.Runtime.CompilerServices; + +using Azure.Messaging.EventHubs; + +using Spotflow.InMemory.Azure.EventHubs.Resources; + +namespace Spotflow.InMemory.Azure.EventHubs.Internals; + +internal static class EventHubExceptionFactory +{ + public static NotSupportedException MethodNotSupported([CallerMemberName] string? callerMemberName = null) + { + return new($"In-memory event hub client does not support method '{callerMemberName}'."); + } + + public static NotSupportedException FeatureNotSupported(string featureName) + { + return new($"In-memory event hub client does not support feature '{featureName}'."); + } + + + public static EventHubsException ConsumerGroupNotFound(InMemoryEventHub eventHub, string consumerGroupName) + { + return ResourceNotFound(consumerGroupName, $"Consumer Group '{consumerGroupName}' not found in '{eventHub}'."); + } + + public static EventHubsException EventHubNotFound(InMemoryEventHubNamespace @namespace, string eventHubName) + { + return ResourceNotFound(eventHubName, $"Event Hub '{eventHubName}' not found in '{@namespace}'."); + } + + public static SocketException NamespaceNotFound(string namespaceHostname) + { + return new SocketException(11001, $"No such host is known: {namespaceHostname}"); + } + + public static EventHubsException PartitionNotFound(InMemoryEventHub eh, string partitionId) + { + return ResourceNotFound(eh.Properties.Name, $"Partition '{partitionId}' not found in '{eh}'."); + } + + private static EventHubsException ResourceIsBusy(string? eventHubName, string message) + { + return new(true, eventHubName, message, EventHubsException.FailureReason.ServiceBusy); + } + + private static EventHubsException ResourceNotFound(string eventHubName, string message) + { + return new(false, eventHubName, message, EventHubsException.FailureReason.ResourceNotFound); + } + + public static EventHubsException ServiceIsBusy(string namespaceHostname, string eventHubName, string? partitionId) + { + if (partitionId is not null) + { + return ResourceIsBusy( + eventHubName, + $"Partition '{partitionId}' in event hub '{eventHubName}' in namespace '{namespaceHostname}' is busy."); + } + else + { + return ResourceIsBusy( + eventHubName, + $"Event hub '{eventHubName}' in namespace '{namespaceHostname}' is busy."); + } + } + +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Internals/InMemoryPartition.cs b/src/Spotflow.InMemory.Azure.EventHubs/Internals/InMemoryPartition.cs new file mode 100644 index 0000000..903a589 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Internals/InMemoryPartition.cs @@ -0,0 +1,166 @@ +using Azure.Messaging.EventHubs; + +using Spotflow.InMemory.Azure.EventHubs.Resources; + +namespace Spotflow.InMemory.Azure.EventHubs.Internals; +internal class InMemoryPartition +{ + private readonly object _syncObj = new(); + + private readonly TimeProvider _timeProvider; + private EventData[] _events = new EventData[1024]; + private int _eventCount = 0; + private long _eventOffset = 0; + + public InMemoryPartition(string partitionId, long initialSequenceNumber, InMemoryEventHub eventHub) + { + PartitionId = partitionId; + EventHub = eventHub; + + if (initialSequenceNumber < 0) + { + throw new ArgumentOutOfRangeException(nameof(initialSequenceNumber), initialSequenceNumber, "Initial sequence number must be greater than or equal to 0."); + } + + InitialSequenceNumber = initialSequenceNumber; + _timeProvider = eventHub.Provider.TimeProvider; + } + + public long LastSequenceNumber + { + get + { + lock (_syncObj) + { + return InitialSequenceNumber + _eventCount - 1; + } + } + } + + public string PartitionId { get; } + + public InMemoryEventHub EventHub { get; } + + public long InitialSequenceNumber { get; } + + + + public PartitionProperties GetProperties() + { + var name = EventHub.Properties.Name; + + var eventDataSegment = GetCurrentEventsSegment(); + + if (eventDataSegment.Count is 0) + { + return EventHubsModelFactory.PartitionProperties(name, PartitionId, true, -1, -1, -1, DateTimeOffset.MinValue); + } + else + { + var first = eventDataSegment[0]; + var last = eventDataSegment[^1]; + + return EventHubsModelFactory.PartitionProperties(name, PartitionId, false, first.SequenceNumber, last.SequenceNumber, last.Offset, last.EnqueuedTime); + } + } + + public void SendEvent(EventData eventData, string? partitionKey) + { + lock (_syncObj) + { + if (_events.Length == _eventCount) + { + var newEvents = new EventData[_events.Length * 2]; + + Array.Copy(_events, newEvents, _events.Length); // Do not zero or reuse old array because it might be still in use. + + _events = newEvents; + } + + var sequenceNumber = InitialSequenceNumber + _eventCount; + + var enqueuedTime = eventData.EnqueuedTime != default ? eventData.EnqueuedTime : _timeProvider.GetUtcNow(); + + var eventBodyMemory = eventData.EventBody.ToMemory(); + + var eventBodyCopy = new byte[eventBodyMemory.Length]; + eventBodyMemory.CopyTo(eventBodyCopy); + + var eventDataPropertiesCopy = new Dictionary(eventData.Properties); + var eventDataSystemPropertiesCopy = new Dictionary(eventData.SystemProperties); + + var eventWithSystemProperties = EventHubsModelFactory.EventData( + eventBody: new(eventBodyCopy), + properties: eventDataPropertiesCopy, + systemProperties: eventDataSystemPropertiesCopy, + partitionKey: partitionKey, + sequenceNumber: sequenceNumber, + offset: _eventOffset, + enqueuedTime: enqueuedTime + ); + + eventWithSystemProperties.MessageId = eventData.MessageId; + eventWithSystemProperties.CorrelationId = eventData.CorrelationId; + eventWithSystemProperties.ContentType = eventData.ContentType; + + _events[_eventCount++] = eventWithSystemProperties; + _eventOffset += eventBodyMemory.Length; + } + + } + + public IReadOnlyList GetEvents(Position position, int maximumEventCount) + { + var startSequenceNumber = position.IsInclusive ? position.SequenceNumber : position.SequenceNumber + 1; + return GetEventsCore(startSequenceNumber, maximumEventCount); + } + + private IReadOnlyList GetEventsCore(long startSequenceNumber, int maximumEventCount) + { + var currentEventsSegment = GetCurrentEventsSegment(); + + var startSequenceNumberNormalized = startSequenceNumber - InitialSequenceNumber; + + if (startSequenceNumberNormalized >= currentEventsSegment.Count) + { + return []; + } + + // Number is surely less than int.MaxValue so the conversaion is safe. + + var startSequenceNumberNormalizedAsInt = (int) startSequenceNumberNormalized; + + var end = startSequenceNumberNormalizedAsInt + maximumEventCount; + + if (end > currentEventsSegment.Count) + { + end = currentEventsSegment.Count; + } + + return currentEventsSegment[startSequenceNumberNormalizedAsInt..end]; + } + + + private ArraySegment GetCurrentEventsSegment() + { + lock (_syncObj) + { + return new(_events, 0, _eventCount); + } + } + + public Position ResolvePosition(StartingPosition startingPosition) + { + if (startingPosition == StartingPosition.Earliest) + { + return Position.FromSequenceNumber(InitialSequenceNumber, true); + } + + if (startingPosition == StartingPosition.Latest) + { + return Position.FromSequenceNumber(LastSequenceNumber, false); + } + + return Position.FromSequenceNumber(startingPosition.SequenceNumber, startingPosition.IsInclusive); + } +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Internals/Position.cs b/src/Spotflow.InMemory.Azure.EventHubs/Internals/Position.cs new file mode 100644 index 0000000..cff5ca1 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Internals/Position.cs @@ -0,0 +1,7 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Internals; + + +internal readonly record struct Position(long SequenceNumber, bool IsInclusive) +{ + public static Position FromSequenceNumber(long sequenceNumber, bool isInclusive) => new(sequenceNumber, isInclusive); +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Internals/StartingPosition.cs b/src/Spotflow.InMemory.Azure.EventHubs/Internals/StartingPosition.cs new file mode 100644 index 0000000..1518d1f --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Internals/StartingPosition.cs @@ -0,0 +1,12 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Internals; + +internal readonly record struct StartingPosition(long SequenceNumber, bool IsInclusive, bool IsEarliest, bool IsLatest) +{ + public static StartingPosition FromSequenceNumber(long sequenceNumber, bool isInclusive) + { + return new(sequenceNumber, isInclusive, false, false); + } + + public static StartingPosition Earliest => new(-1, false, true, false); + public static StartingPosition Latest => new(-1, false, false, true); +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Resources/InMemoryEventHub.cs b/src/Spotflow.InMemory.Azure.EventHubs/Resources/InMemoryEventHub.cs new file mode 100644 index 0000000..252604b --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Resources/InMemoryEventHub.cs @@ -0,0 +1,140 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +using Azure.Messaging.EventHubs; + +using Spotflow.InMemory.Azure.EventHubs.Internals; +namespace Spotflow.InMemory.Azure.EventHubs.Resources; + +public class InMemoryEventHub +{ + public const string DefaultConsumerGroupName = "$Default"; + + private readonly IReadOnlyDictionary _partitions; + private readonly object _roundRobinPartitionLock = new(); + private readonly ConcurrentDictionary _consumerGroups; + private int _roundRobinPartitionIndex = 0; + + public InMemoryEventHub( + string name, + EventHubProperties properties, + InMemoryEventHubOptions options, + InMemoryEventHubNamespace @namespace) + { + Namespace = @namespace; + Name = name; + Properties = properties ?? throw new ArgumentNullException(nameof(properties)); + _consumerGroups = new(StringComparer.OrdinalIgnoreCase); + _consumerGroups[DefaultConsumerGroupName] = 1; + _partitions = CreatePartitions(Properties.PartitionIds, options, this); + } + + public long GetInitialSequenceNumber(string partitionId) + { + if (!_partitions.TryGetValue(partitionId, out var partition)) + { + throw new InvalidOperationException($"Partition '{partitionId}' not found in event hub '{Name}' in namespace {Namespace.Name}."); + } + + return partition.InitialSequenceNumber; + } + + private static IReadOnlyDictionary CreatePartitions(string[] partitionIds, InMemoryEventHubOptions options, InMemoryEventHub parent) + { + var result = new Dictionary(); + + Random? random = null; + + if (options.RandomizeInitialSequenceNumbers) + { + random = options.RandomizationSeed is null ? Random.Shared : new(options.RandomizationSeed.Value); + } + + foreach (var id in partitionIds) + { + var initialSequenceNumber = random is null ? 0 : random.Next(options.MinRandomInitialSequenceNumber, options.MaxRandomInitialSequenceNumber + 1); + + result[id] = new(id, initialSequenceNumber, parent); + } + + return result; + } + + public InMemoryEventHubProvider Provider => Namespace.Provider; + public InMemoryEventHubNamespace Namespace { get; } + public EventHubProperties Properties { get; } + + public IReadOnlySet ConsumerGroups => _consumerGroups.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); + public string Name { get; } + + public bool HasConsumerGroup(string consumerGroupName) => _consumerGroups.ContainsKey(consumerGroupName); + + public string CreateConnectionString() => EventHubConnectionStringUtils.ForEventHub(this); + + public override string ToString() => $"{Name} [{Namespace}]"; + + public InMemoryEventHub AddConsumerGroup(string consumerGroupName) + { + if (!_consumerGroups.TryAdd(consumerGroupName, 1)) + { + throw new InvalidOperationException($"Consumer group '{consumerGroupName}' alredy exists in event hub '{Name}' in namespace {Namespace.Name}."); + } + + return this; + } + + internal InMemoryPartition GetRoundRobinPartition() + { + int index; + + lock (_roundRobinPartitionLock) + { + if (_roundRobinPartitionIndex == _partitions.Count) + { + index = _roundRobinPartitionIndex = 0; + } + else + { + index = _roundRobinPartitionIndex++; + } + } + + return GetPartition(PartitionIdFromInt(index)); + } + + internal InMemoryPartition GetPartition(string partitionId) + { + if (TryGetPartition(partitionId, out var partition)) + { + return partition; + } + + throw new InvalidOperationException($"Partition '{partitionId}' not found in event hub '{Name}' in namespace {Namespace.Name}."); + } + + internal bool TryGetPartition(string partitionId, [NotNullWhen(true)] out InMemoryPartition? partition) + { + return _partitions.TryGetValue(partitionId, out partition); + } + + internal InMemoryPartition GetPartitionByKey(string partitionKey) + { + var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(partitionKey)); + + var hashCode = BinaryPrimitives.ReadInt32BigEndian(hashBytes.AsSpan(0, 4)); + + hashCode -= int.MinValue; + + var partitionId = hashCode % Properties.PartitionIds.Length; + + return GetPartition(PartitionIdFromInt(partitionId)); + } + + private static string PartitionIdFromInt(int partitionId) => partitionId.ToString(CultureInfo.InvariantCulture); + + +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Resources/InMemoryEventHubNamespace.cs b/src/Spotflow.InMemory.Azure.EventHubs/Resources/InMemoryEventHubNamespace.cs new file mode 100644 index 0000000..d7536e9 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Resources/InMemoryEventHubNamespace.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.EventHubs; + +using Spotflow.InMemory.Azure.EventHubs.Internals; + +namespace Spotflow.InMemory.Azure.EventHubs.Resources; + +public class InMemoryEventHubNamespace +{ + private readonly ConcurrentDictionary _eventHubs = new(); + + public InMemoryEventHubNamespace(string name, InMemoryEventHubProvider provider) + { + FullyQualifiedNamespace = $"{name}.{provider.HostnameSuffix.TrimStart('.')}"; + Name = name; + Provider = provider; + } + + public string FullyQualifiedNamespace { get; } + public string Name { get; } + + public InMemoryEventHubProvider Provider { get; } + + public string CreateConnectionString() => EventHubConnectionStringUtils.ForNamespace(this); + + public InMemoryEventHub AddEventHub(string eventHubName, int numberOfPartitions, Action? optionsAction = null) + { + if (numberOfPartitions <= 0) + { + throw new ArgumentOutOfRangeException(nameof(numberOfPartitions), numberOfPartitions, "Number of partitions must be greater than 0."); + } + + var partitionIds = Enumerable.Range(0, numberOfPartitions).Select(id => id.ToString()).ToArray(); + var properties = EventHubsModelFactory.EventHubProperties(eventHubName, DateTimeOffset.UtcNow, partitionIds); + + var options = new InMemoryEventHubOptions(); + + optionsAction?.Invoke(options); + + var eventHub = new InMemoryEventHub(eventHubName, properties, options, this); + + if (!_eventHubs.TryAdd(eventHubName, eventHub)) + { + throw new InvalidOperationException($"Event Hub '{eventHubName}' already exists in namespace '{Name}'."); + } + + return eventHub; + + } + + public InMemoryEventHub GetEventHub(string eventHubName) + { + if (!TryGetEventHub(eventHubName, out var eh)) + { + throw new InvalidOperationException($"Event Hub '{eventHubName}' not found in namespace '{Name}'."); + } + + return eh; + } + + public override string ToString() => $"{Name} ({FullyQualifiedNamespace})"; + + public bool TryGetEventHub(string eventHubName, [NotNullWhen(true)] out InMemoryEventHub? eventHub) + { + return _eventHubs.TryGetValue(eventHubName, out eventHub); + } +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Resources/InMemoryEventHubOptions.cs b/src/Spotflow.InMemory.Azure.EventHubs/Resources/InMemoryEventHubOptions.cs new file mode 100644 index 0000000..79a72cc --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Resources/InMemoryEventHubOptions.cs @@ -0,0 +1,29 @@ +namespace Spotflow.InMemory.Azure.EventHubs.Resources; + +public class InMemoryEventHubOptions +{ + /// + /// + /// If true, the initial sequence numbers of each partition are selected at random. + /// Otherwise, all initial seqence numbers are set to 0. + /// + /// The randomization can be further controlled by and properties. + /// + public bool RandomizeInitialSequenceNumbers { get; set; } = false; + + /// + /// Seed that is used to initialize the random number generator used to randomize the initial sequence numbers. + /// If set to null, the is used. + /// + public int? RandomizationSeed { get; set; } = 21092023; + + /// + /// Maximum value of the randomized initial sequence number. + /// + public int MaxRandomInitialSequenceNumber { get; set; } = 1000; + + /// + /// Minimum value of the randomized initial sequence number. + /// + public int MinRandomInitialSequenceNumber { get; set; } = 10; +} diff --git a/src/Spotflow.InMemory.Azure.EventHubs/Spotflow.InMemory.Azure.EventHubs.csproj b/src/Spotflow.InMemory.Azure.EventHubs/Spotflow.InMemory.Azure.EventHubs.csproj new file mode 100644 index 0000000..65c7863 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.EventHubs/Spotflow.InMemory.Azure.EventHubs.csproj @@ -0,0 +1,21 @@ + + + + In-memory implementation of the Azure Event Hubs clients for convenient testing. + $(PackageTags);EventHub + true + README.md + + + + + + + + + + + + + + diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Contexts/KeyVaultAfterHookContext.cs b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Contexts/KeyVaultAfterHookContext.cs new file mode 100644 index 0000000..9a7052f --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Contexts/KeyVaultAfterHookContext.cs @@ -0,0 +1,4 @@ +namespace Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; + +public abstract class KeyVaultAfterHookContext(KeyVaultBeforeHookContext before) + : KeyVaultHookContext(before.VaultName, before.ResourceProvider, before.CancellationToken); diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Contexts/KeyVaultBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Contexts/KeyVaultBeforeHookContext.cs new file mode 100644 index 0000000..7dec3a0 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Contexts/KeyVaultBeforeHookContext.cs @@ -0,0 +1,4 @@ +namespace Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; + +public abstract class KeyVaultBeforeHookContext(KeyVaultScope scope, InMemoryKeyVaultProvider provider, CancellationToken cancellationToken) + : KeyVaultHookContext(scope.VaultName, provider, cancellationToken); diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Contexts/KeyVaultHookContext.cs b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Contexts/KeyVaultHookContext.cs new file mode 100644 index 0000000..7e52946 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Contexts/KeyVaultHookContext.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; + +public abstract class KeyVaultHookContext(string vaultName, InMemoryKeyVaultProvider provider, CancellationToken cancellationToken) +{ + public string VaultName => vaultName; + + public InMemoryKeyVaultProvider ResourceProvider => provider; + public TimeProvider TimeProvider => provider.TimeProvider; + public CancellationToken CancellationToken => cancellationToken; +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Internals/KeyVaultHookFilter.cs b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Internals/KeyVaultHookFilter.cs new file mode 100644 index 0000000..4444e29 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/Internals/KeyVaultHookFilter.cs @@ -0,0 +1,22 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.KeyVault.Hooks.Internals; + +internal record KeyVaultHookFilter : BaseHookFilter +{ + public string? VaultName { get; private init; } + + public override bool Covers(KeyVaultHookContext context) + { + return VaultName is null || VaultName == context.VaultName; + } + + public KeyVaultHookFilter With(string? vaultName) + { + return this with { VaultName = vaultName ?? VaultName }; + } +} + + + diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Hooks/KeyVaultHook.cs b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/KeyVaultHook.cs new file mode 100644 index 0000000..3ef6b9b --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/KeyVaultHook.cs @@ -0,0 +1,17 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; +using Spotflow.InMemory.Azure.KeyVault.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.KeyVault.Hooks; + +public class KeyVaultHook where TContext : KeyVaultHookContext +{ + internal KeyVaultHook(HookFunc hookFunction, KeyVaultHookFilter filter) + { + HookFunction = hookFunction; + Filter = filter; + } + + internal HookFunc HookFunction { get; } + internal KeyVaultHookFilter Filter { get; } +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Hooks/KeyVaultHookBuilder.cs b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/KeyVaultHookBuilder.cs new file mode 100644 index 0000000..20ee333 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/KeyVaultHookBuilder.cs @@ -0,0 +1,52 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; +using Spotflow.InMemory.Azure.KeyVault.Hooks.Internals; +using Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks; +using Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Contexts; +using Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.KeyVault.Hooks; + +public class KeyVaultHookBuilder +{ + private readonly KeyVaultHookFilter _filter; + + internal KeyVaultHookBuilder(KeyVaultHookFilter? filter = null) + { + _filter = filter ?? new(); + } + + public SecretsHookBuilder ForSecrets(string? vaultName = null) => new(_filter.With(vaultName)); + + public KeyVaultHook Before(HookFunc hook, string? vaultName = null) + { + return new(hook, _filter.With(vaultName)); + } + + public KeyVaultHook After(HookFunc hook, string? vaultName = null) + { + return new(hook, _filter.With(vaultName)); + } + + public class SecretsHookBuilder + { + private readonly SecretHookFilter _filter; + + internal SecretsHookBuilder(KeyVaultHookFilter filter) + { + _filter = new(filter); + } + + public KeyVaultHook Before(HookFunc hook, SecretOperations? operations = null) => new(hook, _filter.With(operations: operations)); + public KeyVaultHook After(HookFunc hook, SecretOperations? operations = null) => new(hook, _filter.With(operations: operations)); + + public KeyVaultHook BeforeGetSecret(HookFunc hook) => new(hook, _filter); + + public KeyVaultHook AfterGetSecret(HookFunc hook) => new(hook, _filter); + + public KeyVaultHook BeforeSetSecret(HookFunc hook) => new(hook, _filter); + + public KeyVaultHook AfterSetSecret(HookFunc hook) => new(hook, _filter); + } + +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Hooks/KeyVaultScope.cs b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/KeyVaultScope.cs new file mode 100644 index 0000000..69bf2d9 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Hooks/KeyVaultScope.cs @@ -0,0 +1,3 @@ +namespace Spotflow.InMemory.Azure.KeyVault.Hooks; + +public record KeyVaultScope(string VaultName); diff --git a/src/Spotflow.InMemory.Azure.KeyVault/InMemoryKeyVaultProvider.cs b/src/Spotflow.InMemory.Azure.KeyVault/InMemoryKeyVaultProvider.cs new file mode 100644 index 0000000..5978138 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/InMemoryKeyVaultProvider.cs @@ -0,0 +1,94 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Hooks.Internals; +using Spotflow.InMemory.Azure.KeyVault.Hooks; +using Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; +using Spotflow.InMemory.Azure.KeyVault.Hooks.Internals; +using Spotflow.InMemory.Azure.KeyVault.Resources; + +namespace Spotflow.InMemory.Azure.KeyVault; + +public class InMemoryKeyVaultProvider(TimeProvider? timeProvider = null, string? hostnameSuffix = null) +{ + private readonly ConcurrentDictionary _keyVaults = []; + + private readonly HooksExecutor _hooksExecutor = new(); + public TimeProvider TimeProvider { get; } = timeProvider ?? TimeProvider.System; + + public string HostnameSuffix { get; } = hostnameSuffix ?? "keyvault.in-memory.example.com"; + + public InMemoryKeyVault AddVault(string? vaultName = null) + { + vaultName ??= GenerateVaultName(); + + var vault = new InMemoryKeyVault(vaultName, this); + + if (!_keyVaults.TryAdd(vault.VaultUri, vault)) + { + throw new InvalidOperationException("Key vault already exists."); + } + + return vault; + } + + public bool TryGetVault(string vaultName, [NotNullWhen(true)] out InMemoryKeyVault? result) + { + foreach (var (_, vault) in _keyVaults) + { + if (vault.Name == vaultName) + { + result = vault; + return true; + } + } + + result = null; + return false; + } + + public InMemoryKeyVault GetVault(string vaultName) + { + if (!TryGetVault(vaultName, out var vault)) + { + throw new InvalidOperationException($"Key vault '{vaultName}' not found."); + } + + return vault; + } + + public bool TryGetVaultByUri(Uri vaultUri, [NotNullWhen(true)] out InMemoryKeyVault? result) + { + return _keyVaults.TryGetValue(vaultUri, out result); + } + + public InMemoryKeyVault GetVaultByUri(Uri vaultUri) + { + if (!TryGetVaultByUri(vaultUri, out var vault)) + { + throw new InvalidOperationException($"Key vault '{vaultUri}' not found."); + } + + return vault; + } + + public IHookRegistration AddHook(Func> hook) where TContext : KeyVaultHookContext + { + var completedHook = hook(new()); + + return _hooksExecutor.AddHook(completedHook.HookFunction, completedHook.Filter); + } + + internal Task ExecuteHooksAsync(TContext context) where TContext : KeyVaultHookContext + { + return _hooksExecutor.ExecuteHooksAsync(context); + } + + private static string GenerateVaultName() => Guid.NewGuid().ToString(); + + internal string GetVaultNameFromUri(Uri vaultUri) + { + return vaultUri.Host[..(vaultUri.Host.Length - HostnameSuffix.Length - 1)]; + } +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Internals/InMemoryDeleteSecretOperation.cs b/src/Spotflow.InMemory.Azure.KeyVault/Internals/InMemoryDeleteSecretOperation.cs new file mode 100644 index 0000000..2fd6ca2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Internals/InMemoryDeleteSecretOperation.cs @@ -0,0 +1,100 @@ +using Azure; +using Azure.Core; +using Azure.Security.KeyVault.Secrets; + +using Spotflow.InMemory.Azure.Internals; + +namespace Spotflow.InMemory.Azure.KeyVault.Internals; + +internal class InMemoryDeleteSecretOperation(InMemoryKeyVaultSecret secret) : DeleteSecretOperation +{ + public override string Id { get; } = Guid.NewGuid().ToString(); + + public override DeletedSecret Value { get; } = SecretModelFactory.DeletedSecret(secret.ToClientProperties(), deletedOn: secret.DeletedOn); + + public override bool HasCompleted => true; + + public override bool HasValue => true; + + #region WaitForCompletion + + public override Response WaitForCompletion(CancellationToken cancellationToken = default) + { + return InMemoryResponse.FromValue(Value, 200); + } + + public override Response WaitForCompletion(TimeSpan pollingInterval, CancellationToken cancellationToken) + { + return WaitForCompletion(cancellationToken); + } + + public override Response WaitForCompletion(DelayStrategy delayStrategy, CancellationToken cancellationToken) + { + return WaitForCompletion(cancellationToken); + } + + public override async ValueTask> WaitForCompletionAsync(DelayStrategy delayStrategy, CancellationToken cancellationToken) + { + await Task.Yield(); + return WaitForCompletion(cancellationToken); + } + + public override async ValueTask> WaitForCompletionAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + return WaitForCompletion(cancellationToken); + } + + public override async ValueTask> WaitForCompletionAsync(TimeSpan pollingInterval, CancellationToken cancellationToken) + { + await Task.Yield(); + return WaitForCompletion(cancellationToken); + } + + public override Response WaitForCompletionResponse(CancellationToken cancellationToken = default) + { + return WaitForCompletion(cancellationToken).GetRawResponse(); + } + + public override Response WaitForCompletionResponse(TimeSpan pollingInterval, CancellationToken cancellationToken = default) + { + return WaitForCompletionResponse(cancellationToken); + } + + public override Response WaitForCompletionResponse(DelayStrategy delayStrategy, CancellationToken cancellationToken = default) + { + return WaitForCompletionResponse(cancellationToken); + } + + public override async ValueTask WaitForCompletionResponseAsync(DelayStrategy delayStrategy, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return WaitForCompletionResponse(cancellationToken); + } + + public override async ValueTask WaitForCompletionResponseAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + return WaitForCompletionResponse(cancellationToken); + } + + public override async ValueTask WaitForCompletionResponseAsync(TimeSpan pollingInterval, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return WaitForCompletionResponse(cancellationToken); + } + + #endregion + + #region Unsupported + public override RehydrationToken? GetRehydrationToken() => throw KeyVaultExceptionFactory.MethodNotSupported(); + + public override Response GetRawResponse() => throw KeyVaultExceptionFactory.MethodNotSupported(); + + public override Response UpdateStatus(CancellationToken cancellationToken = default) => throw KeyVaultExceptionFactory.MethodNotSupported(); + + public override ValueTask UpdateStatusAsync(CancellationToken cancellationToken = default) => throw KeyVaultExceptionFactory.MethodNotSupported(); + + #endregion + +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Internals/InMemoryKeyVaultSecret.cs b/src/Spotflow.InMemory.Azure.KeyVault/Internals/InMemoryKeyVaultSecret.cs new file mode 100644 index 0000000..5b5cde1 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Internals/InMemoryKeyVaultSecret.cs @@ -0,0 +1,171 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using Azure.Security.KeyVault.Secrets; + +namespace Spotflow.InMemory.Azure.KeyVault.Internals; + +internal class InMemoryKeyVaultSecret +{ + private readonly ImmutableArray _versions; + private readonly Uri _vaultUri; + private readonly string _secretName; + private readonly TimeProvider _timeProvider; + private readonly SecretProperties _properties; + + [MemberNotNullWhen(true, nameof(DeletedOn))] + public bool IsDeleted { get; } + + public DateTimeOffset? DeletedOn { get; } + + private InMemoryKeyVaultSecret( + Uri vaultUri, + string secretName, + ImmutableArray versions, + TimeProvider timeProvider, + DateTimeOffset? createdOn, + bool? isDeleted, + DateTimeOffset? deletedOn, + bool? isEnabled + ) + { + _vaultUri = vaultUri; + _secretName = secretName; + _timeProvider = timeProvider; + _versions = versions; + + var now = _timeProvider.GetUtcNow(); + + var id = new Uri(vaultUri, $"/secrets/{secretName}"); + + _properties = SecretModelFactory.SecretProperties( + id: id, + vaultUri: vaultUri, + name: secretName, + createdOn: createdOn ?? now, + updatedOn: now + ); + + _properties.Enabled = isEnabled; + + + IsDeleted = isDeleted ?? false; + + if (IsDeleted && deletedOn is null) + { + throw new ArgumentException("Deleted secret must have a deletedOn date", nameof(deletedOn)); + } + + DeletedOn = deletedOn; + } + + public static InMemoryKeyVaultSecret CreateWithInitialVersion(Uri vaultUri, string secretName, KeyVaultSecret initialVersion, TimeProvider timeProvider) + { + var version = InMemoryKeyVaultSecretVersion.FromClientModel(vaultUri, secretName, initialVersion, timeProvider); + return new( + vaultUri: vaultUri, + secretName: secretName, + versions: [version], + timeProvider: timeProvider, + createdOn: null, + isDeleted: null, + deletedOn: null, + isEnabled: initialVersion.Properties.Enabled); + } + + + public InMemoryKeyVaultSecretVersion? FindLatestVersion() + { + if (_versions.Length > 0) + { + return _versions[^1]; + } + + return null; + } + + public InMemoryKeyVaultSecretVersion GetLastVersion() + { + return FindLatestVersion() ?? throw new InvalidOperationException("No versions found"); + } + + public InMemoryKeyVaultSecretVersion? FindVersion(string secretVersion) + { + foreach (var version in _versions) + { + if (version.Label.Equals(secretVersion, StringComparison.Ordinal)) + { + return version; + } + } + + return null; + } + + public InMemoryKeyVaultSecret WithNewVersion(KeyVaultSecret inputSecretVersion) + { + var version = InMemoryKeyVaultSecretVersion.FromClientModel(_vaultUri, _secretName, inputSecretVersion, _timeProvider); + + var updatedVersions = _versions.Add(version); + + return Updated(updatedVersions); + } + + public SecretProperties ToClientListItem() => _properties; + + public InMemoryKeyVaultSecret WithUpdatedVersionProperties(string secretVersion, SecretProperties inputVersionProperties) + { + for (var i = 0; i < _versions.Length; i++) + { + var version = _versions[i]; + + if (version.Label.Equals(secretVersion, StringComparison.Ordinal)) + { + var updatedVersion = version.WithUpdatedProperties(inputVersionProperties); + + var updatedVersions = _versions.SetItem(i, updatedVersion); + + return Updated(updatedVersions, isEnabled: inputVersionProperties.Enabled); + } + } + + throw new InvalidOperationException($"Version '{secretVersion}' not found."); + + } + + public InMemoryKeyVaultSecretVersion GetVersion(string secretVersion) + { + return FindVersion(secretVersion) ?? throw new InvalidOperationException("Version not found"); + } + + public bool HasVersion(string secretVersion) => FindVersion(secretVersion) is not null; + public bool IsEnabled => _properties.Enabled ?? true; + + private InMemoryKeyVaultSecret Updated( + ImmutableArray? updatedVersions = null, + bool? isDeleted = null, + DateTimeOffset? deletedOn = null, + bool? isEnabled = null) + { + updatedVersions ??= _versions; + isDeleted ??= IsDeleted; + + return new( + vaultUri: _vaultUri, + secretName: _secretName, + versions: updatedVersions.Value, + timeProvider: _timeProvider, + createdOn: _properties.CreatedOn, + isDeleted: isDeleted.Value, + deletedOn: deletedOn, + isEnabled: isEnabled + ); + } + + public IReadOnlyList GetVersions() => _versions; + + public InMemoryKeyVaultSecret Deleted() => Updated(isDeleted: true, deletedOn: _timeProvider.GetUtcNow()); + + public SecretProperties ToClientProperties() => _properties; + +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Internals/InMemoryKeyVaultSecretVersion.cs b/src/Spotflow.InMemory.Azure.KeyVault/Internals/InMemoryKeyVaultSecretVersion.cs new file mode 100644 index 0000000..529d0c2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Internals/InMemoryKeyVaultSecretVersion.cs @@ -0,0 +1,63 @@ +using Azure.Security.KeyVault.Secrets; + +namespace Spotflow.InMemory.Azure.KeyVault.Internals; + +internal class InMemoryKeyVaultSecretVersion(Uri vaultUri, string secretName, string versionId, string value, SecretProperties properties, TimeProvider timeProvider) +{ + public string Label => versionId; + + public static InMemoryKeyVaultSecretVersion FromClientModel(Uri vaultUri, string secretName, KeyVaultSecret inputSecretVersion, TimeProvider timeProvider) + { + var version = GenerateSecretVersionLabel(); + + var id = new Uri(vaultUri, $"/secrets/{secretName}/{version}"); + + var now = timeProvider.GetUtcNow(); + + var properties = SecretModelFactory.SecretProperties( + id: id, + vaultUri: vaultUri, + name: secretName, + version: version, + createdOn: now, + updatedOn: now); + + SetAttributesAndTags(inputSecretVersion.Properties, properties); + + return new(vaultUri, secretName, version, inputSecretVersion.Value, properties, timeProvider); + } + + public InMemoryKeyVaultSecretVersion WithUpdatedProperties(SecretProperties inputProperties) + { + var now = timeProvider.GetUtcNow(); + + var updatedProperties = SecretModelFactory.SecretProperties( + id: properties.Id, + vaultUri: properties.VaultUri, + name: properties.Name, + version: properties.Version, + createdOn: properties.CreatedOn, + updatedOn: now); + + SetAttributesAndTags(inputProperties, updatedProperties); + + return new(vaultUri, secretName, versionId, value, updatedProperties, timeProvider); + } + + public KeyVaultSecret ToClientModel() => SecretModelFactory.KeyVaultSecret(properties, value); + + private static string GenerateSecretVersionLabel() => Guid.NewGuid().ToString("N"); + + private static void SetAttributesAndTags(SecretProperties source, SecretProperties target) + { + target.NotBefore = source.NotBefore; + target.ExpiresOn = source.ExpiresOn; + target.ContentType = source.ContentType; + target.Enabled = source.Enabled; + + foreach (var (k, v) in source.Tags) + { + target.Tags[k] = v; + } + } +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Internals/KeyVaultExceptionFactory.cs b/src/Spotflow.InMemory.Azure.KeyVault/Internals/KeyVaultExceptionFactory.cs new file mode 100644 index 0000000..12e26a5 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Internals/KeyVaultExceptionFactory.cs @@ -0,0 +1,49 @@ +using System.Runtime.CompilerServices; + +using Azure; + +namespace Spotflow.InMemory.Azure.KeyVault.Internals; + +internal static class KeyVaultExceptionFactory +{ + public static NotSupportedException MethodNotSupported([CallerMemberName] string? callerMemberName = null) + { + return new($"In-memory key vault client does not support method '{callerMemberName}'."); + } + + public static NotSupportedException FeatureNotSupported(string featureName) + { + return new($"In-memory key vault hub client does not support feature '{featureName}'."); + } + + public static RequestFailedException KeyVaultNotFound(Uri vaultUri) + { + return new($"Key Vault not found: {vaultUri}."); + } + + public static RequestFailedException SecretVersionNotFound(Uri vaultUri, string secretName, string secretVersion) + { + return new(404, $"Secret version '{secretName}/{secretVersion}' not found in vault '{vaultUri}'", "SecretNotFound", null); + } + + public static RequestFailedException SecretNotFound(Uri vaultUri, string secretName) + { + return new(404, $"Secret '{secretName}' not found in vault '{vaultUri}'", "SecretNotFound", null); + + } + + public static RequestFailedException SecretDisabled(Uri vaultUri, string secretName) + { + return new(403, $"Operation get is not allowed on a disabled secret. Vault = {vaultUri}, Secret = {secretName}", "Forbidden", null); + } + + public static RequestFailedException SecretIsDeleted(Uri vaultUri, string secretName) + { + return new(409, $"Secret '{secretName} is deleted in vault '{vaultUri}'", "SecretIsDeleted", null); + } + + public static RequestFailedException VersionNotSpecified(Uri vaultUri, string secretName) + { + return new(400, $"Version for secret '{secretName}' not found in vault '{vaultUri}' not specified.", "VersionNotSpecified", null); + } +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Resources/InMemoryKeyVault.cs b/src/Spotflow.InMemory.Azure.KeyVault/Resources/InMemoryKeyVault.cs new file mode 100644 index 0000000..6a6010d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Resources/InMemoryKeyVault.cs @@ -0,0 +1,385 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +using Azure; +using Azure.Security.KeyVault.Secrets; + +using Spotflow.InMemory.Azure.KeyVault.Internals; + +namespace Spotflow.InMemory.Azure.KeyVault.Resources; + +public class InMemoryKeyVault(string vaultName, InMemoryKeyVaultProvider provider) +{ + private readonly ConcurrentDictionary _secrets = new(ReferenceEqualityComparer.Instance); + + public string Name => vaultName; + public Uri VaultUri { get; } = new($"https://{vaultName}.{provider.HostnameSuffix}"); + + public InMemoryKeyVaultProvider Provider => provider; + + public bool TryGetSecret(string secretName, string? secretVersion, [NotNullWhen(true)] out KeyVaultSecret? result, [NotNullWhen(false)] out GetSecretError? error) + { + if (!TryGetActiveSecret(secretName, out var secret)) + { + result = null; + error = new GetSecretError.SecretNotFound(VaultUri, secretName); + return false; + } + + if (!secret.IsEnabled) + { + result = null; + error = new GetSecretError.SecretDisabled(VaultUri, secretName); + return false; + } + + if (secretVersion is null) + { + result = secret.GetLastVersion().ToClientModel(); + error = null; + return true; + } + else + { + var version = secret.FindVersion(secretVersion); + + if (version is null) + { + result = null; + error = new GetSecretError.VersionNotFound(VaultUri, secretName, secretVersion); + return false; + } + + result = version.ToClientModel(); + error = null; + return true; + } + } + + public bool TrySetSecret( + KeyVaultSecret inputSecretVersion, + [NotNullWhen(true)] out KeyVaultSecret? result, + [NotNullWhen(false)] out SetSecretError? error, + CancellationToken cancellationToken + ) + { + if (!ValidateSecret(inputSecretVersion, out var validationError)) + { + result = null; + error = validationError; + return false; + } + + var secretName = inputSecretVersion.Name; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_secrets.TryGetValue(secretName, out var existingSecret)) + { + if (existingSecret.IsDeleted) + { + result = null; + error = new SetSecretError.SecretIsDeleted(VaultUri, secretName); + return false; + } + + var updatedSecret = existingSecret.WithNewVersion(inputSecretVersion); + + if (_secrets.TryUpdate(secretName, updatedSecret, existingSecret)) // Retry if the secret has been updated since we last read it + { + result = updatedSecret.GetLastVersion().ToClientModel(); + error = null; + return true; + } + } + else + { + var newSecret = InMemoryKeyVaultSecret.CreateWithInitialVersion(VaultUri, secretName, inputSecretVersion, provider.TimeProvider); + + if (_secrets.TryAdd(secretName, newSecret)) // Retry if the secret has been added since we last checked + { + result = newSecret.GetLastVersion().ToClientModel(); + error = null; + return true; + } + } + } + } + + public bool TryListSecrets([NotNullWhen(true)] out IReadOnlyList? result, [NotNullWhen(false)] out ListSecretsError? error) + { + var items = new List(_secrets.Count); + + foreach (var (_, secret) in _secrets) + { + if (!secret.IsDeleted) + { + items.Add(secret.ToClientListItem()); + } + } + + error = null; + result = items; + return true; + } + + public bool TryListSecretVersions( + string secretName, + [NotNullWhen(true)] out IReadOnlyList? result, + [NotNullWhen(false)] out ListSecretVersionsError? error) + { + if (!TryGetActiveSecret(secretName, out var secret)) + { + result = null; + error = new ListSecretVersionsError.SecretNotFound(VaultUri, secretName); + return false; + } + + result = secret.GetVersions().Select(v => v.ToClientModel().Properties).ToList(); + error = null; + return true; + + } + + public bool TryStartDeleteSecret( + string secretName, + [NotNullWhen(true)] out DeleteSecretOperation? result, + [NotNullWhen(false)] out StartDeleteSecretError? error, + CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!TryGetActiveSecret(secretName, out var originalSecret)) + { + result = null; + error = new StartDeleteSecretError.SecretNotFound(VaultUri, secretName); + return false; + } + + var deletedSecret = originalSecret.Deleted(); + + if (_secrets.TryUpdate(secretName, deletedSecret, originalSecret)) + { + result = new InMemoryDeleteSecretOperation(deletedSecret); + error = null; + return true; + } + } + } + + public bool TryUpdateSecretVersionProperties( + SecretProperties properties, + [NotNullWhen(true)] out SecretProperties? result, + [NotNullWhen(false)] out UpdateSecretVersionPropertiesError? error, + CancellationToken cancellationToken) + { + var secretName = properties.Name; + var secretVersion = properties.Version; + + if (string.IsNullOrWhiteSpace(secretVersion)) + { + error = new UpdateSecretVersionPropertiesError.VersionNotSpecified(VaultUri, secretName); + result = null; + return false; + } + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!TryGetActiveSecret(properties.Name, out var originalSecret)) + { + error = new UpdateSecretVersionPropertiesError.SecretNotFound(VaultUri, secretName); + result = null; + return false; + } + + if (!originalSecret.HasVersion(secretVersion)) + { + error = new UpdateSecretVersionPropertiesError.VersionNotFound(VaultUri, secretName, secretVersion); + result = null; + return false; + } + + var updatedSecret = originalSecret.WithUpdatedVersionProperties(secretVersion, properties); + + // Retry if the secret has been updated since we last read it + if (_secrets.TryUpdate(secretName, updatedSecret, originalSecret)) + { + result = updatedSecret.GetVersion(secretVersion).ToClientModel().Properties; + error = null; + return true; + } + + } + } + + private bool TryGetActiveSecret(string secretName, [NotNullWhen(true)] out InMemoryKeyVaultSecret? secret) + { + if (!_secrets.TryGetValue(secretName, out secret)) + { + return false; + } + + if (secret.IsDeleted) + { + return false; + } + + return true; + } + + + private static bool ValidateSecret(KeyVaultSecret inputSecret, [NotNullWhen(false)] out SetSecretError? error) + { + if (!Regex.IsMatch(inputSecret.Name, @"^[a-zA-Z0-9-]{1,127}$")) + { + error = new SetSecretError.InvalidProperties("Secret name is invalid."); + return false; + } + + if (inputSecret.Properties.ContentType?.Length > 255) + { + error = new SetSecretError.InvalidProperties("Content type too long."); + return false; + } + + + if (inputSecret.Properties.Tags.Count > 15) + { + error = new SetSecretError.InvalidProperties("Too many tags."); + return false; + } + + var invalidTags = inputSecret + .Properties + .Tags + .Where(kv => kv.Key.Length > 512 || kv.Value.Length > 512) + .Select(kv => $"{kv.Key} = {kv.Value}") + .ToList(); + + if (invalidTags.Count > 0) + { + error = new SetSecretError.InvalidProperties($"Invalid tags: {string.Join(", ", invalidTags)}."); + return false; + } + + error = null; + return true; + } + + public abstract class StartDeleteSecretError + { + public abstract RequestFailedException GetClientException(); + + internal class SecretNotFound(Uri vaultUri, string secretName) : StartDeleteSecretError + { + public override RequestFailedException GetClientException() + { + return KeyVaultExceptionFactory.SecretNotFound(vaultUri, secretName); + } + } + } + + public abstract class ListSecretsError + { + public abstract RequestFailedException GetClientException(); + } + + public abstract class ListSecretVersionsError + { + public abstract RequestFailedException GetClientException(); + + public class SecretNotFound(Uri vaultUri, string secretName) : ListSecretVersionsError + { + public override RequestFailedException GetClientException() + { + return KeyVaultExceptionFactory.SecretNotFound(vaultUri, secretName); + } + } + } + + public abstract class GetSecretError + { + public abstract RequestFailedException GetClientException(); + + public class SecretNotFound(Uri vaultUri, string secretName) : GetSecretError + { + public override RequestFailedException GetClientException() + { + return KeyVaultExceptionFactory.SecretNotFound(vaultUri, secretName); + } + } + + public class VersionNotFound(Uri vaultUri, string secretName, string secretVersion) : GetSecretError + { + public override RequestFailedException GetClientException() + { + return KeyVaultExceptionFactory.SecretVersionNotFound(vaultUri, secretName, secretVersion); + } + } + + public class SecretDisabled(Uri vaultUri, string secretName) : GetSecretError + { + public override RequestFailedException GetClientException() + { + return KeyVaultExceptionFactory.SecretDisabled(vaultUri, secretName); + } + } + } + + public abstract class SetSecretError + { + public abstract RequestFailedException GetClientException(); + + public class InvalidProperties(string detail) : SetSecretError + { + public override RequestFailedException GetClientException() + { + return new RequestFailedException(400, $"Invalid properties: {detail}"); + } + } + + public class SecretIsDeleted(Uri vaultUri, string secretName) : SetSecretError + { + public override RequestFailedException GetClientException() + { + return KeyVaultExceptionFactory.SecretIsDeleted(vaultUri, secretName); + } + } + } + + public abstract class UpdateSecretVersionPropertiesError + { + public abstract RequestFailedException GetClientException(); + + public class SecretNotFound(Uri vaultUri, string secretName) : UpdateSecretVersionPropertiesError + { + public override RequestFailedException GetClientException() + { + return KeyVaultExceptionFactory.SecretNotFound(vaultUri, secretName); + } + } + + public class VersionNotSpecified(Uri vaultUri, string secretName) : UpdateSecretVersionPropertiesError + { + public override RequestFailedException GetClientException() + { + return KeyVaultExceptionFactory.VersionNotSpecified(vaultUri, secretName); + } + } + + public class VersionNotFound(Uri vaultUri, string secretName, string secretVersion) : UpdateSecretVersionPropertiesError + { + public override RequestFailedException GetClientException() + { + return KeyVaultExceptionFactory.SecretVersionNotFound(vaultUri, secretName, secretVersion); + } + } + } +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/GetSecretAfterHookContext.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/GetSecretAfterHookContext.cs new file mode 100644 index 0000000..75a3f3b --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/GetSecretAfterHookContext.cs @@ -0,0 +1,10 @@ +using Azure.Security.KeyVault.Secrets; + +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Contexts; + +public class GetSecretAfterHookContext(GetSecretBeforeHookContext before) : SecretAfterHookContext(before) +{ + public GetSecretBeforeHookContext BeforeContext => before; + + public required KeyVaultSecret Secret { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/GetSecretBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/GetSecretBeforeHookContext.cs new file mode 100644 index 0000000..3d9c010 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/GetSecretBeforeHookContext.cs @@ -0,0 +1,7 @@ +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Contexts; + +public class GetSecretBeforeHookContext(SecretScope scope, InMemoryKeyVaultProvider provider, CancellationToken cancellationToken) + : SecretBeforeHookContext(scope, SecretOperations.GetSecret, provider, cancellationToken) +{ + public required string? RequestedSecretVersion { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SecretAfterHookContext.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SecretAfterHookContext.cs new file mode 100644 index 0000000..f9e77d7 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SecretAfterHookContext.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; +using Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Contexts; + +public abstract class SecretAfterHookContext(SecretBeforeHookContext before) : KeyVaultAfterHookContext(before), ISecretOperation +{ + public SecretOperations Operation => before.Operation; + public string SecretName => before.SecretName; +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SecretBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SecretBeforeHookContext.cs new file mode 100644 index 0000000..b2acc81 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SecretBeforeHookContext.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; +using Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Contexts; +public abstract class SecretBeforeHookContext(SecretScope scope, SecretOperations operation, InMemoryKeyVaultProvider provider, CancellationToken cancellationToken) + : KeyVaultBeforeHookContext(scope, provider, cancellationToken), ISecretOperation +{ + public string SecretName => scope.SecretName; + public SecretOperations Operation => operation; +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SetSecretAfterHookContext.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SetSecretAfterHookContext.cs new file mode 100644 index 0000000..4a08699 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SetSecretAfterHookContext.cs @@ -0,0 +1,10 @@ +using Azure.Security.KeyVault.Secrets; + +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Contexts; + +public class SetSecretAfterHookContext(SetSecretBeforeHookContext before) : SecretAfterHookContext(before) +{ + public SetSecretBeforeHookContext BeforeContext => before; + + public required KeyVaultSecret CreatedSecret { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SetSecretBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SetSecretBeforeHookContext.cs new file mode 100644 index 0000000..d68d60a --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Contexts/SetSecretBeforeHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Security.KeyVault.Secrets; + +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Contexts; + +public class SetSecretBeforeHookContext(SecretScope scope, InMemoryKeyVaultProvider provider, CancellationToken cancellationToken) + : SecretBeforeHookContext(scope, SecretOperations.SetSecret, provider, cancellationToken) +{ + public required KeyVaultSecret Secret { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Internals/ISecretOperation.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Internals/ISecretOperation.cs new file mode 100644 index 0000000..0866776 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Internals/ISecretOperation.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Internals; + +internal interface ISecretOperation +{ + SecretOperations Operation { get; } + string SecretName { get; } +} + + + diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Internals/SecretHookFilter.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Internals/SecretHookFilter.cs new file mode 100644 index 0000000..e9abfaa --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/Internals/SecretHookFilter.cs @@ -0,0 +1,41 @@ +using Spotflow.InMemory.Azure.KeyVault.Hooks.Contexts; +using Spotflow.InMemory.Azure.KeyVault.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Internals; + +internal record SecretHookFilter : KeyVaultHookFilter +{ + public SecretHookFilter(KeyVaultHookFilter filter) : base(filter) { } + + public string? SecretName { get; private set; } + + public SecretOperations Operations { get; private set; } = SecretOperations.All; + + public override bool Covers(KeyVaultHookContext context) + { + var result = base.Covers(context); + + if (context is ISecretOperation secret) + { + result &= SecretName is null || secret.SecretName == SecretName; + result &= Operations.HasFlag(secret.Operation); + + return result; + } + + throw new InvalidOperationException($"Unexpected context: {context}"); + } + + internal SecretHookFilter With(string? secretName = null, SecretOperations? operations = null) + { + return this with + { + SecretName = secretName ?? SecretName, + Operations = operations ?? Operations + }; + } + +} + + + diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/SecretOperations.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/SecretOperations.cs new file mode 100644 index 0000000..ec3a955 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/SecretOperations.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks; + +[Flags] +public enum SecretOperations +{ + None = 0, + GetSecret = 1, + SetSecret = 2, + All = GetSecret | SetSecret +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/SecretScope.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/SecretScope.cs new file mode 100644 index 0000000..5585e56 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/Hooks/SecretScope.cs @@ -0,0 +1,4 @@ +using Spotflow.InMemory.Azure.KeyVault.Hooks; + +namespace Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks; +public record SecretScope(string VaultName, string SecretName) : KeyVaultScope(VaultName); diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Secrets/InMemorySecretClient.cs b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/InMemorySecretClient.cs new file mode 100644 index 0000000..5fd9254 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Secrets/InMemorySecretClient.cs @@ -0,0 +1,320 @@ +using Azure; +using Azure.Security.KeyVault.Secrets; + +using Spotflow.InMemory.Azure.Internals; +using Spotflow.InMemory.Azure.KeyVault.Internals; +using Spotflow.InMemory.Azure.KeyVault.Resources; +using Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks; +using Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.KeyVault.Secrets; + +public class InMemorySecretClient(Uri vaultUri, InMemoryKeyVaultProvider provider) : SecretClient +{ + private const int _defaultMaxPageSize = 10; + + public override Uri VaultUri { get; } = vaultUri; + public string VaultName { get; } = provider.GetVaultNameFromUri(vaultUri); + + public InMemoryKeyVaultProvider Provider => provider; + + public static InMemorySecretClient FromVault(InMemoryKeyVault vault) => new(vault.VaultUri, vault.Provider); + + #region GetSecret + + public override Response GetSecret(string name, string? version = null, CancellationToken cancellationToken = default) + { + return GetSecretAsync(name, version, cancellationToken).EnsureCompleted(); + } + + public override async Task> GetSecretAsync(string name, string? version = null, CancellationToken cancellationToken = default) + { + return await GetSecretCoreAsync(name, version, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + private async Task> GetSecretCoreAsync(string name, string? version, CancellationToken cancellationToken) + { + var scope = new SecretScope(VaultName, name); + + var beforeContext = new GetSecretBeforeHookContext(scope, Provider, cancellationToken) + { + RequestedSecretVersion = version + }; + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + var vault = GetVault(); + + if (!vault.TryGetSecret(name, version, out var secret, out var error)) + { + throw error.GetClientException(); + } + + var afterContext = new GetSecretAfterHookContext(beforeContext) + { + Secret = secret + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return InMemoryResponse.FromValue(secret, 200); + } + + #endregion + + #region SetSecret + + public override async Task> SetSecretAsync(KeyVaultSecret secret, CancellationToken cancellationToken = default) + { + return await SetSecretCoreAsync(secret, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + public override Response SetSecret(KeyVaultSecret secret, CancellationToken cancellationToken = default) + { + return SetSecretAsync(secret, cancellationToken).EnsureCompleted(); + } + + public override Response SetSecret(string name, string value, CancellationToken cancellationToken = default) + { + return SetSecretAsync(name, value, cancellationToken).EnsureCompleted(); + } + + public override Task> SetSecretAsync(string name, string value, CancellationToken cancellationToken = default) + { + return SetSecretAsync(new KeyVaultSecret(name, value), cancellationToken); + } + + private async Task> SetSecretCoreAsync(KeyVaultSecret secret, CancellationToken cancellationToken) + { + var scope = new SecretScope(VaultName, secret.Name); + + var beforeContext = new SetSecretBeforeHookContext(scope, Provider, cancellationToken) + { + Secret = secret + }; + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + var vault = GetVault(); + + if (!vault.TrySetSecret(secret, out var createdSecret, out var error, cancellationToken)) + { + throw error.GetClientException(); + } + + var afterContext = new SetSecretAfterHookContext(beforeContext) + { + CreatedSecret = createdSecret + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return InMemoryResponse.FromValue(createdSecret, 200); + } + + #endregion + + #region GetPropertiesOfSecrets + + public override Pageable GetPropertiesOfSecrets(CancellationToken cancellationToken = default) + { + var pages = GetPropertiesOfSecretsCoreAsync() + .ConfigureAwait(ConfigureAwaitOptions.ForceYielding) + .EnsureCompleted(); + + return new InMemoryPageable.Sync(pages, _defaultMaxPageSize); + } + + public override AsyncPageable GetPropertiesOfSecretsAsync(CancellationToken cancellationToken = default) + { + var pages = GetPropertiesOfSecretsCoreAsync() + .ConfigureAwait(ConfigureAwaitOptions.ForceYielding) + .EnsureCompleted(); + + return new InMemoryPageable.YieldingAsync(pages, _defaultMaxPageSize); + } + + private Task> GetPropertiesOfSecretsCoreAsync() + { + var vault = GetVault(); + + if (!vault.TryListSecrets(out var secrets, out var error)) + { + throw error.GetClientException(); + } + + return Task.FromResult(secrets); + } + + #endregion + + #region GetPropertiesOfSecretVersions + + public override AsyncPageable GetPropertiesOfSecretVersionsAsync(string name, CancellationToken cancellationToken = default) + { + var secrets = GetPropertiesOfSecretVersionsCoreAsync(name, cancellationToken) + .ConfigureAwait(ConfigureAwaitOptions.ForceYielding) + .EnsureCompleted(); + + return new InMemoryPageable.YieldingAsync(secrets, _defaultMaxPageSize); + } + + public override Pageable GetPropertiesOfSecretVersions(string name, CancellationToken cancellationToken = default) + { + var secrets = GetPropertiesOfSecretVersionsCoreAsync(name, cancellationToken) + .ConfigureAwait(ConfigureAwaitOptions.ForceYielding) + .EnsureCompleted(); + + return new InMemoryPageable.Sync(secrets, _defaultMaxPageSize); + } + + private Task> GetPropertiesOfSecretVersionsCoreAsync(string name, CancellationToken cancellationToken) + { + var vault = GetVault(); + + if (!vault.TryListSecretVersions(name, out var versions, out var error)) + { + throw error.GetClientException(); + } + + return Task.FromResult(versions); + } + + #endregion + + #region StartDeleteSecret + + public override async Task StartDeleteSecretAsync(string name, CancellationToken cancellationToken = default) + { + return await StartDeleteSecretCoreAsync(name, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + public override DeleteSecretOperation StartDeleteSecret(string name, CancellationToken cancellationToken = default) + { + return StartDeleteSecretAsync(name, cancellationToken).EnsureCompleted(); + } + + private Task StartDeleteSecretCoreAsync(string name, CancellationToken cancellationToken) + { + var vault = GetVault(); + + if (!vault.TryStartDeleteSecret(name, out var operation, out var error, cancellationToken)) + { + throw error.GetClientException(); + } + + return Task.FromResult(operation); + } + + #endregion + + #region UpdateSecretProperties + + public override async Task> UpdateSecretPropertiesAsync(SecretProperties properties, CancellationToken cancellationToken = default) + { + return await UpdateSecretPropertiesCoreAsync(properties, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + public override Response UpdateSecretProperties(SecretProperties properties, CancellationToken cancellationToken = default) + { + return UpdateSecretPropertiesAsync(properties, cancellationToken).EnsureCompleted(); + } + + private Task> UpdateSecretPropertiesCoreAsync(SecretProperties properties, CancellationToken cancellationToken) + { + var vault = GetVault(); + + if (!vault.TryUpdateSecretVersionProperties(properties, out var updatedProperties, out var error, cancellationToken)) + { + throw error.GetClientException(); + } + + return Task.FromResult(InMemoryResponse.FromValue(updatedProperties, 200)); + } + + #endregion + + private InMemoryKeyVault GetVault() + { + if (!provider.TryGetVaultByUri(VaultUri, out var vault)) + { + throw KeyVaultExceptionFactory.KeyVaultNotFound(VaultUri); + } + + return vault; + } + + private Task ExecuteBeforeHooksAsync(TContext context) where TContext : SecretBeforeHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + private Task ExecuteAfterHooksAsync(TContext context) where TContext : SecretAfterHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + #region Unsupported + + public override Task> GetDeletedSecretAsync(string name, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override Response GetDeletedSecret(string name, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override AsyncPageable GetDeletedSecretsAsync(CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override Pageable GetDeletedSecrets(CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override Task StartRecoverDeletedSecretAsync(string name, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override RecoverDeletedSecretOperation StartRecoverDeletedSecret(string name, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override Task PurgeDeletedSecretAsync(string name, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override Response PurgeDeletedSecret(string name, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override Task> BackupSecretAsync(string name, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override Response BackupSecret(string name, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override Task> RestoreSecretBackupAsync(byte[] backup, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + public override Response RestoreSecretBackup(byte[] backup, CancellationToken cancellationToken = default) + { + throw KeyVaultExceptionFactory.MethodNotSupported(); + } + + #endregion +} diff --git a/src/Spotflow.InMemory.Azure.KeyVault/Spotflow.InMemory.Azure.KeyVault.csproj b/src/Spotflow.InMemory.Azure.KeyVault/Spotflow.InMemory.Azure.KeyVault.csproj new file mode 100644 index 0000000..fae1230 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.KeyVault/Spotflow.InMemory.Azure.KeyVault.csproj @@ -0,0 +1,21 @@ + + + + In-memory implementation of the Azure Key Vault clients for convenient testing. + $(PackageTags);KeyVault;Secrets + true + README.md + + + + + + + + + + + + + + diff --git a/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/InMemoryServiceBusQueueAssertions.cs b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/InMemoryServiceBusQueueAssertions.cs new file mode 100644 index 0000000..04c3243 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/InMemoryServiceBusQueueAssertions.cs @@ -0,0 +1,19 @@ +using FluentAssertions.Primitives; + +using Spotflow.InMemory.Azure.ServiceBus.FluentAssertions.Internal; +using Spotflow.InMemory.Azure.ServiceBus.Resources; + +namespace Spotflow.InMemory.Azure.ServiceBus.FluentAssertions; + +public class InMemoryServiceBusQueueAssertions(InMemoryServiceBusQueue subject) + : ReferenceTypeAssertions(subject) +{ + protected override string Identifier => nameof(InMemoryServiceBusQueue); + + public async Task BeEmptyAsync(TimeSpan? maxWaitTime = null, string? because = null, params object[] becauseArgs) + { + var entity = $"{Subject.QueueName}"; + + await ServiceBusAssertionHelpers.EntityShouldBeEmptyAsync(entity, () => Subject.MessageCount, maxWaitTime, because, becauseArgs); + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/InMemoryServiceBusTopicSubscriptionAssertions.cs b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/InMemoryServiceBusTopicSubscriptionAssertions.cs new file mode 100644 index 0000000..779d5ac --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/InMemoryServiceBusTopicSubscriptionAssertions.cs @@ -0,0 +1,19 @@ +using FluentAssertions.Primitives; + +using Spotflow.InMemory.Azure.ServiceBus.FluentAssertions.Internal; +using Spotflow.InMemory.Azure.ServiceBus.Resources; + +namespace Spotflow.InMemory.Azure.ServiceBus.FluentAssertions; + +public class InMemoryServiceBusTopicSubscriptionAssertions(InMemoryServiceBusSubscription subject) + : ReferenceTypeAssertions(subject) +{ + protected override string Identifier => nameof(InMemoryServiceBusSubscription); + + public async Task BeEmptyAsync(TimeSpan? maxWaitTime = null, string? because = null, params object[] becauseArgs) + { + var entity = $"{Subject.Topic.TopicName}/{Subject.SubscriptionName}"; + + await ServiceBusAssertionHelpers.EntityShouldBeEmptyAsync(entity, () => Subject.MessageCount, maxWaitTime, because, becauseArgs); + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/Internal/ServiceBusAssertionHelpers.cs b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/Internal/ServiceBusAssertionHelpers.cs new file mode 100644 index 0000000..933ec0c --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/Internal/ServiceBusAssertionHelpers.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; + +using FluentAssertions.Execution; + +namespace Spotflow.InMemory.Azure.ServiceBus.FluentAssertions.Internal; + +internal static class ServiceBusAssertionHelpers +{ + public static async Task EntityShouldBeEmptyAsync(string entity, Func count, TimeSpan? maxWaitTime = null, string? because = null, params object[] becauseArgs) + { + maxWaitTime ??= TimeSpan.FromSeconds(8); + + var startTime = Stopwatch.GetTimestamp(); + + long? lastCount = null; + + while (Stopwatch.GetElapsedTime(startTime) < maxWaitTime) + { + lastCount = count(); + + if (lastCount == 0) + { + return; + } + + await Task.Delay(10); + } + + Execute + .Assertion + .BecauseOf(because, becauseArgs) + .FailWith( + "Entity {0} should be empty{reason} but {1} messages found after {2} seconds.", + entity, + lastCount, + maxWaitTime.Value.TotalSeconds); + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/ShouldExtensions.cs b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/ShouldExtensions.cs new file mode 100644 index 0000000..21dc174 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/ShouldExtensions.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.ServiceBus.Resources; + +namespace Spotflow.InMemory.Azure.ServiceBus.FluentAssertions; + +public static class ShouldExtensions +{ + public static InMemoryServiceBusTopicSubscriptionAssertions Should(this InMemoryServiceBusSubscription subscription) => new(subscription); + + public static InMemoryServiceBusQueueAssertions Should(this InMemoryServiceBusQueue topic) => new(topic); +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions.csproj b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions.csproj new file mode 100644 index 0000000..23fa5e4 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions/Spotflow.InMemory.Azure.ServiceBus.FluentAssertions.csproj @@ -0,0 +1,22 @@ + + + + FluentAssertions extensions for in-memory implementation of the Azure Service Bus clients for convenient testing. + $(PackageTags);ServiceBus;FluentAssertions + true + README.md + + + + + + + + + + + + + + + diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ConsumerOperations.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ConsumerOperations.cs new file mode 100644 index 0000000..8307321 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ConsumerOperations.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks; + +[Flags] +public enum ConsumerOperations +{ + None = 0, + ReceiveMessage = 1, + ReceiveBatch = 2, + All = ReceiveMessage | ReceiveBatch +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ConsumerAfterHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ConsumerAfterHookContext.cs new file mode 100644 index 0000000..6beb31f --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ConsumerAfterHookContext.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public abstract class ConsumerAfterHookContext(ConsumerBeforeHookContext before) : ServiceBusAfterHookContext(before), IConsumerOperation +{ + public ConsumerOperations Operation => before.Operation; + public bool IsTopicSubscription => before.IsTopicSubscription; +} + diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ConsumerBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ConsumerBeforeHookContext.cs new file mode 100644 index 0000000..746b03d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ConsumerBeforeHookContext.cs @@ -0,0 +1,11 @@ +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public abstract class ConsumerBeforeHookContext(ServiceBusConsumerHookScope scope, ConsumerOperations operation, InMemoryServiceBusProvider provider, CancellationToken cancellationToken) + : ServiceBusBeforeHookContext(scope, provider, cancellationToken), IConsumerOperation +{ + public ConsumerOperations Operation => operation; + public bool IsTopicSubscription => scope.IsTopicSubscription; +} + diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ProducerAfterHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ProducerAfterHookContext.cs new file mode 100644 index 0000000..ae1008c --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ProducerAfterHookContext.cs @@ -0,0 +1,9 @@ +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public abstract class ProducerAfterHookContext(ProducerBeforeHookContext before) : ServiceBusAfterHookContext(before), IProducerOperation +{ + public ProducerOperations Operation => before.Operation; +} + diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ProducerBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ProducerBeforeHookContext.cs new file mode 100644 index 0000000..ed1ccae --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ProducerBeforeHookContext.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public abstract class ProducerBeforeHookContext(ServiceBusProducerHookScope scope, ProducerOperations operation, InMemoryServiceBusProvider provider, CancellationToken cancellationToken) + : ServiceBusBeforeHookContext(scope, provider, cancellationToken), IProducerOperation +{ + public ProducerOperations Operation => operation; +} + diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveBatchAfterHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveBatchAfterHookContext.cs new file mode 100644 index 0000000..f1268c3 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveBatchAfterHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public class ReceiveBatchAfterHookContext(ReceiveBatchBeforeHookContext before) : ConsumerAfterHookContext(before) +{ + public required IReadOnlyList Messages { get; init; } + public ReceiveBatchBeforeHookContext BeforeContext => before; +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveBatchBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveBatchBeforeHookContext.cs new file mode 100644 index 0000000..0aef613 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveBatchBeforeHookContext.cs @@ -0,0 +1,4 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public class ReceiveBatchBeforeHookContext(ServiceBusConsumerHookScope scope, InMemoryServiceBusProvider provider, CancellationToken cancellationToken) + : ConsumerBeforeHookContext(scope, ConsumerOperations.ReceiveBatch, provider, cancellationToken); diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveMessageAfterHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveMessageAfterHookContext.cs new file mode 100644 index 0000000..281221e --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveMessageAfterHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public class ReceiveMessageAfterHookContext(ReceiveMessageBeforeHookContext before) : ConsumerAfterHookContext(before) +{ + public required ServiceBusReceivedMessage? Message { get; init; } + public ReceiveMessageBeforeHookContext BeforeContext => before; +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveMessageBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveMessageBeforeHookContext.cs new file mode 100644 index 0000000..9e95aa7 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ReceiveMessageBeforeHookContext.cs @@ -0,0 +1,4 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public class ReceiveMessageBeforeHookContext(ServiceBusConsumerHookScope scope, InMemoryServiceBusProvider provider, CancellationToken cancellationToken) + : ConsumerBeforeHookContext(scope, ConsumerOperations.ReceiveMessage, provider, cancellationToken); diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendBatchAfterHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendBatchAfterHookContext.cs new file mode 100644 index 0000000..ca40cec --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendBatchAfterHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public class SendBatchAfterHookContext(SendBatchBeforeHookContext before) : ProducerAfterHookContext(before) +{ + public required IReadOnlyList Messages { get; init; } + public SendBatchBeforeHookContext BeforeContext => before; +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendBatchBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendBatchBeforeHookContext.cs new file mode 100644 index 0000000..c0d2234 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendBatchBeforeHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public class SendBatchBeforeHookContext(ServiceBusProducerHookScope scope, InMemoryServiceBusProvider provider, CancellationToken cancellationToken) + : ProducerBeforeHookContext(scope, ProducerOperations.SendBatch, provider, cancellationToken) +{ + public required IReadOnlyList Messages { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendMessageAfterHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendMessageAfterHookContext.cs new file mode 100644 index 0000000..da101fe --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendMessageAfterHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public class SendMessageAfterHookContext(SendMessageBeforeHookContext before) : ProducerAfterHookContext(before) +{ + public required ServiceBusMessage Message { get; init; } + public SendMessageBeforeHookContext BeforeContext => before; +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendMessageBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendMessageBeforeHookContext.cs new file mode 100644 index 0000000..a35f472 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/SendMessageBeforeHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public class SendMessageBeforeHookContext(ServiceBusProducerHookScope scope, InMemoryServiceBusProvider provider, CancellationToken cancellationToken) + : ProducerBeforeHookContext(scope, ProducerOperations.SendMessage, provider, cancellationToken) +{ + public required ServiceBusMessage Message { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ServiceBusAfterHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ServiceBusAfterHookContext.cs new file mode 100644 index 0000000..1699765 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ServiceBusAfterHookContext.cs @@ -0,0 +1,5 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public abstract class ServiceBusAfterHookContext(ServiceBusBeforeHookContext before) + : ServiceBusHookContext(before.ServiceBusNamespaceName, before.EntityPath, before.ResourceProvider, before.CancellationToken); + diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ServiceBusBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ServiceBusBeforeHookContext.cs new file mode 100644 index 0000000..d6baea0 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ServiceBusBeforeHookContext.cs @@ -0,0 +1,4 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public abstract class ServiceBusBeforeHookContext(ServiceBusHookScope scope, InMemoryServiceBusProvider provider, CancellationToken cancellationToken) + : ServiceBusHookContext(scope.ServiceBusNamespaceName, scope.EntityPath, provider, cancellationToken); diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ServiceBusHookContext.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ServiceBusHookContext.cs new file mode 100644 index 0000000..afd972c --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Contexts/ServiceBusHookContext.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +public abstract class ServiceBusHookContext(string serviceBusNamespaceName, string entityPath, InMemoryServiceBusProvider provider, CancellationToken cancellationToken) +{ + public string ServiceBusNamespaceName => serviceBusNamespaceName; + public string EntityPath => entityPath; + public CancellationToken CancellationToken => cancellationToken; + public InMemoryServiceBusProvider ResourceProvider => provider; + public TimeProvider TimeProvider => provider.TimeProvider; +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/ConsumerHookFilter.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/ConsumerHookFilter.cs new file mode 100644 index 0000000..43973c0 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/ConsumerHookFilter.cs @@ -0,0 +1,33 @@ +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +internal record ConsumerHookFilter : ServiceBusHookFilter +{ + public ConsumerHookFilter(ServiceBusHookFilter filter, bool? isTopicSubscription) : base(filter) + { + IsTopicSubscription = isTopicSubscription; + } + + public ConsumerOperations Operations { get; private init; } = ConsumerOperations.All; + public bool? IsTopicSubscription { get; } + + public override bool Covers(ServiceBusHookContext context) + { + var result = base.Covers(context); + + if (context is IConsumerOperation producer) + { + result &= Operations.HasFlag(producer.Operation); + result &= IsTopicSubscription == null || producer.IsTopicSubscription == IsTopicSubscription; + return result; + } + + throw new InvalidOperationException($"Unexpected context: {context}"); + } + + public ConsumerHookFilter With(ConsumerOperations? operations) + { + return this with { Operations = operations ?? Operations }; + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/IConsumerOperation.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/IConsumerOperation.cs new file mode 100644 index 0000000..0bb69f5 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/IConsumerOperation.cs @@ -0,0 +1,7 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +internal interface IConsumerOperation +{ + ConsumerOperations Operation { get; } + bool IsTopicSubscription { get; } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/IProducerOperation.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/IProducerOperation.cs new file mode 100644 index 0000000..03a99f2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/IProducerOperation.cs @@ -0,0 +1,6 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +internal interface IProducerOperation +{ + ProducerOperations Operation { get; } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/ProducerHookFilter.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/ProducerHookFilter.cs new file mode 100644 index 0000000..ae04682 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/ProducerHookFilter.cs @@ -0,0 +1,31 @@ +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +internal record ProducerHookFilter : ServiceBusHookFilter +{ + public ProducerHookFilter(ServiceBusHookFilter filter) : base(filter) { } + + public ProducerOperations Operations { get; private init; } = ProducerOperations.All; + + public override bool Covers(ServiceBusHookContext context) + { + var result = base.Covers(context); + + if (context is IProducerOperation producer) + { + result &= Operations.HasFlag(producer.Operation); + return result; + } + + throw new InvalidOperationException($"Unexpected context: {context}"); + } + + public ProducerHookFilter With(ProducerOperations? operations) + { + return new(this) + { + Operations = operations ?? Operations + }; + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/ServiceBusHookFilter.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/ServiceBusHookFilter.cs new file mode 100644 index 0000000..07dd996 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/Internals/ServiceBusHookFilter.cs @@ -0,0 +1,29 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +internal record ServiceBusHookFilter : BaseHookFilter +{ + public string? ServiceBusNamespaceName { get; init; } + public string? EntityPath { get; init; } + + public override bool Covers(ServiceBusHookContext context) + { + var result = true; + + result &= ServiceBusNamespaceName == null || context.ServiceBusNamespaceName == ServiceBusNamespaceName; + result &= EntityPath == null || context.EntityPath == EntityPath; + + return result; + } + + public ServiceBusHookFilter With(string? serviceBusNamespaceName, string? entityPath) + { + return this with + { + ServiceBusNamespaceName = serviceBusNamespaceName ?? ServiceBusNamespaceName, + EntityPath = entityPath ?? EntityPath + }; + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ProducerOperations.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ProducerOperations.cs new file mode 100644 index 0000000..4d95f46 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ProducerOperations.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks; + +[Flags] +public enum ProducerOperations +{ + None = 0, + SendMessage = 1, + SendBatch = 2, + All = SendMessage | SendBatch +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusConsumerHookScope.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusConsumerHookScope.cs new file mode 100644 index 0000000..69cbca3 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusConsumerHookScope.cs @@ -0,0 +1,6 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks; + +public record ServiceBusConsumerHookScope( + string ServiceBusNamespaceName, + string EntityPath, + bool IsTopicSubscription) : ServiceBusHookScope(ServiceBusNamespaceName, EntityPath); diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusHook.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusHook.cs new file mode 100644 index 0000000..a1fe270 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusHook.cs @@ -0,0 +1,17 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks; + +public class ServiceBusHook where TContext : ServiceBusHookContext +{ + internal ServiceBusHook(HookFunc hookFunction, ServiceBusHookFilter filter) + { + HookFunction = hookFunction; + Filter = filter; + } + + public HookFunc HookFunction { get; } + internal ServiceBusHookFilter Filter { get; } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusHookBuilder.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusHookBuilder.cs new file mode 100644 index 0000000..c89acc9 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusHookBuilder.cs @@ -0,0 +1,74 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks; + +public class ServiceBusHookBuilder +{ + private readonly ServiceBusHookFilter _filter; + + internal ServiceBusHookBuilder(ServiceBusHookFilter? filter = null) + { + _filter = filter ?? new(); + } + + public ProducerHookBuilder ForProducer(string? serviceBusNamespaceName = null, string? entityPath = null) + { + return new(_filter.With(serviceBusNamespaceName, entityPath)); + } + public ConsumerHookBuilder ForConsumer(string? serviceBusNamespaceName = null, string? entityPath = null, bool? isTopicSubscription = null) + { + return new(_filter.With(serviceBusNamespaceName, entityPath), isTopicSubscription); + } + public ServiceBusHook Before(HookFunc hook, string? serviceBusNamespaceName = null, string? entityPath = null) + { + return new(hook, _filter.With(serviceBusNamespaceName, entityPath)); + } + public ServiceBusHook After(HookFunc hook, string? serviceBusNamespaceName = null, string? entityPath = null) + { + return new(hook, _filter.With(serviceBusNamespaceName, entityPath)); + } + + + public class ProducerHookBuilder + { + private readonly ProducerHookFilter _filter; + + internal ProducerHookBuilder(ServiceBusHookFilter filter) + { + _filter = new(filter); + } + public ServiceBusHook Before(HookFunc hook, ProducerOperations? operations = null) => new(hook, _filter.With(operations)); + public ServiceBusHook After(HookFunc hook, ProducerOperations? operations = null) => new(hook, _filter.With(operations)); + + public ServiceBusHook BeforeSendBatch(HookFunc hook) => new(hook, _filter); + + public ServiceBusHook AfterSendBatch(HookFunc hook) => new(hook, _filter); + + public ServiceBusHook BeforeSendMessage(HookFunc hook) => new(hook, _filter); + + public ServiceBusHook AfterSendMessage(HookFunc hook) => new(hook, _filter); + } + + public class ConsumerHookBuilder + { + private readonly ConsumerHookFilter _filter; + + internal ConsumerHookBuilder(ServiceBusHookFilter filter, bool? isTopicSubscription) + { + _filter = new(filter, isTopicSubscription); + } + + public ServiceBusHook Before(HookFunc hook, ConsumerOperations? operations = null) => new(hook, _filter.With(operations)); + public ServiceBusHook After(HookFunc hook, ConsumerOperations? operations = null) => new(hook, _filter.With(operations)); + + public ServiceBusHook AfterReceiveBatch(HookFunc hook) => new(hook, _filter); + + public ServiceBusHook AfterReceiveBatch(HookFunc hook) => new(hook, _filter); + + public ServiceBusHook AfterReceiveMessage(HookFunc hook) => new(hook, _filter); + + public ServiceBusHook AfterReceiveMessage(HookFunc hook) => new(hook, _filter); + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusHookScope.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusHookScope.cs new file mode 100644 index 0000000..0348fec --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusHookScope.cs @@ -0,0 +1,3 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks; + +public record ServiceBusHookScope(string ServiceBusNamespaceName, string EntityPath); diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusProducerHookScope.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusProducerHookScope.cs new file mode 100644 index 0000000..da252a5 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Hooks/ServiceBusProducerHookScope.cs @@ -0,0 +1,3 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Hooks; + +public record ServiceBusProducerHookScope(string ServiceBusNamespaceName, string EntityPath) : ServiceBusHookScope(ServiceBusNamespaceName, EntityPath); diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusClient.cs b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusClient.cs new file mode 100644 index 0000000..42cf528 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusClient.cs @@ -0,0 +1,211 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Core; +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.Auth; +using Spotflow.InMemory.Azure.ServiceBus.Internals; +using Spotflow.InMemory.Azure.ServiceBus.Resources; + +namespace Spotflow.InMemory.Azure.ServiceBus; + +public class InMemoryServiceBusClient : ServiceBusClient +{ + + private volatile bool _isClosed; + + #region Constructors + + public InMemoryServiceBusClient(string connectionString, InMemoryServiceBusProvider provider) + : this(ServiceBusClientUtils.GetFullyQualifiedNamespace(connectionString), new(), provider, null) { } + + public InMemoryServiceBusClient(string connectionString, ServiceBusClientOptions options, InMemoryServiceBusProvider provider) + : this(ServiceBusClientUtils.GetFullyQualifiedNamespace(connectionString), options, provider, null) { } + + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Distinguishing from other constructors")] + public InMemoryServiceBusClient(string fullyQualifiedNamespace, TokenCredential credential, InMemoryServiceBusProvider provider) + : this(fullyQualifiedNamespace, new(), provider, null) { } + + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Distinguishing from other constructors")] + public InMemoryServiceBusClient(string fullyQualifiedNamespace, TokenCredential credential, ServiceBusClientOptions options, InMemoryServiceBusProvider provider) + : this(fullyQualifiedNamespace, options, provider, null) { } + + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Distinguishing from other constructors")] + private InMemoryServiceBusClient(string fullyQualifiedNamespace, ServiceBusClientOptions options, InMemoryServiceBusProvider provider, object? dummy) + { + Provider = provider; + DefaultMaxWaitTime = options.RetryOptions.MaxDelay; + FullyQualifiedNamespace = fullyQualifiedNamespace; + Identifier = options.Identifier ?? Guid.NewGuid().ToString(); + } + + public static InMemoryServiceBusClient FromNamespace(InMemoryServiceBusNamespace serviceBusNamespace, ServiceBusClientOptions? options = null) + { + return new(serviceBusNamespace.FullyQualifiedNamespace, NoOpTokenCredential.Instance, options ?? new(), serviceBusNamespace.Provider); + } + + #endregion + + public InMemoryServiceBusProvider Provider { get; } + + internal TimeSpan DefaultMaxWaitTime { get; } + + #region Properties + public override string FullyQualifiedNamespace { get; } + + public override bool IsClosed => _isClosed; + + public override string Identifier { get; } + + #endregion + + #region Dispose + + public override async ValueTask DisposeAsync() + { + await Task.Yield(); + + _isClosed = true; + + } + + #endregion + + #region CreateSender + + public override ServiceBusSender CreateSender(string queueOrTopicName) => CreateSender(queueOrTopicName, new()); + + public override ServiceBusSender CreateSender(string queueOrTopicName, ServiceBusSenderOptions options) + { + return new InMemoryServiceBusSender(this, queueOrTopicName, options); + } + + #endregion + + #region CreateReceiver + + public override ServiceBusReceiver CreateReceiver(string queueName) => CreateReceiver(queueName, new ServiceBusReceiverOptions()); + + public override ServiceBusReceiver CreateReceiver(string queueName, ServiceBusReceiverOptions options) + { + return new InMemoryServiceBusReceiver(this, queueName, options); + } + + public override ServiceBusReceiver CreateReceiver(string topicName, string subscriptionName) => CreateReceiver(topicName, subscriptionName, new ServiceBusReceiverOptions()); + + public override ServiceBusReceiver CreateReceiver(string topicName, string subscriptionName, ServiceBusReceiverOptions options) + { + return new InMemoryServiceBusReceiver(this, topicName, subscriptionName, options); + } + + #endregion + + #region AcceptNextSession + + public override async Task AcceptNextSessionAsync(string queueName, ServiceBusSessionReceiverOptions? options = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var queue = ServiceBusClientUtils.GetQueue(FullyQualifiedNamespace, queueName, Provider); + + if (queue.Engine is not SessionEngine store) + { + throw ServiceBusExceptionFactory.SessionsNotEnabled(FullyQualifiedNamespace, queue.EntityPath); + } + + var session = await store.TryAcquireNextAvailableSessionAsync(DefaultMaxWaitTime, cancellationToken); + + if (session is null) + { + throw ServiceBusExceptionFactory.NoSessionAvailable(FullyQualifiedNamespace, queue.EntityPath); + } + + return new InMemoryServiceBusSessionReceiver(session, options ?? new(), DefaultMaxWaitTime, Provider); + } + + public override async Task AcceptNextSessionAsync(string topicName, string subscriptionName, ServiceBusSessionReceiverOptions? options = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var subscription = ServiceBusClientUtils.GetSubscription(FullyQualifiedNamespace, topicName, subscriptionName, Provider); + + + if (subscription.Engine is not SessionEngine store) + { + throw ServiceBusExceptionFactory.SessionsNotEnabled(FullyQualifiedNamespace, subscription.EntityPath); + } + + var session = await store.TryAcquireNextAvailableSessionAsync(DefaultMaxWaitTime, cancellationToken); + + if (session is null) + { + throw ServiceBusExceptionFactory.NoSessionAvailable(FullyQualifiedNamespace, subscription.EntityPath); + } + + return new InMemoryServiceBusSessionReceiver(session, options ?? new(), DefaultMaxWaitTime, Provider); + } + + #endregion + + #region AcceptSession + public override async Task AcceptSessionAsync(string queueName, string sessionId, ServiceBusSessionReceiverOptions? options = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var queue = ServiceBusClientUtils.GetQueue(FullyQualifiedNamespace, queueName, Provider); + + if (queue.Engine is not SessionEngine store) + { + throw ServiceBusExceptionFactory.SessionsNotEnabled(FullyQualifiedNamespace, queue.EntityPath); + } + + if (!store.TryAcquireSession(sessionId, out var session)) + { + throw ServiceBusExceptionFactory.SessionNotFound(FullyQualifiedNamespace, queue.EntityPath, sessionId); + }; + + return new InMemoryServiceBusSessionReceiver(session, options ?? new(), DefaultMaxWaitTime, Provider); + + } + + public override async Task AcceptSessionAsync(string topicName, string subscriptionName, string sessionId, ServiceBusSessionReceiverOptions? options = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var subscription = ServiceBusClientUtils.GetSubscription(FullyQualifiedNamespace, topicName, subscriptionName, Provider); + + if (subscription.Engine is not SessionEngine store) + { + throw ServiceBusExceptionFactory.SessionsNotEnabled(FullyQualifiedNamespace, subscription.EntityPath); + } + + if (!store.TryAcquireSession(sessionId, out var session)) + { + throw ServiceBusExceptionFactory.SessionNotFound(FullyQualifiedNamespace, subscription.EntityPath, sessionId); + } + + return new InMemoryServiceBusSessionReceiver(session, options ?? new(), DefaultMaxWaitTime, Provider); + + } + + + #endregion + + #region Unsupported + + public override ServiceBusProcessor CreateProcessor(string queueName) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override ServiceBusProcessor CreateProcessor(string queueName, ServiceBusProcessorOptions options) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName, ServiceBusProcessorOptions options) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override ServiceBusSessionProcessor CreateSessionProcessor(string queueName, ServiceBusSessionProcessorOptions? options = null) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override ServiceBusSessionProcessor CreateSessionProcessor(string topicName, string subscriptionName, ServiceBusSessionProcessorOptions? options = null) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override ServiceBusRuleManager CreateRuleManager(string topicName, string subscriptionName) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + #endregion +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusProvider.cs b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusProvider.cs new file mode 100644 index 0000000..6d8be9d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusProvider.cs @@ -0,0 +1,104 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Hooks.Internals; +using Spotflow.InMemory.Azure.ServiceBus.Hooks; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Internals; +using Spotflow.InMemory.Azure.ServiceBus.Resources; + +namespace Spotflow.InMemory.Azure.ServiceBus; +public class InMemoryServiceBusProvider(TimeProvider? timeProvider = null, string? hostnameSuffix = null) +{ + private readonly ConcurrentDictionary _namespaces = new(); + + public TimeProvider TimeProvider { get; } = timeProvider ?? TimeProvider.System; + + public string HostnameSuffix { get; } = hostnameSuffix ?? "servicebus.in-memory.example.com"; + + private readonly HooksExecutor _hooksExecutor = new(); + + public InMemoryServiceBusNamespace AddNamespace(string? namespaceName = null) + { + if (namespaceName is not null && namespaceName.Contains('.')) + { + throw new ArgumentException($"Namespace name cannot contain dots. Got '{namespaceName}'"); + } + + namespaceName ??= GenerateNamespaceName(); + + var ns = new InMemoryServiceBusNamespace(namespaceName, this); + + if (!_namespaces.TryAdd(ns.FullyQualifiedNamespace, ns)) + { + throw new InvalidOperationException($"Service bus namespace '{namespaceName}' already added."); + } + + return ns; + } + + public bool TryGetNamespace(string namespaceName, [NotNullWhen(true)] out InMemoryServiceBusNamespace? result) + { + foreach (var (_, ns) in _namespaces) + { + if (ns.Name == namespaceName) + { + result = ns; + return true; + } + } + + result = null; + return false; + } + + public InMemoryServiceBusNamespace GetNamespace(string namespaceName) + { + if (!TryGetNamespace(namespaceName, out var serviceBusNamespace)) + { + throw new InvalidOperationException($"Service bus namespace '{namespaceName}' not found."); + } + + return serviceBusNamespace; + } + + public bool TryGetFullyQualifiedNamespace(string fullyQualifiedNamespace, [NotNullWhen(true)] out InMemoryServiceBusNamespace? result) + { + return _namespaces.TryGetValue(fullyQualifiedNamespace, out result); + } + + public InMemoryServiceBusNamespace GetFullyQualifiedNamespace(string fullyQualifiedNamespace) + { + if (!TryGetFullyQualifiedNamespace(fullyQualifiedNamespace, out var result)) + { + throw new InvalidOperationException($"Service bus namespace with fully qualified name '{fullyQualifiedNamespace}' not found."); + } + + return result; + } + + public IHookRegistration AddHook(Func> buildHook) where TContext : ServiceBusHookContext + { + var hook = buildHook(new()); + + return _hooksExecutor.AddHook(hook.HookFunction, hook.Filter); + } + + internal Task ExecuteHooksAsync(TContext context) where TContext : ServiceBusHookContext + { + return _hooksExecutor.ExecuteHooksAsync(context); + } + + internal string GetNamespaceNameFromHostname(string hostname) + { + if (!hostname.EndsWith(HostnameSuffix)) + { + throw new FormatException($"Service Bus namespace host name is expected to end with '{HostnameSuffix}'. Got {hostname}."); + } + + return hostname[..(hostname.Length - HostnameSuffix.Length - 1)]; + } + + private static string GenerateNamespaceName() => Guid.NewGuid().ToString(); +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusReceiver.cs b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusReceiver.cs new file mode 100644 index 0000000..5176643 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusReceiver.cs @@ -0,0 +1,255 @@ +using System.Runtime.CompilerServices; + +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.ServiceBus.Hooks; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; +using Spotflow.InMemory.Azure.ServiceBus.Internals; +using Spotflow.InMemory.Azure.ServiceBus.Resources; + +namespace Spotflow.InMemory.Azure.ServiceBus; + +public class InMemoryServiceBusReceiver : ServiceBusReceiver +{ + private readonly string _identifier; + private readonly TimeSpan _defaultMaxWaitTime; + private readonly int _prefetchCount; + private readonly Func _getStore; + private readonly ServiceBusConsumerHookScope _scope; + + private volatile bool _isClosed = false; + + #region Constructors + + public InMemoryServiceBusReceiver(InMemoryServiceBusClient client, string queueName) + : this(client, queueName, new ServiceBusReceiverOptions()) { } + public InMemoryServiceBusReceiver(InMemoryServiceBusClient client, string queueName, ServiceBusReceiverOptions options) + : this(client, queueName, options, () => GetStoreForQueue(client, queueName), isTopicSubscription: false) { } + + public InMemoryServiceBusReceiver(InMemoryServiceBusClient client, string topicName, string subscriptionName) + : this(client, FormatEntityPath(topicName, subscriptionName), subscriptionName, new ServiceBusReceiverOptions()) { } + + public InMemoryServiceBusReceiver(InMemoryServiceBusClient client, string topicName, string subscriptionName, ServiceBusReceiverOptions options) + : this(client, FormatEntityPath(topicName, subscriptionName), options, () => GetStoreForSubscription(client, topicName, subscriptionName), isTopicSubscription: true) { } + + private static string FormatEntityPath(string topicName, string subscriptionName) + => InMemoryServiceBusSubscription.FormatEntityPath(topicName, subscriptionName); + + private static SessionlessEngine GetStoreForQueue(InMemoryServiceBusClient client, string queueName) + { + IConsumableEntity queue = ServiceBusClientUtils.GetQueue(client.FullyQualifiedNamespace, queueName, client.Provider); + + var store = queue.Engine as SessionlessEngine; + + return store ?? throw ServiceBusExceptionFactory.SessionsEnabled(queue.FullyQualifiedNamespace, queue.EntityPath); + } + + private static SessionlessEngine GetStoreForSubscription(InMemoryServiceBusClient client, string topicName, string subscriptionName) + { + var subscription = ServiceBusClientUtils.GetSubscription(client.FullyQualifiedNamespace, topicName, subscriptionName, client.Provider); + + var store = subscription.Engine as SessionlessEngine; + + return store ?? throw ServiceBusExceptionFactory.SessionsEnabled(subscription.FullyQualifiedNamespace, subscription.EntityPath); + + } + + private InMemoryServiceBusReceiver( + InMemoryServiceBusClient client, + string entityPath, + ServiceBusReceiverOptions options, + Func getStore, + bool isTopicSubscription) + { + FullyQualifiedNamespace = client.FullyQualifiedNamespace; + EntityPath = entityPath; + ReceiveMode = options.ReceiveMode; + _prefetchCount = options.PrefetchCount; + _defaultMaxWaitTime = client.DefaultMaxWaitTime; + _identifier = options.Identifier ?? Guid.NewGuid().ToString(); + _getStore = getStore; + Provider = client.Provider; + _scope = new(Provider.GetNamespaceNameFromHostname(client.FullyQualifiedNamespace), EntityPath, isTopicSubscription); + } + + public static InMemoryServiceBusReceiver FromQueue(InMemoryServiceBusQueue queue, ServiceBusClientOptions? options = null) + { + var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace, options); + return new(client, queue.QueueName); + } + + public static InMemoryServiceBusReceiver FromSubscription(InMemoryServiceBusSubscription subscription, ServiceBusClientOptions? options = null) + { + var client = InMemoryServiceBusClient.FromNamespace(subscription.Topic.Namespace, options); + return new(client, subscription.TopicName, subscription.SubscriptionName); + } + + #endregion + + public InMemoryServiceBusProvider Provider { get; } + + #region Properties + + public override string FullyQualifiedNamespace { get; } + public override string EntityPath { get; } + public override ServiceBusReceiveMode ReceiveMode { get; } + public override int PrefetchCount => _prefetchCount; + public override string Identifier => _identifier; + public override bool IsClosed => _isClosed; + + #endregion + + #region Close & Dispose + + public override async Task CloseAsync(CancellationToken cancellationToken = default) => await DisposeAsync(); + + public override async ValueTask DisposeAsync() + { + await Task.Yield(); + _isClosed = true; + } + + #endregion + + #region ReceiveMessagesAsync + + public override async Task> ReceiveMessagesAsync(int maxMessages, TimeSpan? maxWaitTime = null, CancellationToken cancellationToken = default) + { + return await ReceiveMessagesCoreAsync(maxMessages, maxWaitTime, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + private async Task> ReceiveMessagesCoreAsync(int maxMessages, TimeSpan? maxWaitTime, CancellationToken cancellationToken) + { + var beforeContext = new ReceiveBatchBeforeHookContext(_scope, Provider, cancellationToken); + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + var messages = await _getStore().ReceiveAsync(maxMessages, maxWaitTime ?? _defaultMaxWaitTime, ReceiveMode, cancellationToken); + + var afterContext = new ReceiveBatchAfterHookContext(beforeContext) + { + Messages = messages + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return messages; + } + + public override async Task ReceiveMessageAsync(TimeSpan? maxWaitTime = null, CancellationToken cancellationToken = default) + { + return await ReceiveMessageCoreAsync(maxWaitTime, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + private async Task ReceiveMessageCoreAsync(TimeSpan? maxWaitTime, CancellationToken cancellationToken) + { + var beforeContext = new ReceiveMessageBeforeHookContext(_scope, Provider, cancellationToken); + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + var message = await ServiceBusClientUtils.ReceiveSingleAsync(_getStore(), maxWaitTime ?? _defaultMaxWaitTime, ReceiveMode, cancellationToken); + + var afterContext = new ReceiveMessageAfterHookContext(beforeContext) + { + Message = message + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return message; + } + + public override async IAsyncEnumerable ReceiveMessagesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var beforeContext = new ReceiveBatchBeforeHookContext(_scope, Provider, cancellationToken); + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + var messages = new List(); + + await foreach (var item in ServiceBusClientUtils.ReceiveAsAsyncEnumerable(_getStore(), ReceiveMode, cancellationToken).ConfigureAwait(false)) + { + messages.Add(item); + yield return item; + } + + var afterContext = new ReceiveBatchAfterHookContext(beforeContext) + { + Messages = messages + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + } + + #endregion + + #region Abandon, Complete, Renew Message + + public override async Task AbandonMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + if (propertiesToModify is not null) + { + throw ServiceBusExceptionFactory.FeatureNotSupported("Properties cannot be modified."); + } + + _getStore().AbandonMessage(message); + } + + public override async Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + if (!_getStore().CompleteMessage(message)) + { + throw ServiceBusExceptionFactory.MessageLockLost(FullyQualifiedNamespace, EntityPath); + } + } + + public override async Task RenewMessageLockAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + if (!_getStore().RenewMessageLock(message)) + { + throw ServiceBusExceptionFactory.MessageLockLost(FullyQualifiedNamespace, EntityPath); + } + } + + #endregion + + private Task ExecuteBeforeHooksAsync(TContext context) where TContext : ConsumerBeforeHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + private Task ExecuteAfterHooksAsync(TContext context) where TContext : ConsumerAfterHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + + + #region Unsupported + + public override Task PeekMessageAsync(long? fromSequenceNumber = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task> PeekMessagesAsync(int maxMessages, long? fromSequenceNumber = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, IDictionary propertiesToModify, string deadLetterReason, string? deadLetterErrorDescription = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, string deadLetterReason, string? deadLetterErrorDescription = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task DeferMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task ReceiveDeferredMessageAsync(long sequenceNumber, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task> ReceiveDeferredMessagesAsync(IEnumerable sequenceNumbers, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + #endregion +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusSender.cs b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusSender.cs new file mode 100644 index 0000000..7453308 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusSender.cs @@ -0,0 +1,195 @@ +using System.Collections.Concurrent; + +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.ServiceBus.Hooks; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; +using Spotflow.InMemory.Azure.ServiceBus.Internals; +using Spotflow.InMemory.Azure.ServiceBus.Resources; + +namespace Spotflow.InMemory.Azure.ServiceBus; +public class InMemoryServiceBusSender : ServiceBusSender +{ + private readonly ConcurrentDictionary> _batches = new(ReferenceEqualityComparer.Instance); + + private volatile bool _isClosed = false; + + private readonly ServiceBusProducerHookScope _scope; + + #region Constructors + + public InMemoryServiceBusSender(InMemoryServiceBusClient client, string queueOrTopicName) : this(client, queueOrTopicName, new()) { } + + public InMemoryServiceBusSender(InMemoryServiceBusClient client, string queueOrTopicName, ServiceBusSenderOptions options) + { + Identifier = options?.Identifier ?? Guid.NewGuid().ToString(); + FullyQualifiedNamespace = client.FullyQualifiedNamespace; + EntityPath = queueOrTopicName; + Provider = client.Provider; + _scope = new(Provider.GetNamespaceNameFromHostname(client.FullyQualifiedNamespace), queueOrTopicName); + } + + public static InMemoryServiceBusSender FromQueue(InMemoryServiceBusQueue queue, ServiceBusClientOptions? options = null) + { + var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace, options); + return new(client, queue.QueueName); + } + + public static InMemoryServiceBusSender FromTopic(InMemoryServiceBusTopic topic, ServiceBusClientOptions? options = null) + { + var client = InMemoryServiceBusClient.FromNamespace(topic.Namespace, options); + return new(client, topic.TopicName); + } + + #endregion + + public InMemoryServiceBusProvider Provider { get; } + + #region Properties + + public override string FullyQualifiedNamespace { get; } + public override string EntityPath { get; } + public override bool IsClosed => _isClosed; + public override string Identifier { get; } + + #endregion + + #region Dispose & Close + + public override async Task CloseAsync(CancellationToken cancellationToken = default) => await DisposeAsync(); + + public override async ValueTask DisposeAsync() + { + await Task.Yield(); + _isClosed = true; + } + + #endregion + + #region Send + + public override async Task SendMessageAsync(ServiceBusMessage message, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var beforeContext = new SendMessageBeforeHookContext(_scope, Provider, cancellationToken) + { + Message = message + }; + + await ExecuteBeforeHooksAsync(beforeContext); + + var entity = ServiceBusClientUtils.GetEntity(FullyQualifiedNamespace, EntityPath, Provider); + + if (!entity.TryAddMessage(message, out var error)) + { + throw error.GetClientException(); + } + + var afterContext = new SendMessageAfterHookContext(beforeContext) + { + Message = message + }; + + await ExecuteAfterHooksAsync(afterContext); + } + + public override async Task SendMessagesAsync(ServiceBusMessageBatch messageBatch, CancellationToken cancellationToken = default) + { + if (!_batches.TryRemove(messageBatch, out var messages)) + { + var e = $"Batch can be sent only once and must be created from the same client instance. Current batch was already sent or originates from different client instance."; + throw ServiceBusExceptionFactory.FeatureNotSupported(e); + } + + await SendBatchCoreAsync(messages, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + public override async Task SendMessagesAsync(IEnumerable messages, CancellationToken cancellationToken = default) + { + await SendBatchCoreAsync(messages.ToList(), cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + private async Task SendBatchCoreAsync(IReadOnlyList messages, CancellationToken cancellationToken = default) + { + var beforeContext = new SendBatchBeforeHookContext(_scope, Provider, cancellationToken) + { + Messages = messages + }; + + await ExecuteBeforeHooksAsync(beforeContext); + + var entity = ServiceBusClientUtils.GetEntity(FullyQualifiedNamespace, EntityPath, Provider); + + if (!entity.TryAddMessages(messages, out var error)) + { + throw error.GetClientException(); + } + + var afterContext = new SendBatchAfterHookContext(beforeContext) + { + Messages = messages + }; + + await ExecuteAfterHooksAsync(afterContext); + } + + #endregion + + #region CreateMessageBatchAsync + + public override async ValueTask CreateMessageBatchAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var messages = new List(); + var batch = ServiceBusModelFactory.ServiceBusMessageBatch(-1, messages); + + _batches[batch] = messages; + + return batch; + } + + public override ValueTask CreateMessageBatchAsync(CreateMessageBatchOptions options, CancellationToken cancellationToken = default) + { + return CreateMessageBatchAsync(cancellationToken); + } + + #endregion + + private Task ExecuteBeforeHooksAsync(TContext context) where TContext : ProducerBeforeHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + private Task ExecuteAfterHooksAsync(TContext context) where TContext : ProducerAfterHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + + + #region Unsupported + + public override Task ScheduleMessageAsync(ServiceBusMessage message, DateTimeOffset scheduledEnqueueTime, CancellationToken cancellationToken = default) + { + throw ServiceBusExceptionFactory.MethodNotSupported(); + } + + public override Task> ScheduleMessagesAsync(IEnumerable messages, DateTimeOffset scheduledEnqueueTime, CancellationToken cancellationToken = default) + { + throw ServiceBusExceptionFactory.MethodNotSupported(); + } + + public override Task CancelScheduledMessageAsync(long sequenceNumber, CancellationToken cancellationToken = default) + { + throw ServiceBusExceptionFactory.MethodNotSupported(); + } + + public override Task CancelScheduledMessagesAsync(IEnumerable sequenceNumbers, CancellationToken cancellationToken = default) + { + throw ServiceBusExceptionFactory.MethodNotSupported(); + } + #endregion + +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusSessionReceiver.cs b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusSessionReceiver.cs new file mode 100644 index 0000000..4d74c58 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/InMemoryServiceBusSessionReceiver.cs @@ -0,0 +1,199 @@ +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.ServiceBus.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus; + +public class InMemoryServiceBusSessionReceiver : ServiceBusSessionReceiver +{ + private readonly LockedSession _session; + private readonly TimeSpan _defaultMaxWaitTime; + private readonly string _identifier; + private readonly int _prefetchCount; + private readonly SessionStore _store; + + private volatile bool _isClosed; + + internal InMemoryServiceBusSessionReceiver(LockedSession session, ServiceBusSessionReceiverOptions options, TimeSpan defaultMaxWaitTime, InMemoryServiceBusProvider provider) + { + _session = session; + _store = session.Store; + _defaultMaxWaitTime = defaultMaxWaitTime; + ReceiveMode = options.ReceiveMode; + _identifier = options.Identifier ?? Guid.NewGuid().ToString(); + _prefetchCount = options.PrefetchCount; + Provider = provider; + } + + public InMemoryServiceBusProvider Provider { get; } + + #region Properties + public override string FullyQualifiedNamespace => _session.FullyQualifiedNamespace; + public override string EntityPath => _session.EntityPath; + public override ServiceBusReceiveMode ReceiveMode { get; } + public override string Identifier => _identifier; + public override int PrefetchCount => _prefetchCount; + public override string SessionId => _session.SessionId; + public override bool IsClosed => _isClosed; + public override DateTimeOffset SessionLockedUntil => _store.SessionLockedUntil ?? DateTimeOffset.MinValue; + + #endregion + + #region Close & Dispose + + public override async Task CloseAsync(CancellationToken cancellationToken = default) => await DisposeAsync(); + + public override async ValueTask DisposeAsync() + { + await Task.Yield(); + + _store.Release(_session); + + _isClosed = true; + } + + #endregion + + #region Receive + + public override async Task> ReceiveMessagesAsync(int maxMessages, TimeSpan? maxWaitTime = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var result = await _store.ReceiveAsync(_session, maxMessages, maxWaitTime ?? _defaultMaxWaitTime, ReceiveMode, cancellationToken); + + if (!result.IsSuccessful) + { + throw ServiceBusExceptionFactory.SessionReceiveFailed(result.Error.Value, FullyQualifiedNamespace, EntityPath, SessionId); + } + + return result.Value; + } + + public override IAsyncEnumerable ReceiveMessagesAsync(CancellationToken cancellationToken = default) + { + return ServiceBusClientUtils.ReceiveAsAsyncEnumerable(_session, ReceiveMode, cancellationToken); + } + + public override async Task ReceiveMessageAsync(TimeSpan? maxWaitTime = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return await ServiceBusClientUtils.ReceiveSingleAsync(_session, maxWaitTime ?? _defaultMaxWaitTime, ReceiveMode, cancellationToken); + } + + #endregion + + #region Abandon, Complete, Renew Message + + public override async Task AbandonMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + if (propertiesToModify is not null) + { + throw ServiceBusExceptionFactory.FeatureNotSupported("Properties cannot be modified."); + } + + var result = _store.AbandonMessage(_session, message); + + if (!result.IsSuccessful) + { + throw ServiceBusExceptionFactory.SessionAbandonMessageFailed(result.Error.Value, FullyQualifiedNamespace, EntityPath, SessionId); + } + + } + + public override async Task CompleteMessageAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var result = _store.CompleteMessage(_session, message); + + if (!result.IsSuccessful) + { + throw ServiceBusExceptionFactory.SessionCompleteMessageFailed(result.Error.Value, FullyQualifiedNamespace, EntityPath, SessionId); + } + } + + public override async Task RenewMessageLockAsync(ServiceBusReceivedMessage message, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var result = _store.RenewMessageLock(_session, message); + + if (!result.IsSuccessful) + { + throw ServiceBusExceptionFactory.SessionRenewMessageFailed(result.Error.Value, FullyQualifiedNamespace, EntityPath, SessionId); + } + } + + #endregion + + #region Renew Session + + public override async Task RenewSessionLockAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var result = _store.RenewSessionLock(_session); + + if (!result.IsSuccessful) + { + throw ServiceBusExceptionFactory.SessionRenewFailed(result.Error.Value, FullyQualifiedNamespace, EntityPath, SessionId); + } + } + + #endregion + + #region Session State + + public override async Task GetSessionStateAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var result = _store.GetSessionState(_session); + + if (!result.IsSuccessful) + { + throw ServiceBusExceptionFactory.SessionStateGetSetFailed(result.Error.Value, FullyQualifiedNamespace, EntityPath, SessionId); + } + + return result.Value; + } + + public override async Task SetSessionStateAsync(BinaryData sessionState, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var result = _store.SetSessionState(_session, sessionState); + + if (!result.IsSuccessful) + { + throw ServiceBusExceptionFactory.SessionStateGetSetFailed(result.Error.Value, FullyQualifiedNamespace, EntityPath, SessionId); + } + } + + #endregion + + #region Unsupported + + public override Task PeekMessageAsync(long? fromSequenceNumber = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task> PeekMessagesAsync(int maxMessages, long? fromSequenceNumber = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, IDictionary propertiesToModify, string deadLetterReason, string? deadLetterErrorDescription = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task DeadLetterMessageAsync(ServiceBusReceivedMessage message, string deadLetterReason, string? deadLetterErrorDescription = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task DeferMessageAsync(ServiceBusReceivedMessage message, IDictionary? propertiesToModify = null, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task ReceiveDeferredMessageAsync(long sequenceNumber, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + public override Task> ReceiveDeferredMessagesAsync(IEnumerable sequenceNumbers, CancellationToken cancellationToken = default) => throw ServiceBusExceptionFactory.MethodNotSupported(); + + #endregion + +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/AddMessageError.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/AddMessageError.cs new file mode 100644 index 0000000..f019138 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/AddMessageError.cs @@ -0,0 +1,14 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal abstract class AddMessageError +{ + public abstract Exception GetClientException(); + + public class SessionIdNotSetOnMessage(string fullyQualifiedNamespace, string entityPath) : AddMessageError + { + public override Exception GetClientException() + { + return ServiceBusExceptionFactory.SessionIdNotSetOnMessage(fullyQualifiedNamespace, entityPath); + } + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/IConsumableEntity.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/IConsumableEntity.cs new file mode 100644 index 0000000..578509c --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/IConsumableEntity.cs @@ -0,0 +1,15 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal interface IConsumableEntity +{ + string FullyQualifiedNamespace { get; } + + string EntityPath { get; } + bool EnableSessions { get; } + TimeSpan LockTime { get; } + long ActiveMessageCount { get; } + long MessageCount { get; } + TimeProvider TimeProvider { get; } + + IMessagingEngine Engine { get; } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/IMessagingEngine.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/IMessagingEngine.cs new file mode 100644 index 0000000..6642ac0 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/IMessagingEngine.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal interface IMessagingEngine +{ + bool TryAddMessage(ServiceBusMessage message, [NotNullWhen(false)] out AddMessageError? error); + bool TryAddMessages(IReadOnlyList messages, [NotNullWhen(false)] out AddMessageError? error); + long ActiveMessageCount { get; } + long MessageCount { get; } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/LockedSession.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/LockedSession.cs new file mode 100644 index 0000000..1ee616c --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/LockedSession.cs @@ -0,0 +1,11 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal record LockedSession(SessionStore Store, Guid SessionLockToken) +{ + public string SessionId => Store.SessionId; + + public string FullyQualifiedNamespace => Store.FullyQualifiedNamespace; + + public string EntityPath => Store.EntityPath; + +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/MessageSequenceIdGenerator.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/MessageSequenceIdGenerator.cs new file mode 100644 index 0000000..20aa4df --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/MessageSequenceIdGenerator.cs @@ -0,0 +1,16 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal class MessageSequenceIdGenerator +{ + private long _next = -1; + + public long GetNext(int count = 1) + { + ArgumentOutOfRangeException.ThrowIfLessThan(count, 1); + + var lastSequenceNumber = Interlocked.Add(ref _next, count); + var firstSequenceNumber = lastSequenceNumber - count + 1; + + return firstSequenceNumber; + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/MessagesStore.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/MessagesStore.cs new file mode 100644 index 0000000..1f73f05 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/MessagesStore.cs @@ -0,0 +1,282 @@ +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal class MessagesStore(TimeProvider timeProvider, TimeSpan lockTime) +{ + private record EnqueuedServiceBusMessage(ServiceBusMessage Message, long SequenceNumber, DateTimeOffset EnqueuedTime); + private record LockedServiceBusMessage(ServiceBusMessage Message, long SequenceNumber, DateTimeOffset LockedUntil); + + private readonly object _syncObj = new(); + + private readonly Queue _enqueuedMessages = new(); + private readonly Queue _reenqueuedMessages = new(); + private readonly Dictionary _lockedMessages = []; + + private readonly ManualResetEventSlim _newMessageAdded = new(false); + + public int ActiveMessageCount + { + get + { + lock (_syncObj) + { + ReleaseExpiredMessagesUnsafe(); + return _enqueuedMessages.Count + _reenqueuedMessages.Count; + } + } + } + + public int MessageCount + { + get + { + lock (_syncObj) + { + ReleaseExpiredMessagesUnsafe(); + return _enqueuedMessages.Count + _reenqueuedMessages.Count + _lockedMessages.Count; + } + } + } + + public void AddMessage(ServiceBusMessage message, long sequenceNumber) + { + var enqueuedMessage = new EnqueuedServiceBusMessage(message, sequenceNumber, timeProvider.GetUtcNow()); + + lock (_syncObj) + { + _enqueuedMessages.Enqueue(enqueuedMessage); + _newMessageAdded.Set(); + } + } + + public void AddMessages(IReadOnlyList messages, long firstSequenceNumber) + { + var now = timeProvider.GetUtcNow(); + + lock (_syncObj) + { + var currentSequenceNumber = firstSequenceNumber; + + foreach (var message in messages) + { + var enqueuedMessage = new EnqueuedServiceBusMessage(message, currentSequenceNumber++, now); + _enqueuedMessages.Enqueue(enqueuedMessage); + } + + _newMessageAdded.Set(); + } + } + + public async Task> ReceiveAsync(int maxMessages, TimeSpan maxWaitTime, ServiceBusReceiveMode receiveMode, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maxMessages, 1); + + await Task.Yield(); + + lock (_syncObj) + { + ReleaseExpiredMessagesUnsafe(); + } + + var result = new List(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + cts.CancelAfter(maxWaitTime); + + while (result.Count < maxMessages) + { + cancellationToken.ThrowIfCancellationRequested(); // Use just the callers CT on purpose. + + bool shouldWait; + + lock (_syncObj) + { + EnqueuedServiceBusMessage? message; + + if (_reenqueuedMessages.TryDequeue(out var expiredMessage)) + { + message = expiredMessage; + } + else if (_enqueuedMessages.TryDequeue(out var enqueuedMessage)) + { + message = enqueuedMessage; + } + else + { + message = null; + } + + if (message is not null) + { + var receivedMessage = FinishReceiveMessageUnsafe(message, receiveMode); + result.Add(receivedMessage); + shouldWait = false; + } + else + { + _newMessageAdded.Reset(); + shouldWait = true; + } + } + + if (shouldWait) + { + try + { + _newMessageAdded.Wait(cts.Token); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) // Requested is cancelled by the caller + { + throw; + } + catch (OperationCanceledException) // Max wait time is reached + { + return result; + } + } + } + + return result; + } + + public bool CompleteMessage(ServiceBusReceivedMessage message) + { + var lockToken = GetLockToken(message); + + lock (_syncObj) + { + ReleaseExpiredMessagesUnsafe(); + + if (!_lockedMessages.Remove(lockToken, out var lockedMessage)) + { + return false; + } + + if (IsMessageLockExpired(lockedMessage)) + { + return false; + } + else + { + return true; + } + } + } + + public void AbandonMessage(ServiceBusReceivedMessage message) + { + var lockToken = GetLockToken(message); + + lock (_syncObj) + { + ReleaseExpiredMessagesUnsafe(); + + TryUnlockMessageAndReenqueueUnsafe(lockToken); + } + } + + public bool RenewMessageLock(ServiceBusReceivedMessage message) + { + var lockToken = GetLockToken(message); + + lock (_syncObj) + { + ReleaseExpiredMessagesUnsafe(); + + if (!_lockedMessages.TryGetValue(lockToken, out var lockedMessage)) + { + return false; + } + + if (IsMessageLockExpired(lockedMessage)) + { + return false; + } + + _lockedMessages[lockToken] = lockedMessage with { LockedUntil = timeProvider.GetUtcNow().Add(lockTime) }; + + return true; + + } + } + + private ServiceBusReceivedMessage FinishReceiveMessageUnsafe(EnqueuedServiceBusMessage enqueuedMessage, ServiceBusReceiveMode receiveMode) + { + Guid lockToken; + DateTimeOffset lockedUntil; + + var message = enqueuedMessage.Message; + var sequenceNumber = enqueuedMessage.SequenceNumber; + + if (receiveMode is ServiceBusReceiveMode.PeekLock) + { + lockToken = Guid.NewGuid(); + lockedUntil = timeProvider.GetUtcNow().Add(lockTime); + + var lockedMessage = new LockedServiceBusMessage(message, sequenceNumber, lockedUntil); + + if (!_lockedMessages.TryAdd(lockToken, lockedMessage)) + { + throw new InvalidOperationException("Failed to lock message. The lock token is already in use."); + } + } + else if (receiveMode is ServiceBusReceiveMode.ReceiveAndDelete) + { + lockToken = default; + lockedUntil = default; + } + else + { + throw new InvalidOperationException($"Unsupported receive mode: {receiveMode}."); + } + + + + return ServiceBusModelFactory.ServiceBusReceivedMessage( + body: message.Body, + messageId: message.MessageId, + sessionId: message.SessionId, + replyToSessionId: message.ReplyToSessionId, + timeToLive: message.TimeToLive, + correlationId: message.CorrelationId, + contentType: message.ContentType, + enqueuedTime: timeProvider.GetUtcNow(), + properties: message.ApplicationProperties, + lockTokenGuid: lockToken, + lockedUntil: lockedUntil, + sequenceNumber: sequenceNumber + ); + + } + + private void ReleaseExpiredMessagesUnsafe() + { + foreach (var (lockToken, lockedMessage) in _lockedMessages) + { + if (IsMessageLockExpired(lockedMessage)) + { + TryUnlockMessageAndReenqueueUnsafe(lockToken); + } + } + } + + private bool IsMessageLockExpired(LockedServiceBusMessage lockedMessage) + { + return lockedMessage.LockedUntil < timeProvider.GetUtcNow(); + } + + private void TryUnlockMessageAndReenqueueUnsafe(Guid lockToken) + { + if (_lockedMessages.Remove(lockToken, out var lockedMessage)) + { + var requenquedMessage = new EnqueuedServiceBusMessage(lockedMessage.Message, lockedMessage.SequenceNumber, timeProvider.GetUtcNow()); + + _reenqueuedMessages.Enqueue(requenquedMessage); + } + } + + + private static Guid GetLockToken(ServiceBusReceivedMessage message) => Guid.Parse(message.LockToken); +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/ServiceBusClientUtils.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/ServiceBusClientUtils.cs new file mode 100644 index 0000000..5b84fd7 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/ServiceBusClientUtils.cs @@ -0,0 +1,137 @@ +using System.Runtime.CompilerServices; + +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.ServiceBus.Resources; + +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal static class ServiceBusClientUtils +{ + public static async IAsyncEnumerable ReceiveAsAsyncEnumerable(SessionlessEngine store, ServiceBusReceiveMode receiveMode, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + + while (!cancellationToken.IsCancellationRequested) + { + var messages = await store.ReceiveAsync(16, TimeSpan.FromSeconds(8), receiveMode, cancellationToken); + + + foreach (var message in messages) + { + yield return message; + } + } + } + + public static async Task ReceiveSingleAsync(SessionlessEngine store, TimeSpan maxWaitTime, ServiceBusReceiveMode receiveMode, CancellationToken cancellationToken) + { + var messages = await store.ReceiveAsync(1, maxWaitTime, receiveMode, cancellationToken); + return messages.SingleOrDefault(); + } + + public static async IAsyncEnumerable ReceiveAsAsyncEnumerable(LockedSession session, ServiceBusReceiveMode receiveMode, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + + while (!cancellationToken.IsCancellationRequested) + { + var result = await session.Store.ReceiveAsync(session, 16, TimeSpan.FromSeconds(8), receiveMode, cancellationToken); + + if (!result.IsSuccessful) + { + throw ServiceBusExceptionFactory.SessionReceiveFailed(result.Error.Value, session.FullyQualifiedNamespace, session.EntityPath, session.SessionId); + } + + foreach (var message in result.Value) + { + yield return message; + } + } + } + + + + + public static async Task ReceiveSingleAsync(LockedSession session, TimeSpan maxWaitTime, ServiceBusReceiveMode receiveMode, CancellationToken cancellationToken) + { + var result = await session.Store.ReceiveAsync(session, 1, maxWaitTime, receiveMode, cancellationToken); + + if (!result.IsSuccessful) + { + throw ServiceBusExceptionFactory.SessionReceiveFailed(result.Error.Value, session.FullyQualifiedNamespace, session.EntityPath, session.SessionId); + } + + return result.Value.SingleOrDefault(); + } + + public static InMemoryServiceBusTopic GetTopic(string fullyQualifiedNamespace, string topicName, InMemoryServiceBusProvider provider) + { + var ns = GetNamespace(fullyQualifiedNamespace, provider); + var topic = ns.FindTopic(topicName); + + if (topic is null) + { + throw ServiceBusExceptionFactory.MessagingEntityNotFound(fullyQualifiedNamespace, topicName); + } + + return topic; + } + + public static InMemoryServiceBusQueue GetQueue(string fullyQualifiedNamespace, string queueName, InMemoryServiceBusProvider provider) + { + var ns = GetNamespace(fullyQualifiedNamespace, provider); + var queue = ns.FindQueue(queueName); + + if (queue is null) + { + throw ServiceBusExceptionFactory.MessagingEntityNotFound(fullyQualifiedNamespace, queueName); + } + + return queue; + } + + public static InMemoryServiceBusEntity GetEntity(string fullyQualifiedNamespace, string entityName, InMemoryServiceBusProvider provider) + { + var ns = GetNamespace(fullyQualifiedNamespace, provider); + var entity = ns.FindEntity(entityName); + + if (entity is null) + { + throw ServiceBusExceptionFactory.MessagingEntityNotFound(fullyQualifiedNamespace, entityName); + } + + return entity; + } + + public static InMemoryServiceBusNamespace GetNamespace(string fullyQualifiedNamespace, InMemoryServiceBusProvider provider) + { + if (!provider.TryGetFullyQualifiedNamespace(fullyQualifiedNamespace, out var serviceBusNamespace)) + { + throw ServiceBusExceptionFactory.NamespaceNotFound(fullyQualifiedNamespace); + } + + return serviceBusNamespace; + } + + public static InMemoryServiceBusSubscription GetSubscription(string fullyQualifiedNamespace, string topicName, string subscriptionName, InMemoryServiceBusProvider provider) + { + var topic = GetTopic(fullyQualifiedNamespace, topicName, provider); + var subscription = topic.FindSubscription(subscriptionName); + + + if (subscription is null) + { + var entityPath = InMemoryServiceBusSubscription.FormatEntityPath(topicName, subscriptionName); + throw ServiceBusExceptionFactory.MessagingEntityNotFound(fullyQualifiedNamespace, entityPath); + } + + return subscription; + } + + public static string GetFullyQualifiedNamespace(string connectionString) + { + return ServiceBusConnectionStringUtils.GetFullyQualifiedNamespace(connectionString); + } + +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/ServiceBusConnectionStringUtils.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/ServiceBusConnectionStringUtils.cs new file mode 100644 index 0000000..5a4e7bd --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/ServiceBusConnectionStringUtils.cs @@ -0,0 +1,20 @@ + +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.ServiceBus.Resources; + +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal static class ServiceBusConnectionStringUtils +{ + public static string CreateConnectionString(InMemoryServiceBusNamespace ns) + { + return $"Endpoint={ns.FullyQualifiedNamespace};SharedAccessKeyName=;SharedAccessKey="; + } + + public static string GetFullyQualifiedNamespace(string connectionString) + { + var properties = ServiceBusConnectionStringProperties.Parse(connectionString); + return properties.FullyQualifiedNamespace; + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/ServiceBusExceptionFactory.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/ServiceBusExceptionFactory.cs new file mode 100644 index 0000000..c29a444 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/ServiceBusExceptionFactory.cs @@ -0,0 +1,138 @@ +using System.Runtime.CompilerServices; + +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal static class ServiceBusExceptionFactory +{ + public static NotSupportedException MethodNotSupported([CallerMemberName] string? callerMemberName = null) + { + return new($"In-memory service bus client does not support method '{callerMemberName}'."); + } + + public static NotSupportedException FeatureNotSupported(string featureName) + { + return new($"In-memory service bus client does not support feature '{featureName}'."); + } + + public static ServiceBusException MessageLockLost(string fullyQualifiedNamespace, string entityPath) + { + return new("Message lock lost.", ServiceBusFailureReason.MessageLockLost, EntityFullPath(fullyQualifiedNamespace, entityPath)); + } + + public static ServiceBusException NamespaceNotFound(string fullyQualifiedNamespace) + { + return new( + isTransient: true, + $"No such host is known. ErrorCode: HostNotFound ({fullyQualifiedNamespace})", + null, + ServiceBusFailureReason.ServiceCommunicationProblem); + } + + public static ServiceBusException MessagingEntityNotFound(string fullyQualifiedNamespace, string entityPath) + { + return new("Messaging entity not found.", ServiceBusFailureReason.MessagingEntityNotFound, EntityFullPath(fullyQualifiedNamespace, entityPath)); + } + + public static ServiceBusException NoSessionAvailable(string fullyQualifiedNamespace, string entityPath) + { + return new("No session available.", ServiceBusFailureReason.ServiceTimeout, EntityFullPath(fullyQualifiedNamespace, entityPath)); + } + + public static InvalidOperationException SessionIdNotSetOnMessage(string fullyQualifiedNamespace, string entityPath) + { + return new( + "" + + "The SessionId was not set on a message, and it cannot be sent to the entity. " + + "Entities that have session support enabled can only " + + "receive messages that have the SessionId set to a valid value. " + + $"Entity = {EntityFullPath(fullyQualifiedNamespace, entityPath)}" + ); + } + + public static ServiceBusException SessionNotFound(string fullyQualifiedNamespace, string entityPath, string sessionId) + { + var path = EntityFullPath(fullyQualifiedNamespace, entityPath); + return new($"Session {sessionId} not found.", ServiceBusFailureReason.GeneralError, path); + } + + public static ServiceBusException SessionsEnabled(string fullyQualifiedNamespace, string entityPath) + { + var path = EntityFullPath(fullyQualifiedNamespace, entityPath); + return new($"Session is required but message does not contain session id.", ServiceBusFailureReason.GeneralError, path); + } + + public static ServiceBusException SessionsNotEnabled(string fullyQualifiedNamespace, string entityPath) + { + return new("Sessions are not enabled.", ServiceBusFailureReason.GeneralError, EntityFullPath(fullyQualifiedNamespace, entityPath)); + } + + public static ServiceBusException SessionReceiveFailed(ServiceBusFailureReason reason, string fullyQualifiedNamespace, string entityPath, string sessionId) + { + return reason switch + { + ServiceBusFailureReason.SessionLockLost => SessionLockLost(fullyQualifiedNamespace, entityPath, sessionId), + _ => throw new InvalidOperationException($"Unexpected failure reason: {reason}"), + }; + } + + public static Exception SessionRenewFailed(ServiceBusFailureReason reason, string fullyQualifiedNamespace, string entityPath, string sessionId) + { + return reason switch + { + ServiceBusFailureReason.SessionLockLost => SessionLockLost(fullyQualifiedNamespace, entityPath, sessionId), + _ => throw new InvalidOperationException($"Unexpected failure reason: {reason}"), + }; + } + + public static ServiceBusException SessionStateGetSetFailed(ServiceBusFailureReason reason, string fullyQualifiedNamespace, string entityPath, string sessionId) + { + return reason switch + { + ServiceBusFailureReason.SessionLockLost => SessionLockLost(fullyQualifiedNamespace, entityPath, sessionId), + _ => throw new InvalidOperationException($"Unexpected failure reason: {reason}"), + }; + } + + public static ServiceBusException SessionRenewMessageFailed(ServiceBusFailureReason reason, string fullyQualifiedNamespace, string entityPath, string sessionId) + { + return reason switch + { + ServiceBusFailureReason.SessionLockLost => SessionLockLost(fullyQualifiedNamespace, entityPath, sessionId), + ServiceBusFailureReason.MessageLockLost => MessageLockLost(fullyQualifiedNamespace, entityPath), + _ => throw new InvalidOperationException($"Unexpected failure reason: {reason}"), + }; + } + + public static ServiceBusException SessionCompleteMessageFailed(ServiceBusFailureReason reason, string fullyQualifiedNamespace, string entityPath, string sessionId) + { + return reason switch + { + ServiceBusFailureReason.SessionLockLost => SessionLockLost(fullyQualifiedNamespace, entityPath, sessionId), + ServiceBusFailureReason.MessageLockLost => MessageLockLost(fullyQualifiedNamespace, entityPath), + _ => throw new InvalidOperationException($"Unexpected failure reason: {reason}"), + }; + } + public static ServiceBusException SessionAbandonMessageFailed(ServiceBusFailureReason reason, string fullyQualifiedNamespace, string entityPath, string sessionId) + { + return reason switch + { + ServiceBusFailureReason.SessionLockLost => SessionLockLost(fullyQualifiedNamespace, entityPath, sessionId), + _ => throw new InvalidOperationException($"Unexpected failure reason: {reason}"), + }; + } + + + private static ServiceBusException SessionLockLost(string fullyQualifiedNamespace, string entityPath, string sessionId) + { + return new($"Session lock lost for session '{sessionId}'.", ServiceBusFailureReason.SessionLockLost, EntityFullPath(fullyQualifiedNamespace, entityPath)); + } + + private static string EntityFullPath(string fullyQualifiedNamespace, string entityPath) + { + return $"{fullyQualifiedNamespace.TrimEnd('/')}/{entityPath}"; + } + + +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionEngine.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionEngine.cs new file mode 100644 index 0000000..cdb93fc --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionEngine.cs @@ -0,0 +1,106 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal class SessionEngine(IConsumableEntity entity) : IMessagingEngine +{ + private readonly ConcurrentDictionary _sessions = new(); + + public long ActiveMessageCount => _sessions.Values.Sum(s => s.ActiveMessageCount); + public long MessageCount => _sessions.Values.Sum(s => s.MessageCount); + + private readonly MessageSequenceIdGenerator _sequenceIdGenerator = new(); + + public bool TryAddMessage(ServiceBusMessage message, [NotNullWhen(false)] out AddMessageError? error) + { + if (!HasSessionId(message)) + { + error = new AddMessageError.SessionIdNotSetOnMessage(entity.FullyQualifiedNamespace, entity.EntityPath); + return false; + } + + var sessionId = message.SessionId; + + var sessionStore = _sessions.GetOrAdd(sessionId, (s) => new SessionStore(entity.FullyQualifiedNamespace, entity.EntityPath, sessionId, entity.TimeProvider, entity.LockTime)); + + var sequenceNumber = _sequenceIdGenerator.GetNext(); + + sessionStore.AddMessage(message, sequenceNumber); + + error = null; + return true; + } + + public bool TryAddMessages(IReadOnlyList messages, [NotNullWhen(false)] out AddMessageError? error) + { + foreach (var message in messages) + { + if (!HasSessionId(message)) + { + error = new AddMessageError.SessionIdNotSetOnMessage(entity.FullyQualifiedNamespace, entity.EntityPath); + return false; + } + } + + var firstMessageSequenceNumber = _sequenceIdGenerator.GetNext(messages.Count); + + var currentMessageSequenceNumber = firstMessageSequenceNumber; + + foreach (var sessionGroup in messages.GroupBy(m => m.SessionId)) + { + var sessionId = sessionGroup.Key; + var sessionMessages = sessionGroup.ToList(); + + var session = _sessions.GetOrAdd(sessionId, (s) => new SessionStore(entity.FullyQualifiedNamespace, entity.EntityPath, sessionId, entity.TimeProvider, entity.LockTime)); + + session.AddMessages(sessionMessages, currentMessageSequenceNumber); + currentMessageSequenceNumber += sessionMessages.Count; + } + + error = null; + return true; + } + + public async Task TryAcquireNextAvailableSessionAsync(TimeSpan maxDelay, CancellationToken cancellationToken) + { + var start = entity.TimeProvider.GetTimestamp(); + + do + { + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var (_, sessionStore) in _sessions) + { + if (sessionStore.TryLockIfNotEmpty(out var acquiredSession)) + { + return acquiredSession; + } + } + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken: cancellationToken); + + } while (entity.TimeProvider.GetElapsedTime(start) < maxDelay); + + return null; + } + + public bool TryAcquireSession(string sessionId, [NotNullWhen(true)] out LockedSession? session) + { + if (_sessions.TryGetValue(sessionId, out var sessionStore)) + { + if (sessionStore.TryLockIfNotEmpty(out var acquiredSession)) + { + session = acquiredSession; + return true; + } + } + + session = null; + return false; + } + + private static bool HasSessionId(ServiceBusMessage message) => !string.IsNullOrWhiteSpace(message.SessionId); +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionResult.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionResult.cs new file mode 100644 index 0000000..9b5e132 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionResult.cs @@ -0,0 +1,45 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal class SessionResult where T : notnull +{ + [MemberNotNullWhen(true, nameof(Value))] + [MemberNotNullWhen(false, nameof(Error))] + public bool IsSuccessful => Value is not null; + + public SessionResult(ServiceBusFailureReason error) + { + Error = error; + } + + public SessionResult(T value) + { + Value = value; + } + + public ServiceBusFailureReason? Error { get; } + public T? Value { get; } +} + +internal class SessionResult +{ + [MemberNotNullWhen(false, nameof(Error))] + public bool IsSuccessful => Error is null; + + public static SessionResult Successful { get; } = new(); + + public SessionResult(ServiceBusFailureReason error) + { + Error = error; + } + + private SessionResult() + { + } + + public ServiceBusFailureReason? Error { get; } + +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionStore.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionStore.cs new file mode 100644 index 0000000..a7571c3 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionStore.cs @@ -0,0 +1,240 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal class SessionStore(string fullyQualifiedNamespace, string entityPath, string sessionId, TimeProvider timeProvider, TimeSpan lockTime) +{ + private readonly object _syncObj = new(); + + private readonly MessagesStore _messageStore = new(timeProvider, lockTime); + + private Guid? _sessionLockToken; + private DateTimeOffset? _sessionLockedUntil; + private BinaryData? _sessionState; + + public string FullyQualifiedNamespace { get; } = fullyQualifiedNamespace; + public string EntityPath { get; } = entityPath; + public string SessionId { get; } = sessionId; + public long ActiveMessageCount => _messageStore.ActiveMessageCount; + public long MessageCount => _messageStore.MessageCount; + + public bool IsLocked + { + get { lock (_syncObj) { return _sessionLockToken.HasValue; } } + } + + public DateTimeOffset? SessionLockedUntil + { + get { lock (_syncObj) { return _sessionLockedUntil; } } + } + + public bool TryLockIfNotEmpty([NotNullWhen(true)] out LockedSession? acquiredSession) + { + lock (_syncObj) + { + if (_sessionLockToken is not null) + { + acquiredSession = null; + return false; + } + + if (_messageStore.ActiveMessageCount > 0) + { + _sessionLockToken = Guid.NewGuid(); + _sessionLockedUntil = timeProvider.GetUtcNow().Add(lockTime); + acquiredSession = new LockedSession(this, _sessionLockToken.Value); + return true; + } + + acquiredSession = null; + return false; + + } + } + + public void Release(LockedSession session) + { + lock (_syncObj) + { + if (_sessionLockToken == session.SessionLockToken) + { + _sessionLockToken = null; + _sessionLockedUntil = null; + } + } + } + + public async Task>> ReceiveAsync(LockedSession session, int maxMessages, TimeSpan maxWaitTime, ServiceBusReceiveMode receiveMode, CancellationToken cancellationToken) + { + lock (_syncObj) + { + if (!CheckSessionLockUnsafe(session)) + { + return new(ServiceBusFailureReason.SessionLockLost); + } + } + + var messages = await _messageStore.ReceiveAsync(maxMessages, maxWaitTime, receiveMode, cancellationToken); + + return new(messages); + } + + public SessionResult RenewSessionLock(LockedSession session) + { + lock (_syncObj) + { + if (!CheckSessionLockUnsafe(session)) + { + return new(ServiceBusFailureReason.SessionLockLost); + } + + _sessionLockedUntil = timeProvider.GetUtcNow().Add(lockTime); + return SessionResult.Successful; + } + } + + public SessionResult GetSessionState(LockedSession session) + { + lock (_syncObj) + { + + if (!CheckSessionLockUnsafe(session)) + { + return new(ServiceBusFailureReason.SessionLockLost); + } + + return new(_sessionState ?? new BinaryData(string.Empty)); + } + } + + public SessionResult SetSessionState(LockedSession session, BinaryData state) + { + lock (_syncObj) + { + if (!CheckSessionLockUnsafe(session)) + { + return new(ServiceBusFailureReason.SessionLockLost); + } + + _sessionState = state; + } + + return SessionResult.Successful; + } + + public void AddMessage(ServiceBusMessage message, long sequenceNumber) + { + AssertSession(message); + _messageStore.AddMessage(message, sequenceNumber); + } + + public void AddMessages(IReadOnlyList messages, long firstSequenceNumber) + { + AssertSession(messages); + _messageStore.AddMessages(messages, firstSequenceNumber); + } + + public SessionResult CompleteMessage(LockedSession session, ServiceBusReceivedMessage message) + { + AssertSession(message); + AssertSession(session); + + lock (_syncObj) + { + if (!CheckSessionLockUnsafe(session)) + { + return new(ServiceBusFailureReason.SessionLockLost); + } + } + + if (!_messageStore.CompleteMessage(message)) + { + return new(ServiceBusFailureReason.MessageLockLost); + } + + return SessionResult.Successful; + } + + public SessionResult AbandonMessage(LockedSession session, ServiceBusReceivedMessage message) + { + AssertSession(message); + + lock (_syncObj) + { + if (!CheckSessionLockUnsafe(session)) + { + return new(ServiceBusFailureReason.SessionLockLost); + } + } + + _messageStore.AbandonMessage(message); + + return SessionResult.Successful; + } + + public SessionResult RenewMessageLock(LockedSession session, ServiceBusReceivedMessage message) + { + AssertSession(message); + AssertSession(session); + + lock (_syncObj) + { + if (!CheckSessionLockUnsafe(session)) + { + return new(ServiceBusFailureReason.SessionLockLost); + } + } + + if (!_messageStore.RenewMessageLock(message)) + { + return new(ServiceBusFailureReason.MessageLockLost); + } + + return SessionResult.Successful; + } + + + private bool CheckSessionLockUnsafe(LockedSession session) + { + + if (_sessionLockToken != session.SessionLockToken) + { + return false; + } + + if (_sessionLockedUntil is null || _sessionLockedUntil < timeProvider.GetUtcNow()) + { + return false; + } + + return true; + } + + private void AssertSession(LockedSession session) => AssertSession(session.SessionId); + private void AssertSession(ServiceBusMessage message) => AssertSession(message.SessionId); + private void AssertSession(IEnumerable messages) + { + foreach (var message in messages) + { + AssertSession(message); + } + } + private void AssertSession(ServiceBusReceivedMessage message) => AssertSession(message.SessionId); + + private void AssertSession(string? incomingSessionId) + { + if (string.IsNullOrWhiteSpace(incomingSessionId)) + { + throw new InvalidOperationException("Session id not set."); + } + + if (SessionId != incomingSessionId) + { + throw new InvalidOperationException($"Message (sid = {incomingSessionId}) does not belong to this session (sid = {SessionId})."); + } + } + + +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionlessEngine.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionlessEngine.cs new file mode 100644 index 0000000..fa79ee3 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Internals/SessionlessEngine.cs @@ -0,0 +1,43 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.ServiceBus; + +namespace Spotflow.InMemory.Azure.ServiceBus.Internals; + +internal class SessionlessEngine(IConsumableEntity entity) : IMessagingEngine +{ + private readonly MessagesStore _store = new(entity.TimeProvider, entity.LockTime); + private readonly MessageSequenceIdGenerator _sequenceIdGenerator = new(); + + + public long ActiveMessageCount => _store.ActiveMessageCount; + public long MessageCount => _store.MessageCount; + + + public bool TryAddMessage(ServiceBusMessage message, [NotNullWhen(false)] out AddMessageError? error) + { + var sequenceNumber = _sequenceIdGenerator.GetNext(); + _store.AddMessage(message, sequenceNumber); + error = null; + return true; + } + + public bool TryAddMessages(IReadOnlyList messages, [NotNullWhen(false)] out AddMessageError? error) + { + var firstSequenceNumber = _sequenceIdGenerator.GetNext(messages.Count); + _store.AddMessages(messages, firstSequenceNumber); + error = null; + return true; + } + + public Task> ReceiveAsync(int maxMessages, TimeSpan maxWaitTime, ServiceBusReceiveMode receiveMode, CancellationToken cancellationToken) + { + return _store.ReceiveAsync(maxMessages, maxWaitTime, receiveMode, cancellationToken); + } + + public bool CompleteMessage(ServiceBusReceivedMessage message) => _store.CompleteMessage(message); + + public void AbandonMessage(ServiceBusReceivedMessage message) => _store.AbandonMessage(message); + + public bool RenewMessageLock(ServiceBusReceivedMessage message) => _store.RenewMessageLock(message); +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusEntity.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusEntity.cs new file mode 100644 index 0000000..35fa366 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusEntity.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.ServiceBus; + +using Microsoft.Extensions.Logging; + +using Spotflow.InMemory.Azure.ServiceBus.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Resources; + +public abstract class InMemoryServiceBusEntity(InMemoryServiceBusNamespace parentNamespace) +{ + public abstract string EntityPath { get; } + + public string FullyQualifiedNamespace => Namespace.FullyQualifiedNamespace; + public InMemoryServiceBusProvider Provider => Namespace.Provider; + public InMemoryServiceBusNamespace Namespace { get; } = parentNamespace; + public TimeProvider TimeProvider => Namespace.TimeProvider; + + internal ILoggerFactory LoggerFactory => Namespace.LoggerFactory; + + internal static TimeSpan DefaultLockTime = TimeSpan.FromSeconds(30); + + internal abstract bool TryAddMessage(ServiceBusMessage message, [NotNullWhen(false)] out AddMessageError? error); + + internal abstract bool TryAddMessages(IReadOnlyList messages, [NotNullWhen(false)] out AddMessageError? error); +} + + diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusNamespace.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusNamespace.cs new file mode 100644 index 0000000..f587917 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusNamespace.cs @@ -0,0 +1,56 @@ +using System.Collections.Concurrent; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using Spotflow.InMemory.Azure.ServiceBus.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Resources; + +public class InMemoryServiceBusNamespace(string namespaceName, InMemoryServiceBusProvider provider) +{ + private readonly ConcurrentDictionary _entities = new(); + + internal ILoggerFactory LoggerFactory { get; } = NullLoggerFactory.Instance; + + public string FullyQualifiedNamespace { get; } = $"{namespaceName}.{provider.HostnameSuffix.TrimStart('.')}"; + public string Name => namespaceName; + public InMemoryServiceBusProvider Provider { get; } = provider; + + public TimeProvider TimeProvider => Provider.TimeProvider; + + public string CreateConnectionString() => ServiceBusConnectionStringUtils.CreateConnectionString(this); + + public InMemoryServiceBusEntity? FindEntity(string entityName) => _entities.TryGetValue(entityName, out var entity) ? entity : null; + + public InMemoryServiceBusQueue? FindQueue(string queueName) => FindEntity(queueName) as InMemoryServiceBusQueue; + + public InMemoryServiceBusTopic? FindTopic(string topicName) => FindEntity(topicName) as InMemoryServiceBusTopic; + + public InMemoryServiceBusQueue AddQueue(string queueName, InMemoryServiceBusQueueOptions? options = null) + { + var queue = new InMemoryServiceBusQueue(queueName, options ?? new(), this); + + if (!_entities.TryAdd(queueName, queue)) + { + throw new InvalidOperationException($"The namespace '{FullyQualifiedNamespace}' already contains entity '{queueName}'."); + } + + return queue; + } + + public InMemoryServiceBusTopic AddTopic(string topicName) + { + var topic = new InMemoryServiceBusTopic(topicName, this); + + if (!_entities.TryAdd(topicName, topic)) + { + throw new InvalidOperationException($"The namespace '{FullyQualifiedNamespace}' already contains entity '{topicName}'."); + } + + return topic; + } + + + +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusQueue.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusQueue.cs new file mode 100644 index 0000000..caf0701 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusQueue.cs @@ -0,0 +1,45 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.ServiceBus.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Resources; + +public class InMemoryServiceBusQueue : InMemoryServiceBusEntity, IConsumableEntity +{ + public InMemoryServiceBusQueue(string queueName, InMemoryServiceBusQueueOptions options, InMemoryServiceBusNamespace serviceBusNamespace) : base(serviceBusNamespace) + { + EnableSessions = options.EnableSessions; + LockTime = options.LockTime ?? DefaultLockTime; + QueueName = queueName; + + Engine = EnableSessions switch + { + true => new SessionEngine(this), + false => new SessionlessEngine(this) + }; + } + + public string QueueName { get; } + public bool EnableSessions { get; } + public TimeSpan LockTime { get; } + + public override string EntityPath => QueueName; + public long ActiveMessageCount => Engine.ActiveMessageCount; + public long MessageCount => Engine.MessageCount; + + internal IMessagingEngine Engine { get; } + + IMessagingEngine IConsumableEntity.Engine => Engine; + + internal override bool TryAddMessage(ServiceBusMessage message, [NotNullWhen(false)] out AddMessageError? error) + { + return Engine.TryAddMessage(message, out error); + } + + internal override bool TryAddMessages(IReadOnlyList messages, [NotNullWhen(false)] out AddMessageError? error) + { + return Engine.TryAddMessages(messages, out error); + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusQueueOptions.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusQueueOptions.cs new file mode 100644 index 0000000..b6bdff1 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusQueueOptions.cs @@ -0,0 +1,3 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Resources; + +public record InMemoryServiceBusQueueOptions(bool EnableSessions = false, TimeSpan? LockTime = null); diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusSubscription.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusSubscription.cs new file mode 100644 index 0000000..7053975 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusSubscription.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.ServiceBus.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Resources; + +public class InMemoryServiceBusSubscription : InMemoryServiceBusEntity, IConsumableEntity +{ + public InMemoryServiceBusSubscription(string subscriptionName, InMemoryServiceBusSubscriptionOptions options, InMemoryServiceBusTopic parentTopic) : base(parentTopic.Namespace) + { + SubscriptionName = subscriptionName; + Topic = parentTopic; + EnableSessions = options.EnableSessions; + LockTime = options.LockTime ?? DefaultLockTime; + EntityPath = FormatEntityPath(parentTopic.TopicName, subscriptionName); + + Engine = EnableSessions switch + { + true => new SessionEngine(this), + false => new SessionlessEngine(this) + }; + } + + public string SubscriptionName { get; } + public string TopicName => Topic.TopicName; + public InMemoryServiceBusTopic Topic { get; } + + public override string EntityPath { get; } + + public long ActiveMessageCount => Engine.ActiveMessageCount; + public long MessageCount => Engine.MessageCount; + + public bool EnableSessions { get; } + + public TimeSpan LockTime { get; } + + internal IMessagingEngine Engine { get; } + + IMessagingEngine IConsumableEntity.Engine => Engine; + + public static string FormatEntityPath(string topicName, string subscriptionName) => $"{topicName}/subscriptions/{subscriptionName}"; + + internal override bool TryAddMessage(ServiceBusMessage message, [NotNullWhen(false)] out AddMessageError? error) + { + return Engine.TryAddMessage(message, out error); + } + + internal override bool TryAddMessages(IReadOnlyList messages, [NotNullWhen(false)] out AddMessageError? error) + { + return Engine.TryAddMessages(messages, out error); + } +} + + diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusSubscriptionOptions.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusSubscriptionOptions.cs new file mode 100644 index 0000000..7ddfa5d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusSubscriptionOptions.cs @@ -0,0 +1,3 @@ +namespace Spotflow.InMemory.Azure.ServiceBus.Resources; + +public record InMemoryServiceBusSubscriptionOptions(bool EnableSessions = false, TimeSpan? LockTime = null); diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusTopic.cs b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusTopic.cs new file mode 100644 index 0000000..6ccf122 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Resources/InMemoryServiceBusTopic.cs @@ -0,0 +1,82 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +using Azure.Messaging.ServiceBus; + +using Microsoft.Extensions.Logging; + +using Spotflow.InMemory.Azure.ServiceBus.Internals; + +namespace Spotflow.InMemory.Azure.ServiceBus.Resources; + +public class InMemoryServiceBusTopic(string topicName, InMemoryServiceBusNamespace parentNamespace) + : InMemoryServiceBusEntity(parentNamespace) +{ + private readonly ILogger _logger = parentNamespace.LoggerFactory.CreateLogger(); + private readonly ConcurrentDictionary _subscriptions = new(); + + public string TopicName { get; } = topicName; + + public override string EntityPath => TopicName; + + + internal override bool TryAddMessage(ServiceBusMessage message, [NotNullWhen(false)] out AddMessageError? error) + { + foreach (var subscription in _subscriptions.Values) + { + if (!subscription.TryAddMessage(message, out var subscriptionError)) + { + _logger.LogWarning( + "Message could not be added to subscription {fqn}/{path}: {subscriptionError}", + subscription.FullyQualifiedNamespace, + subscription.EntityPath, + subscriptionError + ); + } + } + + error = null; + return true; + } + + internal override bool TryAddMessages(IReadOnlyList messages, [NotNullWhen(false)] out AddMessageError? error) + { + foreach (var (_, subscription) in _subscriptions) + { + if (!subscription.TryAddMessages(messages, out var subscriptionError)) + { + _logger.LogWarning( + "Message could not be added to subscription {fqn}/{path}: {subscriptionError}", + subscription.FullyQualifiedNamespace, + subscription.EntityPath, + subscriptionError + ); + } + } + + error = null; + return true; + } + + public InMemoryServiceBusSubscription? FindSubscription(string subscriptionName) + { + if (!_subscriptions.TryGetValue(subscriptionName, out var subscription)) + { + return null; + } + + return subscription; + } + + public InMemoryServiceBusSubscription AddSubscription(string subscriptionName, InMemoryServiceBusSubscriptionOptions? options = null) + { + var subscription = new InMemoryServiceBusSubscription(subscriptionName, options ?? new(), this); + + if (!_subscriptions.TryAdd(subscriptionName, subscription)) + { + throw new InvalidOperationException($"Subscription '{subscriptionName}' already exists."); + } + + return subscription; + } +} diff --git a/src/Spotflow.InMemory.Azure.ServiceBus/Spotflow.InMemory.Azure.ServiceBus.csproj b/src/Spotflow.InMemory.Azure.ServiceBus/Spotflow.InMemory.Azure.ServiceBus.csproj new file mode 100644 index 0000000..4e74449 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.ServiceBus/Spotflow.InMemory.Azure.ServiceBus.csproj @@ -0,0 +1,25 @@ + + + + In-memory implementation of the Azure Service Bus clients for convenient testing. + $(PackageTags);ServiceBus + true + README.md + + + + + + + + + + + + + + + + + + diff --git a/src/Spotflow.InMemory.Azure.Storage.FluentAssertions/BlobBaseClientAssertions.cs b/src/Spotflow.InMemory.Azure.Storage.FluentAssertions/BlobBaseClientAssertions.cs new file mode 100644 index 0000000..16eb0fa --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage.FluentAssertions/BlobBaseClientAssertions.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; + +using Azure; +using Azure.Storage.Blobs.Specialized; + +using FluentAssertions.Execution; +using FluentAssertions.Primitives; + +namespace Spotflow.InMemory.Azure.Storage.FluentAssertions; + +public class BlobBaseClientAssertions(BlobBaseClient subject) + : ReferenceTypeAssertions(subject) +{ + protected override string Identifier => nameof(BlobBaseClient); + + public async Task ExistAsync(TimeSpan? maxWaitTime = null, string? because = null, params object[] becauseArgs) + { + maxWaitTime ??= TimeSpan.FromSeconds(8); + + var startTime = Stopwatch.GetTimestamp(); + + while (Stopwatch.GetElapsedTime(startTime) < maxWaitTime) + { + try + { + if (await Subject.ExistsAsync()) + { + return; + } + } + catch (RequestFailedException) { } + + await Task.Delay(10); + } + + Execute + .Assertion + .BecauseOf(because, becauseArgs) + .FailWith("Blob {0} should exist{reason} but it does not exist event after {1} seconds.", Subject.Uri, maxWaitTime.Value.TotalSeconds); + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage.FluentAssertions/ShouldExtensions.cs b/src/Spotflow.InMemory.Azure.Storage.FluentAssertions/ShouldExtensions.cs new file mode 100644 index 0000000..8ed4abf --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage.FluentAssertions/ShouldExtensions.cs @@ -0,0 +1,11 @@ +using Azure.Storage.Blobs.Specialized; + +namespace Spotflow.InMemory.Azure.Storage.FluentAssertions; + +public static class ShouldExtensions +{ + public static BlobBaseClientAssertions Should(this BlobBaseClient client) + { + return new(client); + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage.FluentAssertions/Spotflow.InMemory.Azure.Storage.FluentAssertions.csproj b/src/Spotflow.InMemory.Azure.Storage.FluentAssertions/Spotflow.InMemory.Azure.Storage.FluentAssertions.csproj new file mode 100644 index 0000000..6c842f0 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage.FluentAssertions/Spotflow.InMemory.Azure.Storage.FluentAssertions.csproj @@ -0,0 +1,22 @@ + + + + FluentAssertions extensions for on-memory implementation of the Azure Storage Blobs and Tables clients for convenient testing. + $(PackageTags);Storage;Blobs;Tables;FluentAssertions + true + README.md + + + + + + + + + + + + + + + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobContainerScope.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobContainerScope.cs new file mode 100644 index 0000000..3a1ea0c --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobContainerScope.cs @@ -0,0 +1,5 @@ +using Spotflow.InMemory.Azure.Storage.Hooks; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks; + +public record BlobContainerScope(string StorageAccountName, string ContainerName) : StorageAccountScope(StorageAccountName); diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobOperations.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobOperations.cs new file mode 100644 index 0000000..ca98edd --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobOperations.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks; + +[Flags] +public enum BlobOperations +{ + None = 0, + Download = 1, + Upload = 2, + All = Download | Upload +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobScope.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobScope.cs new file mode 100644 index 0000000..e7b3bc2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobScope.cs @@ -0,0 +1,5 @@ +using Spotflow.InMemory.Azure.Storage.Hooks; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks; + +public record BlobScope(string StorageAccountName, string ContainerName, string BlobName) : StorageAccountScope(StorageAccountName); diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobServiceFaultsBuilder.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobServiceFaultsBuilder.cs new file mode 100644 index 0000000..4ca2d2a --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobServiceFaultsBuilder.cs @@ -0,0 +1,17 @@ +using Spotflow.InMemory.Azure.Storage.Blobs.Internals; +using Spotflow.InMemory.Azure.Storage.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks; + +public class BlobServiceFaultsBuilder(StorageHookContext context) : StorageFaultsBuilder +{ + public Task AuthenticationFailedSignatureDidNotMatch() + { + throw BlobExceptionFactory.AuthenticationFailedSignatureDidNotMatch(context.StorageAccountName); + } + + public override Task ServiceIsBusy() => throw BlobExceptionFactory.ServiceIsBusy(context.StorageAccountName); +} + + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobServiceHookBuilder.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobServiceHookBuilder.cs new file mode 100644 index 0000000..b073f23 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/BlobServiceHookBuilder.cs @@ -0,0 +1,86 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Internals; +using Spotflow.InMemory.Azure.Storage.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks; + +public class BlobServiceHookBuilder +{ + private readonly BlobHookFilter _filter; + + internal BlobServiceHookBuilder(StorageHookFilter filter) + { + _filter = new(filter); + } + + public BlobOperationsBuilder ForBlobOperations(string? containerName = null, string? blobName = null) => new(_filter.With(containerName: containerName, blobName: blobName)); + public ContainerOperationsBuilder ForContainerOperations(string? containerName = null) => new(_filter.With(containerName: containerName)); + + public StorageHook Before( + HookFunc hook, + string? containerName = null, + ContainerOperations? containerOperations = null, + string? blobName = null, + BlobOperations? blobOperations = null) + { + return new(hook, _filter.With(containerName, containerOperations, blobName, blobOperations)); + } + + public StorageHook After( + HookFunc hook, + string? containerName = null, + ContainerOperations? containerOperations = null, + string? blobName = null, + BlobOperations? blobOperations = null) + { + return new(hook, _filter.With(containerName, containerOperations, blobName, blobOperations)); + } + + public class ContainerOperationsBuilder + { + private readonly BlobHookFilter _filter; + + internal ContainerOperationsBuilder(BlobHookFilter filter) + { + _filter = filter.With(blobOperations: BlobOperations.None); + } + + public StorageHook Before(HookFunc hook, ContainerOperations? operations = null) => new(hook, _filter.With(containerOperations: operations)); + + public StorageHook After(HookFunc hook, ContainerOperations? operations = null) => new(hook, _filter.With(containerOperations: operations)); + + public StorageHook BeforeCreate(HookFunc hook) => new(hook, _filter); + + public StorageHook AfterCreate(HookFunc hook) => new(hook, _filter); + + } + + + public class BlobOperationsBuilder + { + private readonly BlobHookFilter _filter; + + internal BlobOperationsBuilder(BlobHookFilter filter) + { + _filter = filter.With(containerOperations: ContainerOperations.None); + } + public StorageHook Before(HookFunc hook, BlobOperations? operations = null) => new(hook, _filter.With(blobOperations: operations)); + + public StorageHook After(HookFunc hook, BlobOperations? operations = null) => new(hook, _filter.With(blobOperations: operations)); + + public StorageHook BeforeDownload(HookFunc hook) => new(hook, _filter); + + public StorageHook AfterDownload(HookFunc hook) => new(hook, _filter); + + public StorageHook BeforeBlobUpload(HookFunc hook) => new(hook, _filter); + + public StorageHook AfterBlobUpload(HookFunc hook) => new(hook, _filter); + + } + + +} + + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/ContainerOperations.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/ContainerOperations.cs new file mode 100644 index 0000000..a735767 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/ContainerOperations.cs @@ -0,0 +1,9 @@ +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks; + +[Flags] +public enum ContainerOperations +{ + None = 0, + Create = 1, + All = Create +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobAfterHookContext.cs new file mode 100644 index 0000000..53d1cd2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobAfterHookContext.cs @@ -0,0 +1,12 @@ +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public abstract class BlobAfterHookContext(BlobBeforeHookContext before) : BlobServiceAfterHookContext(before), IBlobOperation +{ + public BlobOperations Operation => before.Operation; + + public string ContainerName => before.ContainerName; + + public string BlobName => before.BlobName; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobBeforeHookContext.cs new file mode 100644 index 0000000..a5a5104 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobBeforeHookContext.cs @@ -0,0 +1,12 @@ +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public abstract class BlobBeforeHookContext(BlobScope scope, BlobOperations operation, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : BlobServiceBeforeHookContext(scope, provider, cancellationToken), IBlobOperation +{ + public string ContainerName => scope.ContainerName; + public string BlobName => scope.BlobName; + public BlobOperations Operation => operation; +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobDownloadAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobDownloadAfterHookContext.cs new file mode 100644 index 0000000..de87d17 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobDownloadAfterHookContext.cs @@ -0,0 +1,11 @@ +using Azure.Storage.Blobs.Models; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public class BlobDownloadAfterHookContext(BlobDownloadBeforeHookContext before) : BlobAfterHookContext(before) +{ + public required BlobDownloadDetails BlobDownloadDetails { get; init; } + public required BinaryData Content { get; init; } + public BlobDownloadBeforeHookContext BeforeContext => before; +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobDownloadBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobDownloadBeforeHookContext.cs new file mode 100644 index 0000000..a055293 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobDownloadBeforeHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Storage.Blobs.Models; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public class BlobDownloadBeforeHookContext(BlobScope scope, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : BlobBeforeHookContext(scope, BlobOperations.Download, provider, cancellationToken) +{ + public required BlobDownloadOptions? Options { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobServiceAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobServiceAfterHookContext.cs new file mode 100644 index 0000000..c2075cb --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobServiceAfterHookContext.cs @@ -0,0 +1,8 @@ +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public abstract class BlobServiceAfterHookContext(BlobServiceBeforeHookContext before) : StorageAfterHookContext(before) +{ + public override BlobServiceFaultsBuilder Faults() => new(this); +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobServiceBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobServiceBeforeHookContext.cs new file mode 100644 index 0000000..59a4ac1 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobServiceBeforeHookContext.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.Storage.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public abstract class BlobServiceBeforeHookContext(StorageAccountScope scope, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : StorageBeforeHookContext(scope, provider, cancellationToken) +{ + public override BlobServiceFaultsBuilder Faults() => new(this); +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobUploadAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobUploadAfterHookContext.cs new file mode 100644 index 0000000..2c9e67c --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobUploadAfterHookContext.cs @@ -0,0 +1,10 @@ +using Azure.Storage.Blobs.Models; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public class BlobUploadAfterHookContext(BlobUploadBeforeHookContext beforeContext) : BlobAfterHookContext(beforeContext) +{ + public required BinaryData Content { get; init; } + public required BlobContentInfo BlobContentInfo { get; init; } + public BlobUploadBeforeHookContext beforeContext { get; } = beforeContext; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobUploadBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobUploadBeforeHookContext.cs new file mode 100644 index 0000000..8654ba5 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/BlobUploadBeforeHookContext.cs @@ -0,0 +1,13 @@ +using Azure.Storage.Blobs.Models; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public class BlobUploadBeforeHookContext(BlobScope scope, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : BlobBeforeHookContext(scope, BlobOperations.Upload, provider, cancellationToken) +{ + public required BinaryData Content { get; init; } + + public required BlobUploadOptions? Options { get; init; } + + +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerAfterHookContext.cs new file mode 100644 index 0000000..e44a8b1 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerAfterHookContext.cs @@ -0,0 +1,9 @@ +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public abstract class ContainerAfterHookContext(ContainerBeforeHookContext before) : BlobServiceAfterHookContext(before), IContainerOperation +{ + public ContainerOperations Operation => before.Operation; + public string ContainerName => before.ContainerName; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerBeforeHookContext.cs new file mode 100644 index 0000000..1498265 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerBeforeHookContext.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public abstract class ContainerBeforeHookContext(BlobContainerScope scope, ContainerOperations operation, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : BlobServiceBeforeHookContext(scope, provider, cancellationToken), IContainerOperation +{ + public ContainerOperations Operation => operation; + public string ContainerName => scope.ContainerName; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerCreateAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerCreateAfterHookContext.cs new file mode 100644 index 0000000..098b44d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerCreateAfterHookContext.cs @@ -0,0 +1,10 @@ +using Azure.Storage.Blobs.Models; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public class ContainerCreateAfterHookContext(ContainerCreateBeforeHookContext before) : ContainerAfterHookContext(before) +{ + public required BlobContainerInfo ContainerInfo { get; init; } + public ContainerCreateBeforeHookContext BeforeContext => before; + +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerCreateBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerCreateBeforeHookContext.cs new file mode 100644 index 0000000..92a5244 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Contexts/ContainerCreateBeforeHookContext.cs @@ -0,0 +1,7 @@ +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +public class ContainerCreateBeforeHookContext(BlobContainerScope scope, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : ContainerBeforeHookContext(scope, ContainerOperations.Create, provider, cancellationToken) +{ + public required bool CreateIfNotExists { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Internals/BlobHookFilter.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Internals/BlobHookFilter.cs new file mode 100644 index 0000000..64eda32 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Internals/BlobHookFilter.cs @@ -0,0 +1,55 @@ +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Internals; + +internal record BlobHookFilter : StorageHookFilter +{ + + public BlobHookFilter(StorageHookFilter filter) : base(filter) { } + + public string? ContainerName { get; private init; } + public string? BlobName { get; private init; } + + public ContainerOperations ContainerOperations { get; private init; } = ContainerOperations.All; + public BlobOperations BlobOperations { get; private init; } = BlobOperations.All; + + public override bool Covers(StorageHookContext context) + { + var result = base.Covers(context); + + if (context is IContainerOperation container) + { + result &= ContainerName is null || container.ContainerName == ContainerName; + result &= ContainerOperations.HasFlag(container.Operation); + + return result; + } + + if (context is IBlobOperation blob) + { + result &= ContainerName is null || blob.ContainerName == ContainerName; + result &= BlobName is null || blob.BlobName == BlobName; + result &= BlobOperations.HasFlag(blob.Operation); + + return result; + } + + throw new InvalidOperationException($"Unexpected context: {context}"); + } + + internal BlobHookFilter With(string? containerName = null, ContainerOperations? containerOperations = null, string? blobName = null, BlobOperations? blobOperations = null) + { + return this with + { + ContainerName = containerName ?? ContainerName, + BlobName = blobName ?? BlobName, + ContainerOperations = containerOperations ?? ContainerOperations, + BlobOperations = blobOperations ?? BlobOperations + }; + } + +} + + + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Internals/IBlobOperation.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Internals/IBlobOperation.cs new file mode 100644 index 0000000..7f37ca9 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Internals/IBlobOperation.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Internals; + +internal interface IBlobOperation +{ + public string ContainerName { get; } + public string BlobName { get; } + public BlobOperations Operation { get; } + +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Internals/IContainerOperation.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Internals/IContainerOperation.cs new file mode 100644 index 0000000..5e88575 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Hooks/Internals/IContainerOperation.cs @@ -0,0 +1,7 @@ +namespace Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Internals; + +internal interface IContainerOperation +{ + public string ContainerName { get; } + public ContainerOperations Operation { get; } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlobClient.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlobClient.cs new file mode 100644 index 0000000..a8b1345 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlobClient.cs @@ -0,0 +1,655 @@ +using Azure; +using Azure.Storage; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Blobs.Specialized; +using Azure.Storage.Sas; + +using Spotflow.InMemory.Azure.Internals; +using Spotflow.InMemory.Azure.Storage.Blobs.Internals; +using Spotflow.InMemory.Azure.Storage.Resources; + +namespace Spotflow.InMemory.Azure.Storage.Blobs; + +public class InMemoryBlobClient : BlobClient +{ + private readonly BlobClientCore _core; + + #region Constructors + + public InMemoryBlobClient(string connectionString, string blobContainerName, string blobName, InMemoryStorageProvider provider) + : this(connectionString, null, blobContainerName, blobName, provider) { } + + public InMemoryBlobClient(Uri blobUri, InMemoryStorageProvider provider) + : this(null, blobUri, null, null, provider) { } + + private InMemoryBlobClient(string? connectionString, Uri? uri, string? blobContainerName, string? blobName, InMemoryStorageProvider provider) + { + var builder = BlobUriUtils.BuilderForBlob(connectionString, uri, blobContainerName, blobName, provider); + _core = new(builder, provider); + } + + public static InMemoryBlobClient FromAccount(InMemoryStorageAccount account, string blobContainerName, string blobName) + { + var blobUri = BlobUriUtils.UriForBlob(account.BlobServiceUri, blobContainerName, blobName); + return new(blobUri, account.Provider); + } + + #endregion + + public InMemoryStorageProvider Provider => _core.Provider; + + #region Properties + + public override Uri Uri => _core.Uri; + public override string AccountName => _core.AccountName; + public override string BlobContainerName => _core.BlobContainerName; + public override string Name => _core.Name; + public override bool CanGenerateSasUri => false; + + #endregion + + #region Clients + + protected override BlobContainerClient GetParentBlobContainerClientCore() => _core.GetParentContainerClient(); + + #endregion + + #region Upload + + public override Response Upload(Stream content, BlobUploadOptions options, CancellationToken cancellationToken = default) + => UploadCore(BinaryData.FromStream(content), options, null, cancellationToken); + + public override Response Upload(BinaryData content, BlobUploadOptions options, CancellationToken cancellationToken = default) + => UploadCore(content, options, null, cancellationToken); + + public override Response Upload(Stream content) + => UploadCore(BinaryData.FromStream(content), null, null, CancellationToken.None); + + public override Response Upload(BinaryData content) + => UploadCore(content, null, null, CancellationToken.None); + + public override Task> UploadAsync(Stream content) + => UploadCoreAsync(BinaryData.FromStream(content), null, null, CancellationToken.None); + + public override Task> UploadAsync(BinaryData content) + => UploadCoreAsync(content, null, null, CancellationToken.None); + + public override Task> UploadAsync(BinaryData content, BlobUploadOptions options, CancellationToken cancellationToken = default) + => UploadCoreAsync(content, options, null, cancellationToken); + + public override Task> UploadAsync(Stream content, BlobUploadOptions options, CancellationToken cancellationToken = default) + => UploadCoreAsync(BinaryData.FromStream(content), options, null, cancellationToken); + + public override Response Upload(Stream content, CancellationToken cancellationToken) + => UploadCore(BinaryData.FromStream(content), null, null, cancellationToken); + + public override Response Upload(BinaryData content, CancellationToken cancellationToken) + => UploadCore(content, null, null, cancellationToken); + + public override Task> UploadAsync(Stream content, CancellationToken cancellationToken) + => UploadCoreAsync(BinaryData.FromStream(content), null, null, cancellationToken); + + public override Task> UploadAsync(BinaryData content, CancellationToken cancellationToken) + => UploadCoreAsync(content, null, null, cancellationToken); + + public override Response Upload(Stream content, bool overwrite = false, CancellationToken cancellationToken = default) + => UploadCore(BinaryData.FromStream(content), null, overwrite, cancellationToken); + + public override Response Upload(BinaryData content, bool overwrite = false, CancellationToken cancellationToken = default) + => UploadCore(content, null, overwrite, cancellationToken); + + public override Task> UploadAsync(Stream content, bool overwrite = false, CancellationToken cancellationToken = default) + => UploadCoreAsync(BinaryData.FromStream(content), null, overwrite, cancellationToken); + + public override Task> UploadAsync(BinaryData content, bool overwrite = false, CancellationToken cancellationToken = default) + => UploadCoreAsync(content, null, overwrite, cancellationToken); + + public override Response Upload(Stream content, BlobHttpHeaders? httpHeaders = null, IDictionary? metadata = null, BlobRequestConditions? conditions = null, IProgress? progressHandler = null, AccessTier? accessTier = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + var options = new BlobUploadOptions + { + HttpHeaders = httpHeaders, + Metadata = metadata, + Conditions = conditions, + ProgressHandler = progressHandler, + AccessTier = accessTier, + TransferOptions = transferOptions + }; + + return UploadCore(BinaryData.FromStream(content), options, null, cancellationToken); + } + + public override Task> UploadAsync(Stream content, BlobHttpHeaders? httpHeaders = null, IDictionary? metadata = null, BlobRequestConditions? conditions = null, IProgress? progressHandler = null, AccessTier? accessTier = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + return Task.FromResult(Upload(content, httpHeaders, metadata, conditions, progressHandler, accessTier, transferOptions, cancellationToken)); + } + + private Response UploadCore(BinaryData content, BlobUploadOptions? options, bool? overwrite, CancellationToken cancellationToken) + { + var info = _core.UploadAsync(content, options, overwrite, cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(info, 201); + } + + private async Task> UploadCoreAsync(BinaryData content, BlobUploadOptions? options, bool? overwrite, CancellationToken cancellationToken) + { + var info = await _core.UploadAsync(content, options, overwrite, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(info, 201); + } + + #endregion + + #region Exists + + public override Response Exists(CancellationToken cancellationToken = default) + { + var exists = _core.Exists(cancellationToken); + + return exists switch + { + true => InMemoryResponse.FromValue(true, 200), + false => InMemoryResponse.FromValue(false, 404) + }; + } + + public override async Task> ExistsAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return Exists(cancellationToken); + } + + #endregion + + #region Get properties + public override Response GetProperties(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + var properties = _core.GetProperties(conditions, cancellationToken); + return InMemoryResponse.FromValue(properties, 200); + } + + public override async Task> GetPropertiesAsync(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return GetProperties(conditions, cancellationToken); + } + + #endregion + + #region Download + + public override Response Download(CancellationToken cancellationToken = default) + { + var info = _core.DownloadAsync(null, cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(info, 200); + } + + public override Response DownloadStreaming(BlobDownloadOptions? options = null, CancellationToken cancellationToken = default) + { + var result = _core.DownloadStreamingAsync(options, cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(result, 200); + } + + public override Response DownloadContent(BlobDownloadOptions? options = null, CancellationToken cancellationToken = default) + { + var content = _core.DownloadContentAsync(options, cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(content, 200); + } + + public override Response DownloadContent(BlobRequestConditions conditions, CancellationToken cancellationToken) + { + var options = new BlobDownloadOptions { Conditions = conditions }; + return DownloadContent(options, cancellationToken); + } + + public override Response Download() => Download(default); + public override Task> DownloadAsync() => DownloadAsync(default); + + public override async Task> DownloadAsync(CancellationToken cancellationToken) + { + var info = await _core.DownloadAsync(null, cancellationToken: cancellationToken) + .ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(info, 200); + } + + public override async Task> DownloadStreamingAsync(BlobDownloadOptions? options = null, CancellationToken cancellationToken = default) + { + var result = await _core.DownloadStreamingAsync(options, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(result, 200); + } + + public override Response DownloadContent() => DownloadContent((BlobDownloadOptions?) null, default); + + public override Response DownloadContent(CancellationToken cancellationToken = default) => DownloadContent((BlobDownloadOptions?) null, cancellationToken); + + public override Task> DownloadContentAsync() => DownloadContentAsync(default); + + public override Task> DownloadContentAsync(CancellationToken cancellationToken) + { + return DownloadContentAsync((BlobDownloadOptions?) null, cancellationToken); + } + + public override Task> DownloadContentAsync(BlobRequestConditions conditions, CancellationToken cancellationToken) + { + var options = new BlobDownloadOptions { Conditions = conditions }; + return DownloadContentAsync(options, cancellationToken); + } + + public override async Task> DownloadContentAsync(BlobDownloadOptions? options = null, CancellationToken cancellationToken = default) + { + var content = await _core.DownloadContentAsync(options, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(content, 200); + } + + #endregion + + #region Delete + public override Response Delete(DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + return _core.Delete(snapshotsOption, conditions, cancellationToken); + } + + public override async Task DeleteAsync(DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return Delete(snapshotsOption, conditions, cancellationToken); + } + + public override Response DeleteIfExists(DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + return _core.DeleteIfExists(snapshotsOption, conditions, cancellationToken); + } + + public override async Task> DeleteIfExistsAsync(DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return DeleteIfExists(snapshotsOption, conditions, cancellationToken); + } + + #endregion + + #region OpenWrite + + public override Stream OpenWrite(bool overwrite, BlobOpenWriteOptions? options = null, CancellationToken cancellationToken = default) + { + return _core.OpenWrite(overwrite, options?.OpenConditions, options?.BufferSize, cancellationToken); + } + + public override async Task OpenWriteAsync(bool overwrite, BlobOpenWriteOptions? options = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return OpenWrite(overwrite, options, cancellationToken); + } + + #endregion + + #region Unsupported + + protected override BlobBaseClient WithSnapshotCore(string snapshot) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + protected override BlobLeaseClient GetBlobLeaseClientCore(string leaseId) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Download(HttpRange range = default, BlobRequestConditions? conditions = null, bool rangeGetContentHash = false, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> DownloadAsync(HttpRange range = default, BlobRequestConditions? conditions = null, bool rangeGetContentHash = false, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadStreaming(HttpRange range, BlobRequestConditions conditions, bool rangeGetContentHash, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> DownloadStreamingAsync(HttpRange range, BlobRequestConditions conditions, bool rangeGetContentHash, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadStreaming(HttpRange range, BlobRequestConditions conditions, bool rangeGetContentHash, IProgress progressHandler, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> DownloadStreamingAsync(HttpRange range, BlobRequestConditions conditions, bool rangeGetContentHash, IProgress progressHandler, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadContent(BlobRequestConditions conditions, IProgress progressHandler, HttpRange range, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> DownloadContentAsync(BlobRequestConditions conditions, IProgress progressHandler, HttpRange range, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(Stream destination) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(string path) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(Stream destination) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(string path) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(Stream destination, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(string path, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(Stream destination, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(string path, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(Stream destination, BlobDownloadToOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(string path, BlobDownloadToOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(Stream destination, BlobDownloadToOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(string path, BlobDownloadToOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(Stream destination, BlobRequestConditions? conditions = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(string path, BlobRequestConditions? conditions = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(Stream destination, BlobRequestConditions? conditions = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(string path, BlobRequestConditions? conditions = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Stream OpenRead(BlobOpenReadOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task OpenReadAsync(BlobOpenReadOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Stream OpenRead(long position = 0, int? bufferSize = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Stream OpenRead(bool allowBlobModifications, long position = 0, int? bufferSize = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task OpenReadAsync(long position = 0, int? bufferSize = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task OpenReadAsync(bool allowBlobModifications, long position = 0, int? bufferSize = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override CopyFromUriOperation StartCopyFromUri(Uri source, BlobCopyFromUriOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override CopyFromUriOperation StartCopyFromUri(Uri source, IDictionary? metadata = null, AccessTier? accessTier = null, BlobRequestConditions? sourceConditions = null, BlobRequestConditions? destinationConditions = null, RehydratePriority? rehydratePriority = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task StartCopyFromUriAsync(Uri source, BlobCopyFromUriOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task StartCopyFromUriAsync(Uri source, IDictionary? metadata = null, AccessTier? accessTier = null, BlobRequestConditions? sourceConditions = null, BlobRequestConditions? destinationConditions = null, RehydratePriority? rehydratePriority = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response AbortCopyFromUri(string copyId, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task AbortCopyFromUriAsync(string copyId, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SyncCopyFromUri(Uri source, BlobCopyFromUriOptions? options = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SyncCopyFromUriAsync(Uri source, BlobCopyFromUriOptions? options = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Undelete(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task UndeleteAsync(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetHttpHeaders(BlobHttpHeaders? httpHeaders = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetHttpHeadersAsync(BlobHttpHeaders? httpHeaders = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetMetadata(IDictionary metadata, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetMetadataAsync(IDictionary metadata, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response CreateSnapshot(IDictionary? metadata = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> CreateSnapshotAsync(IDictionary? metadata = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetAccessTier(AccessTier accessTier, BlobRequestConditions? conditions = null, RehydratePriority? rehydratePriority = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task SetAccessTierAsync(AccessTier accessTier, BlobRequestConditions? conditions = null, RehydratePriority? rehydratePriority = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response GetTags(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> GetTagsAsync(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetTags(IDictionary tags, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task SetTagsAsync(IDictionary tags, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetImmutabilityPolicy(BlobImmutabilityPolicy immutabilityPolicy, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetImmutabilityPolicyAsync(BlobImmutabilityPolicy immutabilityPolicy, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DeleteImmutabilityPolicy(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DeleteImmutabilityPolicyAsync(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetLegalHold(bool hasLegalHold, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetLegalHoldAsync(bool hasLegalHold, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Uri GenerateSasUri(BlobSasPermissions permissions, DateTimeOffset expiresOn) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Uri GenerateSasUri(BlobSasBuilder builder) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + protected override BlobClient WithClientSideEncryptionOptionsCore(ClientSideEncryptionOptions clientSideEncryptionOptions) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Upload(string path) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> UploadAsync(string path) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Upload(string path, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> UploadAsync(string path, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Upload(string path, bool overwrite = false, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> UploadAsync(string path, bool overwrite = false, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Upload(string path, BlobUploadOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Upload(string path, BlobHttpHeaders? httpHeaders = null, IDictionary? metadata = null, BlobRequestConditions? conditions = null, IProgress? progressHandler = null, AccessTier? accessTier = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> UploadAsync(string path, BlobUploadOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> UploadAsync(string path, BlobHttpHeaders? httpHeaders = null, IDictionary? metadata = null, BlobRequestConditions? conditions = null, IProgress? progressHandler = null, AccessTier? accessTier = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + #endregion +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlobContainerClient.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlobContainerClient.cs new file mode 100644 index 0000000..c249d58 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlobContainerClient.cs @@ -0,0 +1,478 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Blobs.Specialized; +using Azure.Storage.Sas; + +using Spotflow.InMemory.Azure.Internals; +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks; +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Blobs.Internals; +using Spotflow.InMemory.Azure.Storage.Internals; +using Spotflow.InMemory.Azure.Storage.Resources; + +namespace Spotflow.InMemory.Azure.Storage.Blobs; + +public class InMemoryBlobContainerClient : BlobContainerClient +{ + private const int _defaultMaxPageSize = 5000; + + private readonly BlobContainerScope _scope; + + #region Constructors + + public InMemoryBlobContainerClient(string connectionString, string blobContainerName, InMemoryStorageProvider provider) + : this(connectionString, null, blobContainerName, provider) { } + + public InMemoryBlobContainerClient(Uri blobContainerUri, InMemoryStorageProvider provider) + : this(null, blobContainerUri, null, provider) { } + + public static InMemoryBlobContainerClient FromAccount(InMemoryStorageAccount account, string blobContainerName) + { + var blobContainerUri = BlobUriUtils.UriForContainer(account.BlobServiceUri, blobContainerName); + return new(blobContainerUri, account.Provider); + } + + private InMemoryBlobContainerClient(string? connectionString, Uri? uri, string? blobContainerName, InMemoryStorageProvider provider) + { + var builder = BlobUriUtils.BuilderForContainer(connectionString, uri, blobContainerName, provider); + + Uri = builder.ToUri(); + AccountName = builder.AccountName; + Name = builder.BlobContainerName; + Provider = provider; + _scope = new(builder.AccountName, builder.BlobContainerName); + } + + #endregion + + #region Properties + + public override Uri Uri { get; } + public override string AccountName { get; } + public override string Name { get; } + public override bool CanGenerateSasUri => false; + + #endregion + + public InMemoryStorageProvider Provider { get; } + + #region Get Client + + protected override BlobServiceClient GetParentBlobServiceClientCore() + { + var serviceUri = Provider.GetAccount(AccountName).BlobServiceUri; + return new InMemoryBlobServiceClient(serviceUri, Provider); + } + + public override BlobClient GetBlobClient(string blobName) + { + var blobUri = BlobUriUtils.UriForBlob(Uri, Name, blobName); + return new InMemoryBlobClient(blobUri, Provider); + } + + protected override BlobBaseClient GetBlobBaseClientCore(string blobName) => GetBlobClient(blobName); + + protected override BlockBlobClient GetBlockBlobClientCore(string blobName) + { + var blobUri = BlobUriUtils.UriForBlob(Uri, Name, blobName); + return new InMemoryBlockBlobClient(blobUri, Provider); + } + + #endregion + + #region Create If Not Exists + + public override Response CreateIfNotExists(PublicAccessType publicAccessType = PublicAccessType.None, IDictionary? metadata = null, BlobContainerEncryptionScopeOptions? encryptionScopeOptions = null, CancellationToken cancellationToken = default) + { + return CreateIfNotExistsAsync(publicAccessType, metadata, encryptionScopeOptions, cancellationToken).EnsureCompleted(); + } + + public override async Task> CreateIfNotExistsAsync(PublicAccessType publicAccessType = PublicAccessType.None, IDictionary? metadata = null, BlobContainerEncryptionScopeOptions? encryptionScopeOptions = null, CancellationToken cancellationToken = default) + { + (var info, var added) = await CreateIfNotExistsCoreAsync(metadata, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + + return added switch + { + true => InMemoryResponse.FromValue(info, 201), + false => InMemoryResponse.FromValue(info, 409) + }; + } + + public override Task> CreateIfNotExistsAsync(PublicAccessType publicAccessType, IDictionary metadata, CancellationToken cancellationToken) + { + return CreateIfNotExistsAsync(publicAccessType, metadata, null, cancellationToken); + } + + public override Response CreateIfNotExists(PublicAccessType publicAccessType, IDictionary metadata, CancellationToken cancellationToken) + { + return CreateIfNotExists(publicAccessType, metadata, null, cancellationToken); + } + + private async Task<(BlobContainerInfo, bool)> CreateIfNotExistsCoreAsync(IDictionary? metadata, CancellationToken cancellationToken) + { + var beforeContext = new ContainerCreateBeforeHookContext(_scope, Provider, cancellationToken) + { + CreateIfNotExists = true + }; + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + var blobService = GetBlobService(); + + if (!blobService.TryAddBlobContainer(Name, metadata, out var container, out var error)) + { + if (error is InMemoryBlobService.CreateContainerError.ContainerAlreadyExists containerAlreadyExists) + { + return (GetInfo(containerAlreadyExists.ExistingContainer), false); + } + + throw error.GetClientException(); + } + + var containerInfo = GetInfo(container); + + var afterContext = new ContainerCreateAfterHookContext(beforeContext) + { + ContainerInfo = containerInfo + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return (containerInfo, true); + + } + + #endregion + + #region Create + + public override Response Create(PublicAccessType publicAccessType = PublicAccessType.None, IDictionary? metadata = null, BlobContainerEncryptionScopeOptions? encryptionScopeOptions = null, CancellationToken cancellationToken = default) + { + var info = CreateCoreAsync(metadata, cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(info, 201); + } + + public override Response Create(PublicAccessType publicAccessType, IDictionary metadata, CancellationToken cancellationToken) + { + return Create(publicAccessType, metadata, null, cancellationToken); + } + + public override async Task> CreateAsync(PublicAccessType publicAccessType = PublicAccessType.None, IDictionary? metadata = null, BlobContainerEncryptionScopeOptions? encryptionScopeOptions = null, CancellationToken cancellationToken = default) + { + var info = await CreateCoreAsync(metadata, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(info, 201); + } + + public override Task> CreateAsync(PublicAccessType publicAccessType, IDictionary metadata, CancellationToken cancellationToken) + { + return CreateAsync(publicAccessType, metadata, null, cancellationToken); + } + + private async Task CreateCoreAsync(IDictionary? metadata, CancellationToken cancellationToken) + { + var beforeContext = new ContainerCreateBeforeHookContext(_scope, Provider, cancellationToken) + { + CreateIfNotExists = false + }; + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + var blobService = GetBlobService(); + + if (!blobService.TryAddBlobContainer(Name, metadata, out var container, out var error)) + { + throw error.GetClientException(); + } + + var result = GetInfo(container); + + var afterContext = new ContainerCreateAfterHookContext(beforeContext) + { + ContainerInfo = result + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return result; + } + + #endregion + + #region Get Blobs + + public override AsyncPageable GetBlobsAsync( + BlobTraits traits = BlobTraits.None, + BlobStates states = BlobStates.None, + string? prefix = null, + CancellationToken cancellationToken = default) + { + var blobs = GetBlobsCore(prefix); + return new InMemoryPageable.YieldingAsync(blobs, _defaultMaxPageSize); + } + + public override Pageable GetBlobs( + BlobTraits traits = BlobTraits.None, + BlobStates states = BlobStates.None, + string? prefix = null, + CancellationToken cancellationToken = default) + { + var blobs = GetBlobsCore(prefix); + return new InMemoryPageable.Sync(blobs, _defaultMaxPageSize); + } + + + private IReadOnlyList GetBlobsCore(string? prefix) + { + var container = GetContainer(); + + return container.GetBlobs(prefix); + } + + #endregion + + #region Exists + + public override Response Exists(CancellationToken cancellationToken = default) + { + var service = GetBlobService(); + + return service.ContainerExists(Name) switch + { + true => InMemoryResponse.FromValue(true, 200), + false => InMemoryResponse.FromValue(false, 404) + }; + } + + public override Task> ExistsAsync(CancellationToken cancellationToken = default) + { + var result = Exists(cancellationToken); + return Task.FromResult(result); + } + + #endregion + + #region Get Properties + + public override Response GetProperties(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + var container = GetContainer(); + + CheckConditions(container.GetProperties().ETag, conditions); + + return InMemoryResponse.FromValue(container.GetProperties(), 200); + } + + public override async Task> GetPropertiesAsync(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return GetProperties(conditions, cancellationToken); + } + + #endregion + + #region Upload Blob + public override Response UploadBlob(string blobName, Stream content, CancellationToken cancellationToken = default) + { + var blobClient = GetBlobClient(blobName); + return blobClient.Upload(content, cancellationToken); + } + + public override async Task> UploadBlobAsync(string blobName, Stream content, CancellationToken cancellationToken = default) + { + var blobClient = GetBlobClient(blobName); + return await blobClient.UploadAsync(content, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.None); + } + + public override Response UploadBlob(string blobName, BinaryData content, CancellationToken cancellationToken = default) + { + var blobClient = GetBlobClient(blobName); + return blobClient.Upload(content, cancellationToken); + } + + public override async Task> UploadBlobAsync(string blobName, BinaryData content, CancellationToken cancellationToken = default) + { + var blobClient = GetBlobClient(blobName); + return await blobClient.UploadAsync(content, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.None); + } + + #endregion + + #region Delete Blob + + public override Response DeleteBlob(string blobName, DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + var blobClient = GetBlobBaseClientCore(blobName); + return blobClient.Delete(snapshotsOption, conditions, cancellationToken); + } + + public override async Task DeleteBlobAsync(string blobName, DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return DeleteBlob(blobName, snapshotsOption, conditions, cancellationToken); + } + + public override Response DeleteBlobIfExists(string blobName, DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + var blobClient = GetBlobBaseClientCore(blobName); + return blobClient.DeleteIfExists(snapshotsOption, conditions, cancellationToken); + } + + public override async Task> DeleteBlobIfExistsAsync(string blobName, DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return DeleteBlobIfExists(blobName, snapshotsOption, conditions, cancellationToken); + } + + #endregion + + private InMemoryBlobService GetBlobService() + { + if (!Provider.TryGetAccount(AccountName, out var account)) + { + throw BlobExceptionFactory.BlobServiceNotFound(AccountName, Provider); + } + + return account.BlobService; + } + + private void CheckConditions(ETag? currentETag, BlobRequestConditions? conditions) + { + if (!ConditionChecker.CheckConditions(currentETag, conditions?.IfMatch, conditions?.IfNoneMatch, out var error)) + { + throw BlobExceptionFactory.ConditionNotMet(error.ConditionType, AccountName, Name, error.Message); + } + } + + private InMemoryBlobContainer GetContainer() + { + var blobService = GetBlobService(); + + if (!blobService.TryGetBlobContainer(Name, out var container)) + { + throw BlobExceptionFactory.ContainerNotFound(Name, blobService); + } + + return container; + } + + private static BlobContainerInfo GetInfo(InMemoryBlobContainer container) + { + var properties = container.GetProperties(); + return BlobsModelFactory.BlobContainerInfo(properties.ETag, properties.LastModified); + } + + #region Unsupported + + protected override AppendBlobClient GetAppendBlobClientCore(string blobName) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + protected override PageBlobClient GetPageBlobClientCore(string blobName) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + protected override BlobLeaseClient GetBlobLeaseClientCore(string leaseId) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Delete(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DeleteAsync(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DeleteIfExists(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> DeleteIfExistsAsync(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetMetadata(IDictionary metadata, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetMetadataAsync(IDictionary metadata, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response GetAccessPolicy(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> GetAccessPolicyAsync(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetAccessPolicy(PublicAccessType accessType = PublicAccessType.None, IEnumerable? permissions = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetAccessPolicyAsync(PublicAccessType accessType = PublicAccessType.None, IEnumerable? permissions = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Pageable GetBlobsByHierarchy(BlobTraits traits = BlobTraits.None, BlobStates states = BlobStates.None, string? delimiter = null, string? prefix = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override AsyncPageable GetBlobsByHierarchyAsync(BlobTraits traits = BlobTraits.None, BlobStates states = BlobStates.None, string? delimiter = null, string? prefix = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Pageable FindBlobsByTags(string tagFilterSqlExpression, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override AsyncPageable FindBlobsByTagsAsync(string tagFilterSqlExpression, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Uri GenerateSasUri(BlobContainerSasPermissions permissions, DateTimeOffset expiresOn) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Uri GenerateSasUri(BlobSasBuilder builder) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + #endregion + + private Task ExecuteBeforeHooksAsync(TContext context) where TContext : ContainerBeforeHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + private Task ExecuteAfterHooksAsync(TContext context) where TContext : ContainerAfterHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlobServiceClient.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlobServiceClient.cs new file mode 100644 index 0000000..59f2939 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlobServiceClient.cs @@ -0,0 +1,167 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; + +using Spotflow.InMemory.Azure.Storage.Blobs.Internals; +using Spotflow.InMemory.Azure.Storage.Resources; + +namespace Spotflow.InMemory.Azure.Storage.Blobs; + +public class InMemoryBlobServiceClient : BlobServiceClient +{ + #region Constructors + + public InMemoryBlobServiceClient(string connectionString, InMemoryStorageProvider provider) : this(connectionString, null, provider) { } + + + public InMemoryBlobServiceClient(Uri serviceUri, InMemoryStorageProvider provider) : this(null, serviceUri, provider) { } + + private InMemoryBlobServiceClient(string? connectionString, Uri? uri, InMemoryStorageProvider provider) + { + var builder = BlobUriUtils.BuilderForService(connectionString, uri, provider); + + Uri = builder.ToUri(); + Provider = provider; + } + + public static InMemoryBlobServiceClient FromAccount(InMemoryStorageAccount account) + { + return new(account.BlobServiceUri, account.Provider); + } + + #endregion + + public override Uri Uri { get; } + public InMemoryStorageProvider Provider { get; } + public override bool CanGenerateAccountSasUri => false; + + public override BlobContainerClient GetBlobContainerClient(string blobContainerName) + { + var blobContainerUri = BlobUriUtils.UriForContainer(Uri, blobContainerName); + return new InMemoryBlobContainerClient(blobContainerUri, Provider); + } + + #region Unsupported + + public override Pageable GetBlobContainers(BlobContainerTraits traits = BlobContainerTraits.None, BlobContainerStates states = BlobContainerStates.None, string? prefix = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Pageable GetBlobContainers(BlobContainerTraits traits, string prefix, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override AsyncPageable GetBlobContainersAsync(BlobContainerTraits traits = BlobContainerTraits.None, BlobContainerStates states = BlobContainerStates.None, string? prefix = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override AsyncPageable GetBlobContainersAsync(BlobContainerTraits traits, string prefix, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response GetAccountInfo(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> GetAccountInfoAsync(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response GetProperties(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> GetPropertiesAsync(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetProperties(BlobServiceProperties properties, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task SetPropertiesAsync(BlobServiceProperties properties, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response GetStatistics(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> GetStatisticsAsync(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response GetUserDelegationKey(DateTimeOffset? startsOn, DateTimeOffset expiresOn, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> GetUserDelegationKeyAsync(DateTimeOffset? startsOn, DateTimeOffset expiresOn, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response CreateBlobContainer(string blobContainerName, PublicAccessType publicAccessType = PublicAccessType.None, IDictionary? metadata = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> CreateBlobContainerAsync(string blobContainerName, PublicAccessType publicAccessType = PublicAccessType.None, IDictionary? metadata = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DeleteBlobContainer(string blobContainerName, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DeleteBlobContainerAsync(string blobContainerName, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response UndeleteBlobContainer(string deletedContainerName, string deletedContainerVersion, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> UndeleteBlobContainerAsync(string deletedContainerName, string deletedContainerVersion, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response UndeleteBlobContainer(string deletedContainerName, string deletedContainerVersion, string destinationContainerName, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> UndeleteBlobContainerAsync(string deletedContainerName, string deletedContainerVersion, string destinationContainerName, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Pageable FindBlobsByTags(string tagFilterSqlExpression, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override AsyncPageable FindBlobsByTagsAsync(string tagFilterSqlExpression, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + #endregion +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlockBlobClient.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlockBlobClient.cs new file mode 100644 index 0000000..a036896 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/InMemoryBlockBlobClient.cs @@ -0,0 +1,699 @@ +using Azure; +using Azure.Storage; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Blobs.Specialized; +using Azure.Storage.Sas; + +using Spotflow.InMemory.Azure.Internals; +using Spotflow.InMemory.Azure.Storage.Blobs.Internals; +using Spotflow.InMemory.Azure.Storage.Resources; + +namespace Spotflow.InMemory.Azure.Storage.Blobs; + +public class InMemoryBlockBlobClient : BlockBlobClient +{ + #region Constructors + + private readonly BlobClientCore _core; + + public InMemoryBlockBlobClient(string connectionString, string blobContainerName, string blobName, InMemoryStorageProvider provider) + : this(connectionString, null, blobContainerName, blobName, provider) { } + + public InMemoryBlockBlobClient(Uri blobUri, InMemoryStorageProvider provider) + : this(null, blobUri, null, null, provider) { } + + private InMemoryBlockBlobClient(string? connectionString, Uri? uri, string? blobContainerName, string? blobName, InMemoryStorageProvider provider) + { + var builder = BlobUriUtils.BuilderForBlob(connectionString, uri, blobContainerName, blobName, provider); + _core = new(builder, provider); + } + + public static InMemoryBlockBlobClient FromAccount(InMemoryStorageAccount account, string blobContainerName, string blobName) + { + var blobUri = BlobUriUtils.UriForBlob(account.BlobServiceUri, blobContainerName, blobName); + return new(blobUri, account.Provider); + } + + #endregion + + public InMemoryStorageProvider Provider => _core.Provider; + + #region Properties + + public override Uri Uri => _core.Uri; + public override string AccountName => _core.AccountName; + public override string BlobContainerName => _core.BlobContainerName; + public override string Name => _core.Name; + public override bool CanGenerateSasUri => false; + + public override int BlockBlobMaxUploadBlobBytes => InMemoryBlobService.MaxBlockSize; + + public override long BlockBlobMaxUploadBlobLongBytes => InMemoryBlobService.MaxBlockSize; + + public override int BlockBlobMaxStageBlockBytes => InMemoryBlobService.MaxBlockSize; + + public override long BlockBlobMaxStageBlockLongBytes => InMemoryBlobService.MaxBlockSize; + + public override int BlockBlobMaxBlocks => InMemoryBlobService.MaxBlockCount; + + #endregion + + #region Clients + + protected override BlobContainerClient GetParentBlobContainerClientCore() => _core.GetParentContainerClient(); + + #endregion + + #region Get Block List + + public override Response GetBlockList(BlockListTypes blockListTypes = BlockListTypes.All, string? snapshot = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + var blockList = _core.GetBlockList(blockListTypes, conditions, cancellationToken); + return InMemoryResponse.FromValue(blockList, 200); + } + + public override async Task> GetBlockListAsync(BlockListTypes blockListTypes = BlockListTypes.All, string? snapshot = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return GetBlockList(blockListTypes, snapshot, conditions, cancellationToken); + } + + #endregion + + #region Stage Block + + public override Response StageBlock(string base64BlockId, Stream content, BlockBlobStageBlockOptions? options = null, CancellationToken cancellationToken = default) + { + var blockInfo = _core.StageBlock(base64BlockId, BinaryData.FromStream(content), options, cancellationToken); + return InMemoryResponse.FromValue(blockInfo, 201); + } + + public override Response StageBlock(string base64BlockId, Stream content, byte[] transactionalContentHash, BlobRequestConditions conditions, IProgress progressHandler, CancellationToken cancellationToken) + { + var options = new BlockBlobStageBlockOptions + { + Conditions = conditions + }; + + return StageBlock(base64BlockId, content, options, cancellationToken); + } + + public override async Task> StageBlockAsync(string base64BlockId, Stream content, byte[] transactionalContentHash, BlobRequestConditions conditions, IProgress progressHandler, CancellationToken cancellationToken) + { + await Task.Yield(); + return StageBlock(base64BlockId, content, transactionalContentHash, conditions, progressHandler, cancellationToken); + } + + public override async Task> StageBlockAsync(string base64BlockId, Stream content, BlockBlobStageBlockOptions? options = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return StageBlock(base64BlockId, content, options, cancellationToken); + } + + #endregion + + #region Commit Block List + + public override Response CommitBlockList( + IEnumerable base64BlockIds, + CommitBlockListOptions options, + CancellationToken cancellationToken = default) + { + var contentInfo = _core.CommitBlockList(base64BlockIds, options, cancellationToken); + return InMemoryResponse.FromValue(contentInfo, 201); + } + + public override Response CommitBlockList( + IEnumerable base64BlockIds, + BlobHttpHeaders? httpHeaders = null, + IDictionary? metadata = null, + BlobRequestConditions? conditions = null, + AccessTier? accessTier = null, + CancellationToken cancellationToken = default) + { + var options = new CommitBlockListOptions { HttpHeaders = httpHeaders, Metadata = metadata, Conditions = conditions, AccessTier = accessTier }; + + return CommitBlockList(base64BlockIds, options, cancellationToken); + } + + public override async Task> CommitBlockListAsync(IEnumerable base64BlockIds, CommitBlockListOptions options, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return CommitBlockList(base64BlockIds, options, cancellationToken); + + } + + public override async Task> CommitBlockListAsync(IEnumerable base64BlockIds, BlobHttpHeaders? httpHeaders = null, IDictionary? metadata = null, BlobRequestConditions? conditions = null, AccessTier? accessTier = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return CommitBlockList(base64BlockIds, httpHeaders, metadata, conditions, accessTier, cancellationToken); + } + + #endregion + + #region Upload + + public override Response Upload(Stream content, BlobUploadOptions options, CancellationToken cancellationToken = default) + { + var info = _core.UploadAsync(BinaryData.FromStream(content), options, null, cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(info, 201); + } + + public override Response Upload(Stream content, BlobHttpHeaders? httpHeaders = null, IDictionary? metadata = null, BlobRequestConditions? conditions = null, AccessTier? accessTier = null, IProgress? progressHandler = null, CancellationToken cancellationToken = default) + { + var options = new BlobUploadOptions + { + HttpHeaders = httpHeaders, + Metadata = metadata, + Conditions = conditions, + AccessTier = accessTier, + ProgressHandler = progressHandler + }; + return Upload(content, options, cancellationToken); + } + + public override async Task> UploadAsync(Stream content, BlobUploadOptions options, CancellationToken cancellationToken = default) + { + var info = await _core.UploadAsync(BinaryData.FromStream(content), options, null, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(info, 201); + } + + public override Task> UploadAsync(Stream content, BlobHttpHeaders? httpHeaders = null, IDictionary? metadata = null, BlobRequestConditions? conditions = null, AccessTier? accessTier = null, IProgress? progressHandler = null, CancellationToken cancellationToken = default) + { + var options = new BlobUploadOptions + { + HttpHeaders = httpHeaders, + Metadata = metadata, + Conditions = conditions, + AccessTier = accessTier, + ProgressHandler = progressHandler + }; + return UploadAsync(content, options, cancellationToken); + } + + #endregion + + #region Exists + + public override Response Exists(CancellationToken cancellationToken = default) + { + var exists = _core.Exists(cancellationToken); + + return exists switch + { + true => InMemoryResponse.FromValue(true, 200), + false => InMemoryResponse.FromValue(false, 404) + }; + } + + public override async Task> ExistsAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + return Exists(cancellationToken); + } + + #endregion + + #region Get properties + public override Response GetProperties(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + var properties = _core.GetProperties(conditions, cancellationToken); + return InMemoryResponse.FromValue(properties, 200); + } + + public override async Task> GetPropertiesAsync(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return GetProperties(conditions, cancellationToken); + } + + #endregion + + #region Download + + public override Response Download(CancellationToken cancellationToken = default) + { + var info = _core.DownloadAsync(null, cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(info, 200); + } + + public override Response DownloadStreaming(BlobDownloadOptions? options = null, CancellationToken cancellationToken = default) + { + var result = _core.DownloadStreamingAsync(options, cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(result, 200); + } + + public override Response DownloadContent(BlobDownloadOptions? options = null, CancellationToken cancellationToken = default) + { + var content = _core.DownloadContentAsync(options, cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(content, 200); + } + + public override Response DownloadContent(BlobRequestConditions conditions, CancellationToken cancellationToken) + { + var options = new BlobDownloadOptions { Conditions = conditions }; + return DownloadContent(options, cancellationToken); + } + + public override Response Download() => Download(default); + public override Task> DownloadAsync() => DownloadAsync(default); + + public override async Task> DownloadAsync(CancellationToken cancellationToken) + { + var info = await _core.DownloadAsync(null, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(info, 200); + } + + public override async Task> DownloadStreamingAsync(BlobDownloadOptions? options = null, CancellationToken cancellationToken = default) + { + var result = await _core.DownloadStreamingAsync(options, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(result, 200); + } + + public override Response DownloadContent() => DownloadContent((BlobDownloadOptions?) null, default); + + public override Task> DownloadContentAsync() => DownloadContentAsync(default); + + public override Response DownloadContent(CancellationToken cancellationToken = default) => DownloadContent((BlobDownloadOptions?) null, cancellationToken); + + public override Task> DownloadContentAsync(CancellationToken cancellationToken) + { + return DownloadContentAsync((BlobDownloadOptions?) null, cancellationToken); + } + + public override Task> DownloadContentAsync(BlobRequestConditions conditions, CancellationToken cancellationToken) + { + var options = new BlobDownloadOptions { Conditions = conditions }; + return DownloadContentAsync(options, cancellationToken); + } + + public override async Task> DownloadContentAsync(BlobDownloadOptions? options = null, CancellationToken cancellationToken = default) + { + var content = await _core.DownloadContentAsync(options, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(content, 200); + } + + #endregion + + #region OpenWrite + + public override Stream OpenWrite(bool overwrite, BlockBlobOpenWriteOptions? options = null, CancellationToken cancellationToken = default) + { + return _core.OpenWrite(overwrite, options?.OpenConditions, options?.BufferSize, cancellationToken); + } + + public override async Task OpenWriteAsync(bool overwrite, BlockBlobOpenWriteOptions? options = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return OpenWrite(overwrite, options, cancellationToken); + } + + #endregion + + #region Delete + + public override Response Delete(DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + return _core.Delete(snapshotsOption, conditions, cancellationToken); + } + + public override Response DeleteIfExists(DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + return _core.DeleteIfExists(snapshotsOption, conditions, cancellationToken); + } + + public override async Task> DeleteIfExistsAsync(DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return DeleteIfExists(snapshotsOption, conditions, cancellationToken); + } + + public override async Task DeleteAsync(DeleteSnapshotsOption snapshotsOption = DeleteSnapshotsOption.None, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return Delete(snapshotsOption, conditions, cancellationToken); + } + + #endregion + + #region Unsupported + + protected override BlobLeaseClient GetBlobLeaseClientCore(string leaseId) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Download(HttpRange range = default, BlobRequestConditions? conditions = null, bool rangeGetContentHash = false, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> DownloadAsync(HttpRange range = default, BlobRequestConditions? conditions = null, bool rangeGetContentHash = false, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadStreaming(HttpRange range, BlobRequestConditions conditions, bool rangeGetContentHash, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> DownloadStreamingAsync(HttpRange range, BlobRequestConditions conditions, bool rangeGetContentHash, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadStreaming(HttpRange range, BlobRequestConditions conditions, bool rangeGetContentHash, IProgress progressHandler, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> DownloadStreamingAsync(HttpRange range, BlobRequestConditions conditions, bool rangeGetContentHash, IProgress progressHandler, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadContent(BlobRequestConditions conditions, IProgress progressHandler, HttpRange range, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> DownloadContentAsync(BlobRequestConditions conditions, IProgress progressHandler, HttpRange range, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(Stream destination) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(string path) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(Stream destination) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(string path) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(Stream destination, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(string path, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(Stream destination, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(string path, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(Stream destination, BlobDownloadToOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(string path, BlobDownloadToOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(Stream destination, BlobDownloadToOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(string path, BlobDownloadToOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(Stream destination, BlobRequestConditions? conditions = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DownloadTo(string path, BlobRequestConditions? conditions = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(Stream destination, BlobRequestConditions? conditions = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DownloadToAsync(string path, BlobRequestConditions? conditions = null, StorageTransferOptions transferOptions = default, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Stream OpenRead(BlobOpenReadOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task OpenReadAsync(BlobOpenReadOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Stream OpenRead(long position = 0, int? bufferSize = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Stream OpenRead(bool allowBlobModifications, long position = 0, int? bufferSize = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task OpenReadAsync(long position = 0, int? bufferSize = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task OpenReadAsync(bool allowBlobModifications, long position = 0, int? bufferSize = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override CopyFromUriOperation StartCopyFromUri(Uri source, BlobCopyFromUriOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override CopyFromUriOperation StartCopyFromUri(Uri source, IDictionary? metadata = null, AccessTier? accessTier = null, BlobRequestConditions? sourceConditions = null, BlobRequestConditions? destinationConditions = null, RehydratePriority? rehydratePriority = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task StartCopyFromUriAsync(Uri source, BlobCopyFromUriOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task StartCopyFromUriAsync(Uri source, IDictionary? metadata = null, AccessTier? accessTier = null, BlobRequestConditions? sourceConditions = null, BlobRequestConditions? destinationConditions = null, RehydratePriority? rehydratePriority = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response AbortCopyFromUri(string copyId, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task AbortCopyFromUriAsync(string copyId, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SyncCopyFromUri(Uri source, BlobCopyFromUriOptions? options = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SyncCopyFromUriAsync(Uri source, BlobCopyFromUriOptions? options = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Undelete(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task UndeleteAsync(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetHttpHeaders(BlobHttpHeaders? httpHeaders = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetHttpHeadersAsync(BlobHttpHeaders? httpHeaders = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetMetadata(IDictionary metadata, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetMetadataAsync(IDictionary metadata, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response CreateSnapshot(IDictionary? metadata = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> CreateSnapshotAsync(IDictionary? metadata = null, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetAccessTier(AccessTier accessTier, BlobRequestConditions? conditions = null, RehydratePriority? rehydratePriority = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task SetAccessTierAsync(AccessTier accessTier, BlobRequestConditions? conditions = null, RehydratePriority? rehydratePriority = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response GetTags(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> GetTagsAsync(BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetTags(IDictionary tags, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task SetTagsAsync(IDictionary tags, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetImmutabilityPolicy(BlobImmutabilityPolicy immutabilityPolicy, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetImmutabilityPolicyAsync(BlobImmutabilityPolicy immutabilityPolicy, BlobRequestConditions? conditions = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response DeleteImmutabilityPolicy(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task DeleteImmutabilityPolicyAsync(CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SetLegalHold(bool hasLegalHold, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SetLegalHoldAsync(bool hasLegalHold, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Uri GenerateSasUri(BlobSasPermissions permissions, DateTimeOffset expiresOn) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Uri GenerateSasUri(BlobSasBuilder builder) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response StageBlockFromUri(Uri sourceUri, string base64BlockId, StageBlockFromUriOptions? options = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> StageBlockFromUriAsync(Uri sourceUri, string base64BlockId, StageBlockFromUriOptions? options = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response StageBlockFromUri(Uri sourceUri, string base64BlockId, HttpRange sourceRange, byte[] sourceContentHash, RequestConditions sourceConditions, BlobRequestConditions conditions, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> StageBlockFromUriAsync(Uri sourceUri, string base64BlockId, HttpRange sourceRange, byte[] sourceContentHash, RequestConditions sourceConditions, BlobRequestConditions conditions, CancellationToken cancellationToken) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response Query(string querySqlExpression, BlobQueryOptions? options = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> QueryAsync(string querySqlExpression, BlobQueryOptions? options = null, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SyncUploadFromUri(Uri copySource, bool overwrite = false, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SyncUploadFromUriAsync(Uri copySource, bool overwrite = false, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Response SyncUploadFromUri(Uri copySource, BlobSyncUploadFromUriOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + public override Task> SyncUploadFromUriAsync(Uri copySource, BlobSyncUploadFromUriOptions options, CancellationToken cancellationToken = default) + { + throw BlobExceptionFactory.MethodNotSupported(); + } + + #endregion +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BinaryDataExtensions.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BinaryDataExtensions.cs new file mode 100644 index 0000000..45913f9 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BinaryDataExtensions.cs @@ -0,0 +1,6 @@ +namespace Spotflow.InMemory.Azure.Storage.Blobs.Internals; + +internal static class BinaryDataExtensions +{ + public static int GetLenght(this BinaryData binaryData) => binaryData.ToMemory().Length; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobClientCore.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobClientCore.cs new file mode 100644 index 0000000..8df2463 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobClientCore.cs @@ -0,0 +1,302 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; + +using Spotflow.InMemory.Azure.Internals; +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks; +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Internals; + +internal class BlobClientCore(BlobUriBuilder uriBuilder, InMemoryStorageProvider provider) +{ + public Uri Uri { get; } = uriBuilder.ToUri(); + public string AccountName { get; } = uriBuilder.AccountName; + public string BlobContainerName { get; } = uriBuilder.BlobContainerName; + public string Name { get; } = uriBuilder.BlobName; + public InMemoryStorageProvider Provider { get; } = provider ?? throw new ArgumentNullException(nameof(provider)); + + private readonly BlobScope _scope = new(uriBuilder.AccountName, uriBuilder.BlobContainerName, uriBuilder.BlobName); + + public async Task DownloadAsync(BlobDownloadOptions? options, CancellationToken cancellationToken) + { + return (await DownloadCoreAsync(options, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.None)).Info; + } + + public async Task DownloadStreamingAsync(BlobDownloadOptions? options, CancellationToken cancellationToken) + { + var (info, content) = await DownloadCoreAsync(options, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.None); + return BlobsModelFactory.BlobDownloadStreamingResult(content.ToStream(), info.Details); + } + + public async Task DownloadContentAsync(BlobDownloadOptions? options, CancellationToken cancellationToken) + { + var (info, content) = await DownloadCoreAsync(options, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.None); + return BlobsModelFactory.BlobDownloadResult(content, info.Details); + } + + private async Task<(BlobDownloadInfo Info, BinaryData Content)> DownloadCoreAsync(BlobDownloadOptions? options, CancellationToken cancellationToken) + { + var beforeContext = new BlobDownloadBeforeHookContext(_scope, Provider, cancellationToken) + { + Options = options + }; + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + using var blob = AcquireBlob(cancellationToken); + + if (!blob.Value.TryDownload(options, out var content, out var properties, out var error)) + { + throw error.GetClientException(); + } + + var info = BlobsModelFactory.BlobDownloadInfo( + blobType: blob.Value.BlobType, + contentLength: content.GetLenght(), + eTag: properties.ETag, + lastModified: properties.LastModified, + content: content.ToStream() + ); + + var afterContext = new BlobDownloadAfterHookContext(beforeContext) + { + BlobDownloadDetails = info.Details, + Content = content + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return (info, content); + } + + public BlobProperties GetProperties(BlobRequestConditions? conditions, CancellationToken cancellationToken) + { + using var blob = AcquireBlob(cancellationToken); + + if (!blob.Value.TryGetProperties(conditions, out var properties, out var error)) + { + throw error.GetClientException(); + } + + return properties; + } + + public bool Exists(CancellationToken cancellationToken) + { + using var blob = AcquireBlob(cancellationToken); + + return blob.Value.Exists; + } + + public BlockList GetBlockList(BlockListTypes types, BlobRequestConditions? conditions, CancellationToken cancellationToken) + { + using var blob = AcquireBlob(cancellationToken); + + if (!blob.Value.TryGetBlockList(types, conditions, out var blockList, out var error)) + { + throw error.GetClientException(); + } + + return blockList; + + } + + public async Task UploadAsync(BinaryData content, BlobUploadOptions? options, bool? overwrite, CancellationToken cancellationToken) + { + var beforeContext = new BlobUploadBeforeHookContext(_scope, Provider, cancellationToken) + { + Content = content, + Options = options + }; + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + RequestConditions? conditions = options?.Conditions; + + var contentMemory = content.ToMemory(); + + var index = 0; + + var blockList = new List(); + + while (index < contentMemory.Length) + { + var blockSize = Math.Min(contentMemory.Length - index, InMemoryBlobService.MaxBlockSize); + + var blockId = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + + var block = new BinaryData(contentMemory[index..blockSize]); + + using (var blob = AcquireBlob(cancellationToken)) + { + if (!blob.Value.TryStageBlock(blockId, block, conditions, out _, out var error)) + { + throw error.GetClientException(); + } + } + + blockList.Add(blockId); + index += blockSize; + } + + using var blobToCommit = AcquireBlob(cancellationToken); + + var result = CommitBlockListCoreUnsafe(blockList, blobToCommit.Value, conditions, overwrite, options?.HttpHeaders, options?.Metadata); + + var afterContext = new BlobUploadAfterHookContext(beforeContext) + { + BlobContentInfo = result, + Content = content + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return result; + } + + + + public BlobContentInfo CommitBlockList(IEnumerable blockIds, CommitBlockListOptions? options, CancellationToken cancellationToken) + { + RequestConditions? conditions = options?.Conditions; + + using var blob = AcquireBlob(cancellationToken); + + return CommitBlockListCoreUnsafe(blockIds, blob.Value, conditions, null, options?.HttpHeaders, options?.Metadata); + } + + + public BlockInfo StageBlock(string blockId, BinaryData content, BlockBlobStageBlockOptions? options, CancellationToken cancellationToken) + { + RequestConditions? conditions = options?.Conditions; + + using var blob = AcquireBlob(cancellationToken); + + if (!blob.Value.TryStageBlock(blockId, content, conditions, out var block, out var stageError)) + { + throw stageError.GetClientException(); + } + + return block.GetInfo(); + } + + public Stream OpenWrite(bool overwrite, BlobRequestConditions? conditions, long? bufferSize, CancellationToken cancellationToken) + { + if (!overwrite) + { + throw new ArgumentException("BlockBlobClient.OpenWrite only supports overwriting"); + } + + using var blob = AcquireBlob(cancellationToken); + + if (!blob.Value.TryOpenWrite(conditions, bufferSize, out var stream, out var error)) + { + throw error.GetClientException(); + } + + return stream; + } + + public Response Delete(DeleteSnapshotsOption snapshotsOption, BlobRequestConditions? conditions, CancellationToken cancellationToken) + { + if (snapshotsOption != DeleteSnapshotsOption.None) + { + throw BlobExceptionFactory.FeatureNotSupported(nameof(DeleteSnapshotsOption)); + } + + using var blob = AcquireBlob(cancellationToken); + + if (!blob.Value.TryDeleteIfExists(conditions, out var deleted, out var error)) + { + throw error.GetClientException(); + } + + if (!deleted.Value) + { + throw BlobExceptionFactory.BlobNotFound(AccountName, BlobContainerName, Name); + } + + return new InMemoryResponse(202); + + } + + public Response DeleteIfExists(DeleteSnapshotsOption snapshotsOption, BlobRequestConditions? conditions, CancellationToken cancellationToken) + { + + if (snapshotsOption != DeleteSnapshotsOption.None) + { + throw BlobExceptionFactory.FeatureNotSupported(nameof(DeleteSnapshotsOption)); + } + + using var blob = AcquireBlob(cancellationToken); + + if (!blob.Value.TryDeleteIfExists(conditions, out var deleted, out var error)) + { + throw error.GetClientException(); + } + + if (deleted.Value) + { + return InMemoryResponse.FromValue(true, 202); + } + else + { + return Response.FromValue(false, null!); + } + } + + + public BlobContainerClient GetParentContainerClient() + { + var containerUriBuilder = new BlobUriBuilder(Uri) + { + BlobName = null + }; + + return new InMemoryBlobContainerClient(containerUriBuilder.ToUri(), Provider); + } + + private static BlobContentInfo CommitBlockListCoreUnsafe( + IEnumerable blockIds, + InMemoryBlockBlob blob, + RequestConditions? conditions, + bool? overwrite, + BlobHttpHeaders? headers, + IDictionary? metadata) + { + if (!blob.TryCommitBlockList(blockIds, conditions, overwrite, headers, metadata, out var properties, out var error)) + { + throw error.GetClientException(); + } + + return BlobsModelFactory.BlobContentInfo(properties.ETag, properties.LastModified, default, default, default, default, default); + } + + private InMemoryBlobContainer.AcquiredBlob AcquireBlob(CancellationToken cancellationToken) + { + if (!Provider.TryGetAccount(AccountName, out var account)) + { + throw BlobExceptionFactory.BlobServiceNotFound(AccountName, Provider); + } + + if (!account.BlobService.TryGetBlobContainer(BlobContainerName, out var container)) + { + throw BlobExceptionFactory.ContainerNotFound(BlobContainerName, account.BlobService); + } + + return container.AcquireBlob(Name, cancellationToken); + } + + private Task ExecuteBeforeHooksAsync(TContext context) where TContext : BlobBeforeHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + private Task ExecuteAfterHooksAsync(TContext context) where TContext : BlobAfterHookContext + { + return Provider.ExecuteHooksAsync(context); + } +} + + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobExceptionFactory.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobExceptionFactory.cs new file mode 100644 index 0000000..be874e8 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobExceptionFactory.cs @@ -0,0 +1,179 @@ +using System.Runtime.CompilerServices; + +using Azure; +using Azure.Storage.Blobs.Models; + +using Spotflow.InMemory.Azure.Storage.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Internals; + +internal static class BlobExceptionFactory +{ + public static HttpRequestException BlobServiceNotFound(string accountName, InMemoryStorageProvider provider) + { + return new($"Host '{provider.GetAccount(accountName).BlobServiceUri}' not found."); + } + + + + public static RequestFailedException ContainerNotFound(string containerName, InMemoryBlobService blobService) + { + return new( + 404, + $"Container '{containerName}' not found in '{blobService}' account.", + BlobErrorCode.ContainerNotFound.ToString(), + null); + } + + public static RequestFailedException ContainerAlreadyExists(string accountName, string containerName) + { + return new( + 412, + $"Container '{containerName}' in account '{accountName}' already exist.", + BlobErrorCode.ContainerNotFound.ToString(), + null); + } + + public static RequestFailedException BlobNotFound(string accountName, string blobContainerName, string blobName) + { + return new(404, $"Blob '{blobName}' not found in container '{blobContainerName}' in account '{accountName}'.", BlobErrorCode.BlobNotFound.ToString(), null); + } + + public static RequestFailedException BlockCountExceeded(string accountName, string blobContainerName, string blobName, int limit, int actualCount) + { + return new( + 409, + $"Number of blocks for in a block list ({actualCount} exceeded the limit ({limit}) " + + $"in blob '{blobName}' in container '{blobContainerName}' in account '{accountName}'.", + BlobErrorCode.BlockCountExceedsLimit.ToString(), + null + ); + } + + public static RequestFailedException BlockNotFound(string accountName, string blobContainerName, string blobName, string blockId) + { + return new( + 400, + $"Block '{blockId}' not found in blob '{blobName}' in container '{blobContainerName}' in account '{accountName}'.", + BlobErrorCode.InvalidBlockList.ToString(), + null + ); + } + + public static RequestFailedException TooManyUncommittedBlocks(string accountName, string blobContainerName, string blobName, int limit, int actualCount) + { + return new( + 409, + $"Number of uncommited blocks ({actualCount}) exceeded the limit ({limit}) " + + $"in blob '{blobName}' in container '{blobContainerName}' in account '{accountName}'.", + BlobErrorCode.BlockCountExceedsLimit.ToString(), + null); + } + + public static RequestFailedException BlockTooLarge(string accountName, string blobContainerName, string blobName, int limit, int actualSize) + { + return new( + 413, + $"Size of block ({actualSize}) exceeded the limit ({limit}) " + + $"in blob '{blobName}' in container '{blobContainerName}' in account '{accountName}'.", + BlobErrorCode.RequestBodyTooLarge.ToString(), + null); + } + + public static RequestFailedException ConditionNotMet(string accountName, string blobContainerName, string blobName, ConditionError error) + { + return new( + 412, + $"Condition {error.ConditionType} " + + $"for blob '{blobName}' in container '{blobContainerName}' in account '{accountName} " + + $"not met: {error.Message}'.", + BlobErrorCode.ConditionNotMet.ToString(), + null); + } + + public static RequestFailedException ConditionNotMet(ConditionType conditionType, string accountName, string blobContainerName, string message) + { + return new( + 412, + $"Condition {conditionType} " + + $"for container '{blobContainerName}' in account '{accountName} " + + $"not met: {message}'.", + BlobErrorCode.ConditionNotMet.ToString(), + null); + } + + public static RequestFailedException InvalidQueryParameterValue( + string accountName, + string blobContainerName, + string blobName, + string parameterName, + string actualValue, + string reason) + { + var ex = new RequestFailedException( + 400, + $"Invalid query parameter '{parameterName}' = '{actualValue}' " + + $"for blob '{blobName}' in container '{blobContainerName}' in account '{accountName} ", + BlobErrorCode.InvalidQueryParameterValue.ToString(), + null + ); + + ex.Data["QueryParameterName"] = parameterName; + ex.Data["QueryParameterValue"] = actualValue; + ex.Data["Reason"] = reason; + + return ex; + } + + public static RequestFailedException InvalidContainerName(string accountName, string blobContainerName) + { + return new( + 400, + $"Container name '{blobContainerName}' is invalid for account '{accountName}'.", + BlobErrorCode.InvalidResourceName.ToString(), + null); + } + + public static RequestFailedException BlobAlreadyExists(string accountName, string containerName, string name) + { + return new( + 409, + $"Blob '{name}' already exists in container '{containerName}' in account '{accountName}'.", + BlobErrorCode.BlobAlreadyExists.ToString(), + null); + } + + public static NotSupportedException MethodNotSupported([CallerMemberName] string? callerMemberName = null) + { + return new($"In-memory blob storage client does not support method '{callerMemberName}'."); + } + + public static NotSupportedException FeatureNotSupported(string featureName) + { + return new($"In-memory blob storage client does not support feature '{featureName}'."); + } + + public static RequestFailedException ServiceIsBusy(string accountName) + { + return new( + 503, + $"Blob service in account '{accountName}' is busy.", + BlobErrorCode.ServerBusy.ToString(), + null); + } + + public static RequestFailedException AuthenticationFailedSignatureDidNotMatch(string storageAccountName) + { + var exception = new RequestFailedException( + 403, + $"Server failed to authenticate the request. " + + $"Make sure the value of Authorization header is formed correctly including the signature. " + + $"Storage account '{storageAccountName}'.", + BlobErrorCode.AuthenticationFailed.ToString(), + null); + + exception.Data["AuthenticationErrorDetail"] = "Signature did not match."; + + return exception; + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobUriUtils.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobUriUtils.cs new file mode 100644 index 0000000..083a166 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobUriUtils.cs @@ -0,0 +1,96 @@ +using Azure.Storage.Blobs; + +using Spotflow.InMemory.Azure.Storage.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Internals; +internal static class BlobUriUtils +{ + public static BlobUriBuilder BuilderForBlob(string? connectionString, Uri? uri, string? blobContainerName, string? blobName, InMemoryStorageProvider provider) + { + var builder = Builder(connectionString, uri, blobContainerName, blobName, provider); + + if (string.IsNullOrWhiteSpace(builder.BlobContainerName)) + { + throw new InvalidOperationException("Blob container name must be specified when creating a blob client."); + } + + if (string.IsNullOrWhiteSpace(builder.BlobName)) + { + throw new InvalidOperationException("Blob name must be specified when creating a blob client."); + } + + return builder; + } + + public static BlobUriBuilder BuilderForContainer(string? connectionString, Uri? uri, string? blobContainerName, InMemoryStorageProvider provider) + { + var builder = Builder(connectionString, uri, blobContainerName, null, provider); + + if (string.IsNullOrWhiteSpace(builder.BlobContainerName)) + { + throw new InvalidOperationException("Blob container name must be specified when creating a blob container client."); + } + + return builder; + } + + public static BlobUriBuilder BuilderForService(string? connectionString, Uri? uri, InMemoryStorageProvider provider) + { + var builder = Builder(connectionString, uri, null, null, provider); + + return builder; + } + + public static Uri UriForBlob(Uri blobServiceUri, string blobContainerName, string blobName) + { + var builder = Builder(null, blobServiceUri, blobContainerName, blobName, null); + + return builder.ToUri(); + + } + + public static Uri UriForContainer(Uri blobServiceUri, string blobContainerName) + { + var builder = Builder(null, blobServiceUri, blobContainerName, null, null); + + return builder.ToUri(); + } + + private static BlobUriBuilder Builder(string? connectionString, Uri? uri, string? blobContainerName, string? blobName, InMemoryStorageProvider? provider) + { + if (connectionString is not null && uri is not null) + { + throw new InvalidOperationException("Both a connection string and a URI cannot be provided."); + } + + if (uri is null) + { + if (connectionString is null) + { + throw new InvalidOperationException("Either a connection string or a URI must be provided."); + } + + if (provider is null) + { + throw new InvalidOperationException("A provider must be provided when using a connection string."); + } + + var accountName = StorageConnectionStringUtils.GetAccountNameFromConnectionString(connectionString); + uri = InMemoryBlobService.CreateServiceUriFromAccountName(accountName, provider); + } + + var builder = new BlobUriBuilder(uri); + + if (blobContainerName is not null) + { + builder.BlobContainerName = blobContainerName; + } + + if (blobName is not null) + { + builder.BlobName = blobName; + } + + return builder; + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobWriteStream.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobWriteStream.cs new file mode 100644 index 0000000..465b338 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/BlobWriteStream.cs @@ -0,0 +1,105 @@ +using Azure; +using Azure.Storage.Blobs.Models; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Internals; + +internal class BlobWriteStream(InMemoryBlockBlobClient blobClient, ETag eTag, long? bufferSize) : Stream +{ + private const int _defaultBufferSize = 4 * 1024 * 1024; + + private readonly object _syncObj = new(); + private readonly List _blocks = []; + private readonly byte[] _buffer = new byte[bufferSize ?? _defaultBufferSize]; + private readonly BlockBlobStageBlockOptions _stageOptions = new() { Conditions = new() { IfMatch = eTag } }; + private readonly CommitBlockListOptions _commitOptions = new() { Conditions = new() { IfMatch = eTag } }; + + private int _bufferPosition = 0; + private bool _isDisposed; + + public override void Write(byte[] buffer, int offset, int count) + { + lock (_syncObj) + { + AssertNotDisposedUnsafe(); + var remainingBytesToCopy = count; + + while (remainingBytesToCopy > 0) + { + var bytesToCopy = Math.Min(remainingBytesToCopy, _buffer.Length - _bufferPosition); + + Array.Copy(buffer, offset, _buffer, _bufferPosition, bytesToCopy); + + _bufferPosition += bytesToCopy; + offset += bytesToCopy; + remainingBytesToCopy -= bytesToCopy; + + if (_bufferPosition == _buffer.Length) + { + FlushUnsafe(); + } + } + } + } + + public override void Flush() + { + lock (_syncObj) + { + AssertNotDisposedUnsafe(); + FlushUnsafe(); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + lock (_syncObj) + { + if (!_isDisposed) + { + FlushUnsafe(); + blobClient.CommitBlockList(_blocks, options: _commitOptions); + MarkDisposeUnsafe(); + } + } + + } + + base.Dispose(disposing); + } + + private void FlushUnsafe() + { + var blockId = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + + var content = new MemoryStream(_buffer, 0, _bufferPosition); + + blobClient.StageBlock(blockId, content, options: _stageOptions); + + _blocks.Add(blockId); + + _bufferPosition = 0; + } + + private void AssertNotDisposedUnsafe() => ObjectDisposedException.ThrowIf(_isDisposed, this); + + private void MarkDisposeUnsafe() => _isDisposed = true; + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlobContainer.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlobContainer.cs new file mode 100644 index 0000000..87f22a9 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlobContainer.cs @@ -0,0 +1,82 @@ +using Azure; +using Azure.Storage.Blobs.Models; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Internals; + +internal class InMemoryBlobContainer(string name, IDictionary? metadata, InMemoryBlobService service) +{ + + private readonly object _lock = new(); + private readonly Dictionary _blobEntries = []; + + private readonly BlobContainerProperties _properties = BlobsModelFactory.BlobContainerProperties( + lastModified: service.Account.Provider.TimeProvider.GetUtcNow(), + eTag: new ETag(Guid.NewGuid().ToString()), + metadata: metadata); + + public string Name { get; } = name; + + public string AccountName => Service.Account.Name; + + public BlobContainerProperties GetProperties() + { + lock (_lock) + { + return _properties; + } + } + + public InMemoryBlobService Service { get; } = service; + + public override string? ToString() => $"{Service} / {Name}"; + + public IReadOnlyList GetBlobs(string? prefix) + { + lock (_lock) + { + return _blobEntries + .Values + .Where(entry => entry.Blob.Exists) + .Where(entry => prefix is null ? true : entry.Blob.Name.StartsWith(prefix)) + .Select(entry => BlobsModelFactory.BlobItem(entry.Blob.Name)) + .ToList(); + } + } + + public AcquiredBlob AcquireBlob(string blobName, CancellationToken cancellationToken) + { + var entry = GetBlobEntry(blobName); + + entry.Semaphore.Wait(cancellationToken); + + return new(entry.Blob, entry.Semaphore); + } + + private BlobEntry GetBlobEntry(string blobName) + { + BlobEntry? entry; + + lock (_lock) + { + if (!_blobEntries.TryGetValue(blobName, out entry)) + { + var blob = new InMemoryBlockBlob(blobName, this); + entry = new(blob, new(1, 1)); + _blobEntries.Add(blobName, entry); + } + } + + return entry; + } + + public sealed class AcquiredBlob(InMemoryBlockBlob blob, SemaphoreSlim semaphore) : IDisposable + { + public InMemoryBlockBlob Value { get; } = blob ?? throw new ArgumentNullException(nameof(blob)); + + public void Dispose() => semaphore.Release(); + } + + private record BlobEntry(InMemoryBlockBlob Blob, SemaphoreSlim Semaphore); + +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlobService.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlobService.cs new file mode 100644 index 0000000..de127cc --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlobService.cs @@ -0,0 +1,130 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure; + +using Spotflow.InMemory.Azure.Storage.Resources; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Internals; + +internal class InMemoryBlobService(InMemoryStorageAccount account) +{ + private readonly object _syncObj = new(); + + private readonly Dictionary _containers = []; + + public Uri Uri { get; } = CreateServiceUriFromAccountName(account.Name, account.Provider); + + public static int MaxBlockCount { get; } = 50_000; + public static int MaxUncommitedBlocks { get; } = 100_000; + public static int MaxBlockSize { get; } = 2000 * 1024 * 1024; + + public InMemoryStorageAccount Account { get; } = account; + + public bool TryAddBlobContainer( + string blobContainerName, + IDictionary? metadata, + [NotNullWhen(true)] out InMemoryBlobContainer? result, + [NotNullWhen(false)] out CreateContainerError? error) + { + if (!ValidateContainerName(blobContainerName)) + { + result = null; + error = new CreateContainerError.InvalidContainerName(Account.Name, blobContainerName); + return false; + } + + + lock (_syncObj) + { + if (_containers.TryGetValue(blobContainerName, out var existingContainer)) + { + result = null; + error = new CreateContainerError.ContainerAlreadyExists(existingContainer); + return false; + } + + var newContainer = new InMemoryBlobContainer(blobContainerName, metadata, this); + + _containers.Add(blobContainerName, newContainer); + + result = newContainer; + error = null; + return true; + } + + } + + public bool TryGetBlobContainer(string blobContainerName, [NotNullWhen(true)] out InMemoryBlobContainer? container) + { + lock (_syncObj) + { + return _containers.TryGetValue(blobContainerName, out container); + } + } + + public override string ToString() => Uri.ToString().TrimEnd('/'); + + public bool ContainerExists(string name) => TryGetBlobContainer(name, out _); + + public static Uri CreateServiceUriFromAccountName(string accountName, InMemoryStorageProvider provider) + { + return new($"https://{accountName}.blob.{provider.HostnameSuffix}"); + } + + private static bool ValidateContainerName(string blobContainerName) + { + if (blobContainerName.Length < 3 || blobContainerName.Length > 63) + { + return false; + } + + if (!char.IsLetterOrDigit(blobContainerName[0])) + { + return false; + } + + if (!char.IsLetterOrDigit(blobContainerName[^1])) + { + return false; + } + + if (blobContainerName.Any(c => !char.IsLetterOrDigit(c) && c != '-')) + { + return false; + } + + if (blobContainerName.Contains("--")) + { + return false; + } + + return true; + } + + public Uri CreateBlobSasUri(string blobContainerName, string blobName) => BlobUriUtils.UriForBlob(Uri, blobContainerName, blobName); + + public Uri CreateContainerSasUri(string blobContainerName) => BlobUriUtils.UriForContainer(Uri, blobContainerName); + + public abstract class CreateContainerError + { + public abstract RequestFailedException GetClientException(); + + public class ContainerAlreadyExists(InMemoryBlobContainer existingContainer) : CreateContainerError + { + public InMemoryBlobContainer ExistingContainer => existingContainer; + + public override RequestFailedException GetClientException() + { + return BlobExceptionFactory.ContainerAlreadyExists(existingContainer.AccountName, existingContainer.Name); + } + } + + public class InvalidContainerName(string accountName, string blobContainerName) : CreateContainerError + { + public override RequestFailedException GetClientException() + { + return BlobExceptionFactory.InvalidContainerName(accountName, blobContainerName); + } + } + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlockBlob.cs b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlockBlob.cs new file mode 100644 index 0000000..cc8514d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Blobs/Internals/InMemoryBlockBlob.cs @@ -0,0 +1,503 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure; +using Azure.Storage.Blobs.Models; + +using Spotflow.InMemory.Azure.Storage.Internals; + +using static Spotflow.InMemory.Azure.Storage.Blobs.Internals.InMemoryBlockBlob.StageBlockError; + +namespace Spotflow.InMemory.Azure.Storage.Blobs.Internals; + +internal class InMemoryBlockBlob(string blobName, InMemoryBlobContainer container) +{ + private Dictionary? _uncommittedBlocks = null; + private List? _committedBlocks = null; + private BinaryData? _cachedContent = null; + private BlobProperties? _properties = null; + + public BlobType BlobType { get; } = BlobType.Block; + + public string Name { get; } = blobName; + public string ContainerName => Container.Name; + public string AccountName => Container.AccountName; + + public InMemoryBlobContainer Container { get; } = container; + + public override string? ToString() => $"{Container} / {Name}"; + + public bool Exists => _properties is not null; + + public bool TryGetProperties( + BlobRequestConditions? conditions, + [NotNullWhen(true)] out BlobProperties? properties, + [NotNullWhen(false)] out GetPropertiesError? error) + { + if (_properties is null) + { + error = new GetPropertiesError.BlobNotFound(this); + properties = null; + return false; + } + + if (!ConditionChecker.CheckConditions(_properties.ETag, conditions?.IfMatch, conditions?.IfNoneMatch, out var conditionError)) + { + error = new GetPropertiesError.ConditionNotMet(this, conditionError); + properties = null; + return false; + } + + properties = _properties; + error = null; + return true; + } + + public bool TryStageBlock( + string base64BlockId, + BinaryData content, + RequestConditions? conditions, + [NotNullWhen(true)] out Block? result, + [NotNullWhen(false)] out StageBlockError? error) + { + + try + { + _ = Convert.FromBase64String(base64BlockId); + } + catch (FormatException) + { + result = null; + error = new InvalidBlockId(this, base64BlockId); + return false; + } + + if (content.GetLenght() > InMemoryBlobService.MaxBlockSize) + { + result = null; + error = new BlockTooLarge(this, InMemoryBlobService.MaxBlockSize, content.GetLenght()); + return false; + } + + if (_properties is not null && conditions?.IfNoneMatch == ETag.All) + { + error = new BlobAlreadyExist(this); + result = null; + return false; + } + + if (!ConditionChecker.CheckConditions(_properties?.ETag, conditions?.IfMatch, conditions?.IfNoneMatch, out var conditionError)) + { + result = null; + error = new ConditionNotMet(this, conditionError); + return false; + } + + _uncommittedBlocks ??= []; + + if (_uncommittedBlocks.Count >= InMemoryBlobService.MaxUncommitedBlocks) + { + result = null; + error = new TooManyUncommittedBlocks(this, InMemoryBlobService.MaxUncommitedBlocks, _uncommittedBlocks.Count); + return false; + } + + var block = new Block(base64BlockId, content); + + _uncommittedBlocks[base64BlockId] = block; + + result = block; + error = null; + return true; + + } + + public bool TryCommitBlockList( + IEnumerable base64BlockIds, + RequestConditions? conditions, + bool? overwrite, + BlobHttpHeaders? headers, + IDictionary? metadata, + [NotNullWhen(true)] out BlobProperties? properties, + [NotNullWhen(false)] out CommitBlockListError? error) + { + + + var canOverwrite = overwrite ?? conditions?.IfNoneMatch != ETag.All; + + if (_committedBlocks is not null && !canOverwrite) + { + error = new CommitBlockListError.BlobAlreadyExist(this); + properties = null; + return false; + } + + if (!ConditionChecker.CheckConditions(_properties?.ETag, conditions?.IfMatch, conditions?.IfNoneMatch, out var conditionError)) + { + properties = null; + error = new CommitBlockListError.ConditionNotMet(this, conditionError); + return false; + } + + IReadOnlyDictionary? currentUncommittedBlocks = _uncommittedBlocks; + IReadOnlyDictionary? currentCommittedBlocks = _committedBlocks?.ToDictionary(b => b.Id); + + var stagingCommittedBlocks = new List(); + + foreach (var id in base64BlockIds) + { + if (currentUncommittedBlocks is not null) + { + if (currentUncommittedBlocks.TryGetValue(id, out var block)) + { + stagingCommittedBlocks.Add(block); + continue; + } + } + + if (currentCommittedBlocks is not null) + { + if (currentCommittedBlocks.TryGetValue(id, out var block)) + { + stagingCommittedBlocks.Add(block); + continue; + } + } + + error = new CommitBlockListError.BlockNotFound(this, id); + properties = null; + return false; + } + + if (stagingCommittedBlocks.Count > InMemoryBlobService.MaxBlockCount) + { + error = new CommitBlockListError.BlockCountExceeded(this, InMemoryBlobService.MaxBlockCount, stagingCommittedBlocks.Count); + properties = null; + return false; + } + + SetCommitedState(headers, metadata, stagingCommittedBlocks); + + error = null; + properties = _properties; + return true; + + } + + public bool TryDownload( + BlobDownloadOptions? options, + [NotNullWhen(true)] out BinaryData? content, + [NotNullWhen(true)] out BlobProperties? properties, + [NotNullWhen(false)] out DownloadError? error) + { + if (_properties is null) + { + error = new DownloadError.BlobNotFound(this); + content = null; + properties = null; + return false; + } + + if (!ConditionChecker.CheckConditions(_properties.ETag, options?.Conditions?.IfMatch, options?.Conditions?.IfNoneMatch, out var conditionError)) + { + error = new DownloadError.ConditionNotMet(this, conditionError); + content = null; + properties = null; + return false; + } + + content = GetContent(); + properties = _properties; + error = null; + return true; + + } + + public bool TryGetBlockList( + BlockListTypes types, + BlobRequestConditions? conditions, + [NotNullWhen(true)] out BlockList? blockList, + [NotNullWhen(false)] out GetBlockListError? error) + { + if (_uncommittedBlocks is null && _committedBlocks is null) + { + error = new GetBlockListError.BlobNotFound(this); + blockList = null; + return false; + } + + + if (!ConditionChecker.CheckConditions(_properties?.ETag, conditions?.IfMatch, conditions?.IfNoneMatch, out var conditionError)) + { + blockList = null; + error = new GetBlockListError.ConditionNotMet(this, conditionError); + return false; + } + + IEnumerable? commitedBlocks = null; + IEnumerable? uncommittedBlocks = null; + + if (types.HasFlag(BlockListTypes.Committed)) + { + commitedBlocks = _committedBlocks?.Select(b => BlobsModelFactory.BlobBlock(b.Id, b.Content.GetLenght())); + commitedBlocks ??= Enumerable.Empty(); + } + + if (types.HasFlag(BlockListTypes.Uncommitted)) + { + uncommittedBlocks = _uncommittedBlocks?.Values.Select(b => BlobsModelFactory.BlobBlock(b.Id, b.Content.GetLenght())); + uncommittedBlocks ??= Enumerable.Empty(); + } + + blockList = BlobsModelFactory.BlockList(commitedBlocks, uncommittedBlocks); + error = null; + return true; + } + + public bool TryOpenWrite(RequestConditions? conditions, long? bufferSize, [NotNullWhen(true)] out Stream? stream, [NotNullWhen(false)] out OpenWriteError? error) + { + if (!ConditionChecker.CheckConditions(_properties?.ETag, conditions?.IfMatch, conditions?.IfNoneMatch, out var conditionError)) + { + stream = null; + error = new OpenWriteError.ConditionNotMet(this, conditionError); + return false; + } + + SetCommitedState(null, null, []); + + var client = InMemoryBlockBlobClient.FromAccount(Container.Service.Account, ContainerName, Name); + + stream = new BlobWriteStream(client, _properties.ETag, bufferSize); + error = null; + return true; + + } + + public bool TryDeleteIfExists(BlobRequestConditions? conditions, [NotNullWhen(true)] out bool? deleted, [NotNullWhen(false)] out DeleteError? error) + { + if (_properties is null) + { + error = null; + deleted = false; + return true; + } + + if (!ConditionChecker.CheckConditions(_properties.ETag, conditions?.IfMatch, conditions?.IfNoneMatch, out var conditionError)) + { + error = new DeleteError.ConditionNotMet(this, conditionError); + deleted = null; + return false; + } + + DeleteCore(); + + error = null; + deleted = true; + + return true; + } + + private BinaryData GetContent() + { + if (_cachedContent is not null) + { + return _cachedContent; + } + + if (_committedBlocks is null) + { + return _cachedContent = new BinaryData(Array.Empty()); + } + + var len = _committedBlocks.Sum(b => b.Content.GetLenght()); + + Memory buffer = new byte[len]; + + var bufferIndex = 0; + + foreach (var block in _committedBlocks) + { + block.Content.ToMemory().CopyTo(buffer[bufferIndex..]); + bufferIndex += block.Content.GetLenght(); + } + + return _cachedContent = new(buffer); + } + + [MemberNotNull(nameof(_properties))] + [MemberNotNull(nameof(_committedBlocks))] + private void SetCommitedState(BlobHttpHeaders? headers, IDictionary? metadata, List committedBlocks) + { + var newProperties = BlobsModelFactory.BlobProperties( + contentLength: _committedBlocks is null ? 0 : GetContent().ToMemory().Length, + metadata: metadata ?? _properties?.Metadata, + eTag: new ETag(Guid.NewGuid().ToString()), + lastModified: DateTimeOffset.UtcNow, + contentType: headers?.ContentType ?? _properties?.ContentType, + contentEncoding: headers?.ContentEncoding ?? _properties?.ContentEncoding + ); + + _properties = newProperties; + _cachedContent = null; + _uncommittedBlocks = null; + _committedBlocks = committedBlocks; + } + + private void DeleteCore() + { + _properties = null; + _committedBlocks = null; + _uncommittedBlocks = null; + _cachedContent = null; + } + + public record Block(string Id, BinaryData Content) + { + public BlockInfo GetInfo() => BlobsModelFactory.BlockInfo(null, null, null); + } + + public abstract class CommitBlockListError() + { + public abstract RequestFailedException GetClientException(); + + public class BlockCountExceeded(InMemoryBlockBlob blob, int Limit, int ActualCount) : CommitBlockListError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.BlockCountExceeded(blob.AccountName, blob.ContainerName, blob.Name, Limit, ActualCount); + } + + public class BlockNotFound(InMemoryBlockBlob blob, string BlockId) : CommitBlockListError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.BlockNotFound(blob.AccountName, blob.ContainerName, blob.Name, BlockId); + } + + public class BlobAlreadyExist(InMemoryBlockBlob blob) : CommitBlockListError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.BlobAlreadyExists(blob.AccountName, blob.ContainerName, blob.Name); + } + + public class ConditionNotMet(InMemoryBlockBlob blob, ConditionError error) : CommitBlockListError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.ConditionNotMet(blob.AccountName, blob.ContainerName, blob.Name, error); + } + + + + } + + public abstract class GetBlockListError + { + public abstract RequestFailedException GetClientException(); + + public class ConditionNotMet(InMemoryBlockBlob blob, ConditionError error) : GetBlockListError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.ConditionNotMet(blob.AccountName, blob.ContainerName, blob.Name, error); + } + + public class BlobNotFound(InMemoryBlockBlob blob) : GetBlockListError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.BlobNotFound(blob.AccountName, blob.ContainerName, blob.Name); + } + } + + public abstract class DeleteError + { + public abstract RequestFailedException GetClientException(); + + public class ConditionNotMet(InMemoryBlockBlob blob, ConditionError error) : DeleteError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.ConditionNotMet(blob.AccountName, blob.ContainerName, blob.Name, error); + } + } + + public abstract class GetPropertiesError + { + public abstract RequestFailedException GetClientException(); + + public class BlobNotFound(InMemoryBlockBlob blob) : GetPropertiesError + { + public override RequestFailedException GetClientException() + { + return BlobExceptionFactory.BlobNotFound(blob.AccountName, blob.ContainerName, blob.Name); + } + } + + public class ConditionNotMet(InMemoryBlockBlob blob, ConditionError error) : GetPropertiesError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.ConditionNotMet(blob.AccountName, blob.ContainerName, blob.Name, error); + } + } + + public abstract class DownloadError + { + public abstract RequestFailedException GetClientException(); + + public class BlobNotFound(InMemoryBlockBlob blob) : DownloadError + { + public override RequestFailedException GetClientException() + { + return BlobExceptionFactory.BlobNotFound(blob.AccountName, blob.ContainerName, blob.Name); + } + } + + public class ConditionNotMet(InMemoryBlockBlob blob, ConditionError error) : DownloadError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.ConditionNotMet(blob.AccountName, blob.ContainerName, blob.Name, error); + } + } + + public abstract class OpenWriteError + { + public abstract RequestFailedException GetClientException(); + + public class ConditionNotMet(InMemoryBlockBlob blob, ConditionError error) : OpenWriteError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.ConditionNotMet(blob.AccountName, blob.ContainerName, blob.Name, error); + } + } + + public abstract class StageBlockError + { + public abstract RequestFailedException GetClientException(); + + public class BlobAlreadyExist(InMemoryBlockBlob blob) : StageBlockError + { + public override RequestFailedException GetClientException() + { + return BlobExceptionFactory.BlobAlreadyExists(blob.AccountName, blob.ContainerName, blob.Name); + } + } + + public class TooManyUncommittedBlocks(InMemoryBlockBlob blob, int limit, int actualCount) : StageBlockError + { + public override RequestFailedException GetClientException() + { + return BlobExceptionFactory.TooManyUncommittedBlocks(blob.AccountName, blob.ContainerName, blob.Name, limit, actualCount); + } + } + public class BlockTooLarge(InMemoryBlockBlob blob, int limit, int actualSize) : StageBlockError + { + public override RequestFailedException GetClientException() + { + return BlobExceptionFactory.BlockTooLarge(blob.AccountName, blob.ContainerName, blob.Name, limit, actualSize); + } + } + public class ConditionNotMet(InMemoryBlockBlob blob, ConditionError error) : StageBlockError + { + public override RequestFailedException GetClientException() => BlobExceptionFactory.ConditionNotMet(blob.AccountName, blob.ContainerName, blob.Name, error); + } + + public class InvalidBlockId(InMemoryBlockBlob blob, string actualValue) : StageBlockError + { + public override RequestFailedException GetClientException() + { + return BlobExceptionFactory.InvalidQueryParameterValue( + blob.AccountName, + blob.ContainerName, + blob.Name, + "blockid", + actualValue, + "Not a valid base64 string." + ); + } + } + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Hooks/Contexts/StorageAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Hooks/Contexts/StorageAfterHookContext.cs new file mode 100644 index 0000000..8ad453a --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Hooks/Contexts/StorageAfterHookContext.cs @@ -0,0 +1,5 @@ + +namespace Spotflow.InMemory.Azure.Storage.Hooks.Contexts; + +public abstract class StorageAfterHookContext(StorageBeforeHookContext before) + : StorageHookContext(before.StorageAccountName, before.ResourceProvider, before.CancellationToken); diff --git a/src/Spotflow.InMemory.Azure.Storage/Hooks/Contexts/StorageBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Hooks/Contexts/StorageBeforeHookContext.cs new file mode 100644 index 0000000..7f085e9 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Hooks/Contexts/StorageBeforeHookContext.cs @@ -0,0 +1,6 @@ + +namespace Spotflow.InMemory.Azure.Storage.Hooks.Contexts; + +public abstract class StorageBeforeHookContext(StorageAccountScope scope, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : StorageHookContext(scope.StorageAccountName, provider, cancellationToken); + diff --git a/src/Spotflow.InMemory.Azure.Storage/Hooks/Contexts/StorageHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Hooks/Contexts/StorageHookContext.cs new file mode 100644 index 0000000..36e65bc --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Hooks/Contexts/StorageHookContext.cs @@ -0,0 +1,33 @@ +using Spotflow.InMemory.Azure.Hooks; + +namespace Spotflow.InMemory.Azure.Storage.Hooks.Contexts; + +public abstract class StorageHookContext(string storageAccountName, InMemoryStorageProvider provider, CancellationToken cancellationToken) +{ + public string StorageAccountName => storageAccountName; + public TimeProvider TimeProvider => ResourceProvider.TimeProvider; + + public abstract StorageFaultsBuilder Faults(); + + public CancellationToken CancellationToken => cancellationToken; + + public InMemoryStorageProvider ResourceProvider => provider; + + /// + /// Adds delay before operations that is generated from . + /// + public async Task DelayAsync(IDelayGenerator delayGenerator) + { + var delay = delayGenerator.Next(); + await Task.Delay(delay, TimeProvider, CancellationToken).ConfigureAwait(ConfigureAwaitOptions.None); + } + + /// + /// Adds constant delay before operations. + /// + public Task DelayAsync(TimeSpan constantDelay) + { + return DelayAsync(new ConstantDelayGenerator(constantDelay)); + } + +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Hooks/Internals/StorageHookFilter.cs b/src/Spotflow.InMemory.Azure.Storage/Hooks/Internals/StorageHookFilter.cs new file mode 100644 index 0000000..69d87d0 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Hooks/Internals/StorageHookFilter.cs @@ -0,0 +1,22 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.Storage.Hooks.Internals; + +internal record StorageHookFilter : BaseHookFilter +{ + public string? StorageAccountName { get; private init; } + + public override bool Covers(StorageHookContext context) + { + return StorageAccountName is null || StorageAccountName == context.StorageAccountName; + } + + public StorageHookFilter With(string? storageAccountName) + { + return this with { StorageAccountName = storageAccountName ?? StorageAccountName }; + } +} + + + diff --git a/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageAccountScope.cs b/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageAccountScope.cs new file mode 100644 index 0000000..6ca71e4 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageAccountScope.cs @@ -0,0 +1,3 @@ +namespace Spotflow.InMemory.Azure.Storage.Hooks; + +public record StorageAccountScope(string StorageAccountName); diff --git a/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageFaultsBuilder.cs b/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageFaultsBuilder.cs new file mode 100644 index 0000000..c768d93 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageFaultsBuilder.cs @@ -0,0 +1,7 @@ +namespace Spotflow.InMemory.Azure.Storage.Hooks; + +public abstract class StorageFaultsBuilder +{ + public abstract Task ServiceIsBusy(); +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageHook.cs b/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageHook.cs new file mode 100644 index 0000000..0a29bc7 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageHook.cs @@ -0,0 +1,17 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Hooks; + +public class StorageHook where TContext : StorageHookContext +{ + internal StorageHook(HookFunc hookFunction, StorageHookFilter filter) + { + HookFunction = hookFunction; + Filter = filter; + } + + internal HookFunc HookFunction { get; } + internal StorageHookFilter Filter { get; } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageHookBuilder.cs b/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageHookBuilder.cs new file mode 100644 index 0000000..db7bb07 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Hooks/StorageHookBuilder.cs @@ -0,0 +1,30 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Hooks.Internals; +using Spotflow.InMemory.Azure.Storage.Tables.Hooks; + +namespace Spotflow.InMemory.Azure.Storage.Hooks; + +public class StorageHookBuilder +{ + private readonly StorageHookFilter _filter; + + internal StorageHookBuilder(StorageHookFilter? filter = null) + { + _filter = filter ?? new(); + } + + public BlobServiceHookBuilder ForBlobService(string? storageAccountName = null) => new(_filter.With(storageAccountName)); + public TableServiceHookBuilder ForTableService(string? storageAccountName = null) => new(_filter.With(storageAccountName)); + + public StorageHook Before(HookFunc hook, string? storageAccountName = null) + { + return new(hook, _filter.With(storageAccountName)); + } + + public StorageHook After(HookFunc hook, string? storageAccountName = null) + { + return new(hook, _filter.With(storageAccountName)); + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/InMemoryStorageProvider.cs b/src/Spotflow.InMemory.Azure.Storage/InMemoryStorageProvider.cs new file mode 100644 index 0000000..fb1b81d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/InMemoryStorageProvider.cs @@ -0,0 +1,70 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Hooks.Internals; +using Spotflow.InMemory.Azure.Storage.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Hooks.Internals; +using Spotflow.InMemory.Azure.Storage.Resources; + +namespace Spotflow.InMemory.Azure.Storage; + +public class InMemoryStorageProvider(string? hostnameSuffix = null, TimeProvider? timeProvider = null, ILoggerFactory? loggerFactory = null) +{ + private readonly ConcurrentDictionary _storageAccounts = new(); + + private readonly HooksExecutor _hooksExecutor = new(); + + internal TimeProvider TimeProvider { get; } = timeProvider ?? TimeProvider.System; + internal ILoggerFactory LoggerFactory { get; } = loggerFactory ?? NullLoggerFactory.Instance; + public string HostnameSuffix { get; } = hostnameSuffix ?? "storage.in-memory.example.com"; + + public InMemoryStorageAccount AddAccount(string? accountName = null) + { + accountName ??= GenerateAccountName(); + + var storageAccount = new InMemoryStorageAccount(accountName, this); + + if (!_storageAccounts.TryAdd(accountName, storageAccount)) + { + throw new InvalidOperationException($"Storage account '{accountName}' already exists."); + } + + return storageAccount; + } + + public bool TryGetAccount(string accountName, [NotNullWhen(true)] out InMemoryStorageAccount? result) + { + return _storageAccounts.TryGetValue(accountName, out result); + } + + public InMemoryStorageAccount GetAccount(string accountName) + { + if (!TryGetAccount(accountName, out var account)) + { + throw new InvalidOperationException($"Storage account '{accountName}' not found."); + } + + return account; + } + + public IHookRegistration AddHook(Func> hook) where TContext : StorageHookContext + { + var completedHook = hook(new()); + + return _hooksExecutor.AddHook(completedHook.HookFunction, completedHook.Filter); + } + + internal Task ExecuteHooksAsync(TContext context) where TContext : StorageHookContext + { + return _hooksExecutor.ExecuteHooksAsync(context); + } + + private static string GenerateAccountName() => Guid.NewGuid().ToString().Replace("-", string.Empty)[..24]; +} + + diff --git a/src/Spotflow.InMemory.Azure.Storage/Internals/ConditionChecker.cs b/src/Spotflow.InMemory.Azure.Storage/Internals/ConditionChecker.cs new file mode 100644 index 0000000..f37a233 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Internals/ConditionChecker.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure; + +using Spotflow.InMemory.Azure.Storage.Tables.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Internals; + +internal static class ConditionChecker +{ + public static bool CheckConditions(ETag? currentETag, ETag? ifMatch, ETag? ifNoneMatch, [NotNullWhen(false)] out ConditionError? error) + { + ifMatch?.EnsureNotEmpty(); + ifNoneMatch?.EnsureNotEmpty(); + + if (currentETag is not null && ifNoneMatch == ETag.All) + { + error = new ConditionError(ConditionType.IfNoneMatch, "Target already exists."); + return false; + } + + if (ifMatch is not null && ifMatch.Value != ETag.All && ifMatch != currentETag) + { + error = new ConditionError(ConditionType.IfMatch, "Target has different ETag."); + return false; + } + + if (ifNoneMatch is not null && ifNoneMatch == currentETag) + { + error = new ConditionError(ConditionType.IfNoneMatch, "Target has the same ETag."); + return false; + } + + error = null; + return true; + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Internals/ConditionError.cs b/src/Spotflow.InMemory.Azure.Storage/Internals/ConditionError.cs new file mode 100644 index 0000000..2bf0247 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Internals/ConditionError.cs @@ -0,0 +1,4 @@ +namespace Spotflow.InMemory.Azure.Storage.Internals; + +internal record ConditionError(ConditionType ConditionType, string Message); + diff --git a/src/Spotflow.InMemory.Azure.Storage/Internals/ConditionType.cs b/src/Spotflow.InMemory.Azure.Storage/Internals/ConditionType.cs new file mode 100644 index 0000000..7d7ff6e --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Internals/ConditionType.cs @@ -0,0 +1,7 @@ +namespace Spotflow.InMemory.Azure.Storage.Internals; + +internal enum ConditionType +{ + IfMatch, + IfNoneMatch, +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Internals/StorageConnectionStringUtils.cs b/src/Spotflow.InMemory.Azure.Storage/Internals/StorageConnectionStringUtils.cs new file mode 100644 index 0000000..a1830ad --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Internals/StorageConnectionStringUtils.cs @@ -0,0 +1,55 @@ +using Azure.Data.Tables; +using Azure.Storage.Blobs; + +using Spotflow.InMemory.Azure.Storage.Resources; + +namespace Spotflow.InMemory.Azure.Storage.Internals; + +internal static class StorageConnectionStringUtils +{ + public static string GetAccountNameFromConnectionString(string connectionString) + { + var options = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries; + + var pairs = connectionString.Split(';', options); + + var props = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var pair in pairs) + { + KeyValuePair? kv = pair.Split('=', options) switch + { + [var k, var v] => new(k, v), + _ => null + }; + + if (kv is not null) + { + props.Add(kv.Value.Key, kv.Value.Value); + } + } + + if (props.TryGetValue("AccountName", out var accountName)) + { + return accountName; + } + + if (props.TryGetValue("TableEndpoint", out var tableEndpoint)) + { + return new TableUriBuilder(new Uri(tableEndpoint)).AccountName; + } + + if (props.TryGetValue("BlobEndpoint", out var blobEndpoint)) + { + return new BlobUriBuilder(new Uri(blobEndpoint)).AccountName; + } + + throw new InvalidOperationException("Storage account name could not be resolved."); + + } + + public static string CreateConnectionString(InMemoryStorageAccount account) + { + return $"AccountName={account.Name};AccountKey={account.PrimaryAccessKey};DefaultEndpointsProtocol=https;TableEndpoint={account.TableService.Uri};BlobEndpoint={account.BlobService.Uri}"; + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Resources/InMemoryStorageAccount.cs b/src/Spotflow.InMemory.Azure.Storage/Resources/InMemoryStorageAccount.cs new file mode 100644 index 0000000..6fa32d0 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Resources/InMemoryStorageAccount.cs @@ -0,0 +1,47 @@ +using System.Security.Cryptography; +using System.Text; + +using Spotflow.InMemory.Azure.Storage.Blobs.Internals; +using Spotflow.InMemory.Azure.Storage.Internals; +using Spotflow.InMemory.Azure.Storage.Tables.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Resources; + +public class InMemoryStorageAccount +{ + public InMemoryStorageAccount(string name, InMemoryStorageProvider provider) + { + Name = name; + Provider = provider; + TableService = new(this); + BlobService = new(this); + + var keySeed = $"{Name}|{Provider.HostnameSuffix}"; + + PrimaryAccessKey = Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(keySeed))); + + } + + public string Name { get; } + public InMemoryStorageProvider Provider { get; } + public string PrimaryAccessKey { get; } + + internal InMemoryTableService TableService { get; } + internal InMemoryBlobService BlobService { get; } + + public Uri CreateBlobContainerSasUri(string blobContainerName) => BlobService.CreateContainerSasUri(blobContainerName); + + public Uri CreateBlobSasUri(string blobContainerName, string blobName) => BlobService.CreateBlobSasUri(blobContainerName, blobName); + + public Uri CreateTableSasUri(string tableName) => TableService.CreateTableSasUri(tableName); + + + public string CreateConnectionString() => StorageConnectionStringUtils.CreateConnectionString(this); + + public Uri BlobServiceUri => BlobService.Uri; + public Uri TableServiceUri => TableService.Uri; + + public override string ToString() => Name; + +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Spotflow.InMemory.Azure.Storage.csproj b/src/Spotflow.InMemory.Azure.Storage/Spotflow.InMemory.Azure.Storage.csproj new file mode 100644 index 0000000..1f98647 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Spotflow.InMemory.Azure.Storage.csproj @@ -0,0 +1,28 @@ + + + + In-memory implementation of the Azure Storage Blobs and Tables clients for convenient testing. + $(PackageTags);Storage;Blobs;Tables + true + README.md + + + + + + + + + + + + + <_Parameter1>Tests + + + + + + + + diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityAddAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityAddAfterHookContext.cs new file mode 100644 index 0000000..41d2dac --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityAddAfterHookContext.cs @@ -0,0 +1,10 @@ +using Azure.Data.Tables; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public class EntityAddAfterHookContext(EntityAddBeforeHookContext beforeContext) : EntityAfterHookContext(beforeContext) +{ + public required ITableEntity Entity { get; init; } + public EntityAddBeforeHookContext beforeContext { get; } = beforeContext; +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityAddBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityAddBeforeHookContext.cs new file mode 100644 index 0000000..1ec720a --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityAddBeforeHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Data.Tables; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public class EntityAddBeforeHookContext(EntityScope scope, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : EntityBeforeHookContext(scope, EntityOperations.Add, provider, cancellationToken) +{ + public required ITableEntity Entity { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityAfterHookContext.cs new file mode 100644 index 0000000..d852d57 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityAfterHookContext.cs @@ -0,0 +1,14 @@ +using Spotflow.InMemory.Azure.Storage.Tables.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public abstract class EntityAfterHookContext(EntityBeforeHookContext before) : TableServiceAfterHookContext(before), IEntityOperation +{ + public EntityOperations Operation => before.Operation; + + public string TableName => before.TableName; + + public string PartitionKey => before.PartitionKey; + + public string RowKey => before.RowKey; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityBeforeHookContext.cs new file mode 100644 index 0000000..0effb9f --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityBeforeHookContext.cs @@ -0,0 +1,15 @@ +using Spotflow.InMemory.Azure.Storage.Tables.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public abstract class EntityBeforeHookContext(EntityScope scope, EntityOperations operation, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : TableServiceBeforeHookContext(scope, provider, cancellationToken), IEntityOperation +{ + public EntityOperations Operation => operation; + + public string TableName => scope.TableName; + + public string PartitionKey => scope.PartitionKey; + + public string RowKey => scope.RowKey; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityUpsertAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityUpsertAfterHookContext.cs new file mode 100644 index 0000000..7dd6674 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityUpsertAfterHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Data.Tables; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public class EntityUpsertAfterHookContext(EntityUpsertBeforeHookContext before) : EntityAfterHookContext(before) +{ + public required ITableEntity Entity { get; init; } + public EntityUpsertBeforeHookContext BeforeContext => before; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityUpsertBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityUpsertBeforeHookContext.cs new file mode 100644 index 0000000..2143942 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/EntityUpsertBeforeHookContext.cs @@ -0,0 +1,9 @@ +using Azure.Data.Tables; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public class EntityUpsertBeforeHookContext(EntityScope scope, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : EntityBeforeHookContext(scope, EntityOperations.Upsert, provider, cancellationToken) +{ + public required ITableEntity Entity { get; init; } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableAfterHookContext.cs new file mode 100644 index 0000000..ebd662a --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableAfterHookContext.cs @@ -0,0 +1,8 @@ +using Spotflow.InMemory.Azure.Storage.Tables.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; +public abstract class TableAfterHookContext(TableBeforeHookContext before) : TableServiceAfterHookContext(before), ITableOperation +{ + public string TableName => before.TableName; + public TableOperations Operation => before.Operation; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableBeforeHookContext.cs new file mode 100644 index 0000000..538eec2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableBeforeHookContext.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.Storage.Tables.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public abstract class TableBeforeHookContext(TableScope scope, TableOperations operation, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : TableServiceBeforeHookContext(scope, provider, cancellationToken), ITableOperation +{ + public string TableName => scope.TableName; + public TableOperations Operation => operation; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableCreateAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableCreateAfterHookContext.cs new file mode 100644 index 0000000..e1d0c2f --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableCreateAfterHookContext.cs @@ -0,0 +1,6 @@ +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public class TableCreateAfterHookContext(TableCreateBeforeHookContext before) : TableAfterHookContext(before) +{ + public TableCreateBeforeHookContext BeforeContext => before; +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableCreateBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableCreateBeforeHookContext.cs new file mode 100644 index 0000000..2429772 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableCreateBeforeHookContext.cs @@ -0,0 +1,4 @@ +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public class TableCreateBeforeHookContext(TableScope scope, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : TableBeforeHookContext(scope, TableOperations.Create, provider, cancellationToken); diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableServiceAfterHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableServiceAfterHookContext.cs new file mode 100644 index 0000000..39709ee --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableServiceAfterHookContext.cs @@ -0,0 +1,9 @@ +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public abstract class TableServiceAfterHookContext(TableServiceBeforeHookContext before) : StorageAfterHookContext(before) +{ + public override TableServiceFaultsBuilder Faults() => new(this); + +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableServiceBeforeHookContext.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableServiceBeforeHookContext.cs new file mode 100644 index 0000000..e42d947 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Contexts/TableServiceBeforeHookContext.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.Storage.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +public abstract class TableServiceBeforeHookContext(StorageAccountScope scope, InMemoryStorageProvider provider, CancellationToken cancellationToken) + : StorageBeforeHookContext(scope, provider, cancellationToken) +{ + public override TableServiceFaultsBuilder Faults() => new(this); +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/EntityOperations.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/EntityOperations.cs new file mode 100644 index 0000000..b9911eb --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/EntityOperations.cs @@ -0,0 +1,10 @@ +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks; + +[Flags] +public enum EntityOperations +{ + None = 0, + Add = 1, + Upsert = 2, + All = Add | Upsert +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/EntityScope.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/EntityScope.cs new file mode 100644 index 0000000..b4f23e1 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/EntityScope.cs @@ -0,0 +1,3 @@ +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks; + +public record EntityScope(string StorageAccountName, string TableName, string PartitionKey, string RowKey) : TableScope(StorageAccountName, TableName); diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Internals/IEntityOperation.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Internals/IEntityOperation.cs new file mode 100644 index 0000000..cb74c1c --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Internals/IEntityOperation.cs @@ -0,0 +1,9 @@ +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Internals; + +internal interface IEntityOperation +{ + string TableName { get; } + string PartitionKey { get; } + string RowKey { get; } + EntityOperations Operation { get; } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Internals/ITableOperation.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Internals/ITableOperation.cs new file mode 100644 index 0000000..c0e51e1 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Internals/ITableOperation.cs @@ -0,0 +1,7 @@ +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Internals; + +internal interface ITableOperation +{ + string TableName { get; } + TableOperations Operation { get; } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Internals/TableHookFilter.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Internals/TableHookFilter.cs new file mode 100644 index 0000000..d1c772f --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/Internals/TableHookFilter.cs @@ -0,0 +1,55 @@ +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks.Internals; + +internal record TableHookFilter : StorageHookFilter +{ + public TableHookFilter(StorageHookFilter filter) : base(filter) { } + + public string? TableName { get; private init; } + public string? PartitionKey { get; private init; } + public string? RowKey { get; private init; } + public TableOperations TableOperations { get; private init; } = TableOperations.All; + public EntityOperations EntityOperations { get; private init; } = EntityOperations.All; + + public override bool Covers(StorageHookContext context) + { + var result = base.Covers(context); + + if (context is ITableOperation table) + { + result &= TableName is null || TableName == table.TableName; + result &= TableOperations.HasFlag(table.Operation); + + return result; + } + + if (context is IEntityOperation entity) + { + result &= TableName is null || TableName == entity.TableName; + result &= PartitionKey is null || PartitionKey == entity.PartitionKey; + result &= RowKey is null || RowKey == entity.RowKey; + result &= EntityOperations.HasFlag(entity.Operation); + + return result; + } + + throw new InvalidOperationException($"Unexpected context: {context}"); + } + + internal TableHookFilter With(string? tableName = null, TableOperations? tableOperations = null, string? partitionKey = null, string? rowKey = null, EntityOperations? entityOperations = null) + { + return this with + { + TableName = tableName ?? TableName, + PartitionKey = partitionKey ?? PartitionKey, + RowKey = rowKey ?? RowKey, + TableOperations = tableOperations ?? TableOperations, + EntityOperations = entityOperations ?? EntityOperations + }; + } +} + + + diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableOperations.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableOperations.cs new file mode 100644 index 0000000..25e0ced --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableOperations.cs @@ -0,0 +1,9 @@ +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks; + +[Flags] +public enum TableOperations +{ + None = 0, + Create = 1, + All = Create +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableScope.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableScope.cs new file mode 100644 index 0000000..319a45b --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableScope.cs @@ -0,0 +1,13 @@ +using Azure.Data.Tables; + +using Spotflow.InMemory.Azure.Storage.Hooks; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks; + +public record TableScope(string StorageAccountName, string TableName) : StorageAccountScope(StorageAccountName) +{ + public EntityScope ForEntity(T entity) where T : ITableEntity + { + return new(StorageAccountName, TableName, entity.PartitionKey, entity.RowKey); + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableServiceFaultsBuilder.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableServiceFaultsBuilder.cs new file mode 100644 index 0000000..42bb00d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableServiceFaultsBuilder.cs @@ -0,0 +1,10 @@ +using Spotflow.InMemory.Azure.Storage.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Tables.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks; + +public class TableServiceFaultsBuilder(StorageHookContext context) : StorageFaultsBuilder +{ + public override Task ServiceIsBusy() => throw TableExceptionFactory.ServiceIsBusy(context.StorageAccountName); +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableServiceHookBuilder.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableServiceHookBuilder.cs new file mode 100644 index 0000000..16efc10 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Hooks/TableServiceHookBuilder.cs @@ -0,0 +1,89 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks; +using Spotflow.InMemory.Azure.Storage.Hooks.Internals; +using Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Tables.Hooks.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Hooks; + +public class TableServiceHookBuilder +{ + private readonly TableHookFilter _filter; + + internal TableServiceHookBuilder(StorageHookFilter filter) + { + _filter = new(filter); + } + + public TableOperationsBuilder ForTableOperations(string? tableName = null) => new(_filter.With(tableName: tableName)); + public EntityOperationsBuilder ForEntityOperations(string? tableName = null, string? partitionKey = null, string? rowKey = null) + { + return new(_filter.With(tableName: tableName, partitionKey: partitionKey, rowKey: rowKey)); + } + + public StorageHook Before( + HookFunc hookFunction, + string? tableName = null, + TableOperations? tableOperations = null, + string? partitionKey = null, + string? rowKey = null, + EntityOperations? entityOperations = null) + { + return new(hookFunction, _filter.With(tableName, tableOperations, partitionKey, rowKey, entityOperations)); + } + + public StorageHook After( + HookFunc hookFunction, + string? tableName = null, + TableOperations? tableOperations = null, + string? partitionKey = null, + string? rowKey = null, + EntityOperations? entityOperations = null) + { + return new(hookFunction, _filter.With(tableName, tableOperations, partitionKey, rowKey, entityOperations)); + } + + public class TableOperationsBuilder + { + private readonly TableHookFilter _filter; + + internal TableOperationsBuilder(TableHookFilter filter) + { + _filter = filter.With(entityOperations: EntityOperations.None); + } + + public StorageHook Before(HookFunc hook, TableOperations? operations = null) => new(hook, _filter.With(tableOperations: operations)); + public StorageHook After(HookFunc hook, TableOperations? operations = null) => new(hook, _filter.With(tableOperations: operations)); + + public StorageHook BeforeCreate(HookFunc hook) => new(hook, _filter); + + public StorageHook AfterCreate(HookFunc hook) => new(hook, _filter); + } + + + public class EntityOperationsBuilder + { + private readonly TableHookFilter _filter; + + internal EntityOperationsBuilder(TableHookFilter filter) + { + _filter = filter.With(tableOperations: TableOperations.None); + } + + public StorageHook Before(HookFunc hook, EntityOperations? operations = null) => new(hook, _filter.With(entityOperations: operations)); + + public StorageHook After(HookFunc hook, EntityOperations? operations = null) => new(hook, _filter.With(entityOperations: operations)); + + public StorageHook BeforeAdd(HookFunc hook) => new(hook, _filter); + + public StorageHook AfterAdd(HookFunc hook) => new(hook, _filter); + + public StorageHook BeforeUpsert(HookFunc hook) => new(hook, _filter); + + public StorageHook AfterUpsert(HookFunc hook) => new(hook, _filter); + } + + + + +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/InMemoryTableClient.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/InMemoryTableClient.cs new file mode 100644 index 0000000..a75f792 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/InMemoryTableClient.cs @@ -0,0 +1,567 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; + +using Azure; +using Azure.Data.Tables; +using Azure.Data.Tables.Models; +using Azure.Data.Tables.Sas; + +using Spotflow.InMemory.Azure.Internals; +using Spotflow.InMemory.Azure.Storage.Resources; +using Spotflow.InMemory.Azure.Storage.Tables.Hooks; +using Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; +using Spotflow.InMemory.Azure.Storage.Tables.Internals; + +using static Spotflow.InMemory.Azure.Storage.Tables.Internals.InMemoryTable; + +namespace Spotflow.InMemory.Azure.Storage.Tables; + +public class InMemoryTableClient : TableClient +{ + private const int _defaultMaxPerPage = 1000; + + private readonly TableScope _scope; + + #region Constructors + + public InMemoryTableClient(string connectionString, string tableName, InMemoryStorageProvider provider) : this(connectionString, null, tableName, provider) { } + + public InMemoryTableClient(Uri tableServiceUri, string tableName, InMemoryStorageProvider provider) : this(null, tableServiceUri, tableName, provider) { } + + public InMemoryTableClient(Uri tableUri, InMemoryStorageProvider provider) : this(null, tableUri, null, provider) { } + + private InMemoryTableClient(string? connectionString, Uri? tableUri, string? tableName, InMemoryStorageProvider provider) + { + var builder = TableUriUtils.BuilderForTable(connectionString, tableUri, tableName, provider); + Uri = builder.ToUri(); + AccountName = builder.AccountName; + Name = builder.Tablename; + Provider = provider; + + _scope = new(builder.AccountName, builder.Tablename); + } + + public static InMemoryTableClient FromAccount(InMemoryStorageAccount account, string tableName) + { + return new(account.TableServiceUri, tableName, account.Provider); + } + + #endregion + + public override Uri Uri { get; } + public override string Name { get; } + public override string AccountName { get; } + + public InMemoryStorageProvider Provider { get; } + + #region Create + + public override Response CreateIfNotExists(CancellationToken cancellationToken = default) + { + (var item, var added) = CreateIfNotExistsCore(); + + return added switch + { + true => InMemoryResponse.FromValue(item, 201), + false => InMemoryResponse.FromValue(item, 409) + }; + + } + + public override async Task> CreateIfNotExistsAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + return CreateIfNotExists(cancellationToken); + } + + private async Task CreateCoreAsync(CancellationToken cancellationToken) + { + var beforeContext = new TableCreateBeforeHookContext(_scope, Provider, cancellationToken); + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + var service = GetService(); + + if (!service.TryAddTable(Name, out var table)) + { + throw TableExceptionFactory.TableAlreadyExists(AccountName, Name); + } + + var afterContext = new TableCreateAfterHookContext(beforeContext); + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return table.AsItem(); + } + + public override Response Create(CancellationToken cancellationToken = default) + { + var item = CreateCoreAsync(cancellationToken).EnsureCompleted(); + return InMemoryResponse.FromValue(item, 201); + } + + public override async Task> CreateAsync(CancellationToken cancellationToken = default) + { + var item = await CreateCoreAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + return InMemoryResponse.FromValue(item, 201); + } + + private (TableItem, bool) CreateIfNotExistsCore() + { + var service = GetService(); + + var added = service.TryAddTable(Name, out var table); + + return (table.AsItem(), added); + } + + + #endregion + + #region Query + + public override Pageable Query(Expression> filter, int? maxPerPage = null, IEnumerable? select = null, CancellationToken cancellationToken = default) + { + var filterCompiled = filter.Compile(); + + var entities = QueryCore(filterCompiled); + + return new InMemoryPageable.Sync(entities, maxPerPage ?? _defaultMaxPerPage); + } + + public override AsyncPageable QueryAsync(Expression> filter, int? maxPerPage = null, IEnumerable? select = null, CancellationToken cancellationToken = default) + { + var filterCompiled = filter.Compile(); + + var entities = QueryCore(filterCompiled); + + return new InMemoryPageable.YieldingAsync(entities, maxPerPage ?? _defaultMaxPerPage); + } + + public override Pageable Query(string? filter = null, int? maxPerPage = null, IEnumerable? select = null, CancellationToken cancellationToken = default) + { + var matcher = new TextQueryFilterMatcher(filter, Provider.LoggerFactory); + + var entities = QueryCore(matcher.IsMatch); + + return new InMemoryPageable.Sync(entities, maxPerPage ?? _defaultMaxPerPage); + } + + + public override AsyncPageable QueryAsync(string? filter = null, int? maxPerPage = null, IEnumerable? select = null, CancellationToken cancellationToken = default) + { + var matcher = new TextQueryFilterMatcher(filter, Provider.LoggerFactory); + + var entities = QueryCore(matcher.IsMatch); + + return new InMemoryPageable.YieldingAsync(entities, maxPerPage ?? _defaultMaxPerPage); + } + + private IReadOnlyList QueryCore(Func typedFilter) where T : class, ITableEntity + { + return QueryCore(entities => entities.Select(e => e.ToAzureTableEntity()).Where(typedFilter)); + } + + private IReadOnlyList QueryCore(Func genericFilter) where T : class, ITableEntity + { + return QueryCore(e => e.Where(genericFilter).Select(e => e.ToAzureTableEntity())); + } + + private IReadOnlyList QueryCore(Func, IEnumerable> filter) where T : class, ITableEntity + { + var table = GetTable(); + + return table.GetEntities(filter); + } + + #endregion + + #region Upsert & Update Entity + + public override Response UpsertEntity(T entity, TableUpdateMode mode = TableUpdateMode.Merge, CancellationToken cancellationToken = default) + { + return UpsertCoreAsync(entity, ETag.All, mode, cancellationToken).EnsureCompleted(); + } + + public override async Task UpsertEntityAsync(T entity, TableUpdateMode mode = TableUpdateMode.Merge, CancellationToken cancellationToken = default) + { + return await UpsertCoreAsync(entity, ETag.All, mode, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + public override Response UpdateEntity(T entity, ETag ifMatch, TableUpdateMode mode = TableUpdateMode.Merge, CancellationToken cancellationToken = default) + { + return UpdateCore(entity, ifMatch, mode); + } + + public override async Task UpdateEntityAsync(T entity, ETag ifMatch, TableUpdateMode mode = TableUpdateMode.Merge, CancellationToken cancellationToken = default) + { + await Task.Yield(); + return UpdateCore(entity, ifMatch, mode); + } + + private InMemoryResponse UpdateCore(T entity, ETag ifMatch, TableUpdateMode mode) where T : ITableEntity + { + if (ifMatch.IsEmpty()) + { + ifMatch = ETag.All; + } + + var table = GetTable(); + + if (!table.TryUpsertEntity(entity, ifMatch, mode, mustExist: true, out var eTag, out var error)) + { + throw error.GetClientException(); + } + + return new(204, eTag: eTag.Value); + } + + private async Task UpsertCoreAsync(T entity, ETag ifMatch, TableUpdateMode mode, CancellationToken cancellationToken) where T : ITableEntity + { + var beforeContext = new EntityUpsertBeforeHookContext(_scope.ForEntity(entity), Provider, cancellationToken) + { + Entity = entity + }; + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + if (ifMatch.IsEmpty()) + { + ifMatch = ETag.All; + } + + var table = GetTable(); + + if (!table.TryUpsertEntity(entity, ifMatch, mode, mustExist: false, out var eTag, out var error)) + { + throw error.GetClientException(); + } + + var afterContext = new EntityUpsertAfterHookContext(beforeContext) + { + Entity = entity, + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return new(204, eTag: eTag.Value); + } + + #endregion + + #region Delete Entity + + public override Response DeleteEntity(string partitionKey, string rowKey, ETag ifMatch = default, CancellationToken cancellationToken = default) + { + var table = GetTable(); + + if (!table.TryDeleteEntity(partitionKey, rowKey, ifMatch, out var error)) + { + if (error is EntityDeleteError.NotFound) + { + return new InMemoryResponse(404); + } + + throw error.GetClientException(); + } + + return new InMemoryResponse(204); + + } + + public override async Task DeleteEntityAsync(string partitionKey, string rowKey, ETag ifMatch = default, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return DeleteEntity(partitionKey, rowKey, ifMatch, cancellationToken); + } + + #endregion + + #region Add Entity + + public override Response AddEntity(T entity, CancellationToken cancellationToken = default) + { + return AddEntityCoreAsync(entity, cancellationToken).EnsureCompleted(); + } + + public override async Task AddEntityAsync(T entity, CancellationToken cancellationToken = default) + { + return await AddEntityCoreAsync(entity, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + } + + private async Task AddEntityCoreAsync(T entity, CancellationToken cancellationToken) where T : ITableEntity + { + var beforeContext = new EntityAddBeforeHookContext(_scope.ForEntity(entity), Provider, cancellationToken) + { + Entity = entity + }; + + await ExecuteBeforeHooksAsync(beforeContext).ConfigureAwait(ConfigureAwaitOptions.None); + + var table = GetTable(); + + if (!table.TryAddEntity(entity, out var eTag, out var error)) + { + throw error.GetClientException(); + } + + var response = new InMemoryResponse(204, eTag: eTag.Value); + + var afterContext = new EntityAddAfterHookContext(beforeContext) + { + Entity = entity + }; + + await ExecuteAfterHooksAsync(afterContext).ConfigureAwait(ConfigureAwaitOptions.None); + + return response; + } + + #endregion + + #region Table Exists + + public bool Exists() => TryGetTable(out _, out _); + + #endregion + + #region Get Entity + + public override Response GetEntity(string partitionKey, string rowKey, IEnumerable? select = null, CancellationToken cancellationToken = default) + { + if (!TryGetEntityCore(partitionKey, rowKey, out var entity)) + { + throw TableExceptionFactory.EntityNotFound(AccountName, Name, partitionKey, rowKey); + } + + return InMemoryResponse.FromValue(entity, 200, eTag: entity.ETag); + } + + public override async Task> GetEntityAsync(string partitionKey, string rowKey, IEnumerable? select = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return GetEntity(partitionKey, rowKey, select, cancellationToken); + } + + public override NullableResponse GetEntityIfExists(string partitionKey, string rowKey, IEnumerable? select = null, CancellationToken cancellationToken = default) + { + if (TryGetEntityCore(partitionKey, rowKey, out var entity)) + { + return InMemoryNullableResponse.FromValue(entity); + } + else + { + return InMemoryNullableResponse.FromNull(); + } + } + + public override async Task> GetEntityIfExistsAsync(string partitionKey, string rowKey, IEnumerable? select = null, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return GetEntityIfExists(partitionKey, rowKey, select, cancellationToken); + } + + private bool TryGetEntityCore(string partitionKey, string rowKey, [NotNullWhen(true)] out T? result) where T : class, ITableEntity + { + var table = GetTable(); + + var entities = QueryCore(typedFilter: entity => filter(entity, partitionKey, rowKey)); + + if (entities.Count == 0) + { + result = default; + return false; + } + + if (entities.Count == 1) + { + result = entities[0]; + return true; + } + + throw new InvalidOperationException("Multiple entities returned to point query."); + + + static bool filter(T entity, string partitionKey, string rowKey) + { + if (!entity.PartitionKey.Equals(partitionKey, StringComparison.Ordinal)) + { + return false; + } + + if (!entity.RowKey.Equals(rowKey, StringComparison.Ordinal)) + { + return false; + } + + return true; + } + } + + #endregion + + #region Transaction + + public override Response> SubmitTransaction(IEnumerable transactionActions, CancellationToken cancellationToken = default) + { + var table = GetTable(); + + var transactions = transactionActions.ToList(); + + if (!table.TrySubmitTransaction(transactions, out var results, out var error)) + { + throw error.GetClientException(); + } + + if (results.Count != transactions.Count) + { + throw new InvalidOperationException("Transaction results count does not match transaction actions count."); + } + + var responses = new List(); + + for (var i = 0; i < results.Count; i++) + { + var t = transactions[i]; + var r = results[i]; + + var status = t.ActionType switch + { + TableTransactionActionType.Add => 204, + TableTransactionActionType.UpdateMerge => 204, + TableTransactionActionType.UpdateReplace => 204, + TableTransactionActionType.Delete => 204, + TableTransactionActionType.UpsertMerge => 204, + TableTransactionActionType.UpsertReplace => 204, + }; + + responses.Add(new(status, r.ETag)); + } + + return InMemoryResponse.FromValue>(responses, 202); + + } + + public override async Task>> SubmitTransactionAsync(IEnumerable transactionActions, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return SubmitTransaction(transactionActions, cancellationToken); + } + + #endregion + + #region SAS + + public override TableSasBuilder GetSasBuilder(TableSasPermissions permissions, DateTimeOffset expiresOn) => new(Name, permissions, expiresOn); + + public override TableSasBuilder GetSasBuilder(string rawPermissions, DateTimeOffset expiresOn) => new(Name, rawPermissions, expiresOn); + + public override Uri GenerateSasUri(TableSasPermissions permissions, DateTimeOffset expiresOn) => Uri; + + public override Uri GenerateSasUri(TableSasBuilder builder) + { + if (builder.TableName != Name) + { + throw new InvalidOperationException($"Table name in the builder ({builder.TableName}) does not match actual table name ({Name})."); + } + + return Uri; + } + + #endregion + + #region Delete Table + + public override Response Delete(CancellationToken cancellationToken = default) + { + var service = GetService(); + + if (!service.TryDeleteTable(Name)) + { + throw TableExceptionFactory.TableNotFound(Name, service); + } + + return new InMemoryResponse(204); + } + + public override async Task DeleteAsync(CancellationToken cancellationToken = default) + { + await Task.Yield(); + + return Delete(cancellationToken); + } + + #endregion + + private InMemoryTableService GetService() + { + if (!Provider.TryGetAccount(AccountName, out var account)) + { + throw TableExceptionFactory.TableServiceNotFound(AccountName, Provider); + } + + return account.TableService; + } + + private InMemoryTable GetTable() + { + if (!TryGetTable(out var service, out var table)) + { + throw TableExceptionFactory.TableNotFound(Name, service); + } + + return table; + } + + private bool TryGetTable(out InMemoryTableService service, [NotNullWhen(true)] out InMemoryTable? table) + { + service = GetService(); + + if (!service.TryGetTable(Name, out table)) + { + return false; + } + + return true; + } + + private Task ExecuteBeforeHooksAsync(TContext context) where TContext : TableServiceBeforeHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + private Task ExecuteAfterHooksAsync(TContext context) where TContext : TableServiceAfterHookContext + { + return Provider.ExecuteHooksAsync(context); + } + + + #region Unsupported + + public override Task>> GetAccessPoliciesAsync(CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Response> GetAccessPolicies(CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Task SetAccessPolicyAsync(IEnumerable tableAcl, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Response SetAccessPolicy(IEnumerable tableAcl, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + #endregion + +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/InMemoryTableServiceClient.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/InMemoryTableServiceClient.cs new file mode 100644 index 0000000..f3e7d68 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/InMemoryTableServiceClient.cs @@ -0,0 +1,164 @@ +using System.Linq.Expressions; + +using Azure; +using Azure.Data.Tables; +using Azure.Data.Tables.Models; +using Azure.Data.Tables.Sas; + +using Spotflow.InMemory.Azure.Storage.Resources; +using Spotflow.InMemory.Azure.Storage.Tables.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables; + +public class InMemoryTableServiceClient : TableServiceClient +{ + #region Constructors + + public InMemoryTableServiceClient(string connectionString, InMemoryStorageProvider provider) : this(connectionString, null, provider) { } + + public InMemoryTableServiceClient(Uri tableServiceUri, InMemoryStorageProvider provider) : this(null, tableServiceUri, provider) { } + + private InMemoryTableServiceClient(string? connectionString, Uri? uri, InMemoryStorageProvider provider) + { + var builder = TableUriUtils.BuilderForService(connectionString, uri, provider); + + Uri = builder.ToUri(); + Provider = provider; + AccountName = builder.AccountName; + } + + public static InMemoryTableServiceClient FromAccount(InMemoryStorageAccount account) + { + return new(account.TableServiceUri, account.Provider); + } + + #endregion + + public override Uri Uri { get; } + public override string AccountName { get; } + public InMemoryStorageProvider Provider { get; } + + public override TableClient GetTableClient(string tableName) + { + var uriBuilder = new TableUriBuilder(Uri) + { + Tablename = tableName + }; + + return new InMemoryTableClient(uriBuilder.ToUri(), Provider); + } + + #region Unsupported + + public override TableAccountSasBuilder GetSasBuilder(TableAccountSasPermissions permissions, TableAccountSasResourceTypes resourceTypes, DateTimeOffset expiresOn) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override TableAccountSasBuilder GetSasBuilder(string rawPermissions, TableAccountSasResourceTypes resourceTypes, DateTimeOffset expiresOn) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override AsyncPageable QueryAsync(string? filter = null, int? maxPerPage = null, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Pageable Query(string? filter = null, int? maxPerPage = null, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override AsyncPageable QueryAsync(FormattableString filter, int? maxPerPage = null, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Pageable Query(FormattableString filter, int? maxPerPage = null, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override AsyncPageable QueryAsync(Expression> filter, int? maxPerPage = null, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Pageable Query(Expression> filter, int? maxPerPage = null, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Response CreateTable(string tableName, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Task> CreateTableAsync(string tableName, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Response CreateTableIfNotExists(string tableName, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Task> CreateTableIfNotExistsAsync(string tableName, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Response DeleteTable(string tableName, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Task DeleteTableAsync(string tableName, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Response SetProperties(TableServiceProperties properties, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Task SetPropertiesAsync(TableServiceProperties properties, CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Response GetProperties(CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Task> GetPropertiesAsync(CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Task> GetStatisticsAsync(CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Response GetStatistics(CancellationToken cancellationToken = default) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Uri GenerateSasUri(TableAccountSasPermissions permissions, TableAccountSasResourceTypes resourceTypes, DateTimeOffset expiresOn) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + public override Uri GenerateSasUri(TableAccountSasBuilder builder) + { + throw TableExceptionFactory.MethodNotSupported(); + } + + #endregion +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/ETagExtensions.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/ETagExtensions.cs new file mode 100644 index 0000000..9eba91d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/ETagExtensions.cs @@ -0,0 +1,16 @@ +using Azure; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Internals; + +internal static class ETagExtensions +{ + public static bool IsEmpty(this ETag eTag) => eTag == new ETag(string.Empty) || eTag == default; + + public static void EnsureNotEmpty(this ETag eTag) + { + if (eTag.IsEmpty()) + { + throw new InvalidOperationException("ETag cannot be empty."); + } + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/InMemoryTable.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/InMemoryTable.cs new file mode 100644 index 0000000..78a6563 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/InMemoryTable.cs @@ -0,0 +1,438 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure; +using Azure.Data.Tables; +using Azure.Data.Tables.Models; + +using Spotflow.InMemory.Azure.Storage.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Internals; + + +internal class InMemoryTable(string name, InMemoryTableService service) +{ + private readonly Dictionary<(string PK, string RK), InMemoryTableEntity> _entities = []; + private readonly string _tableName = name; + + public InMemoryTableService Service { get; } = service; + + private readonly string _accountName = service.Account.Name; + private readonly TimeProvider _timeProvider = service.Account.Provider.TimeProvider; + + public IReadOnlyList GetEntities(Func, IEnumerable> filter) where T : ITableEntity + { + lock (_entities) + { + return filter(_entities.Values).ToList(); + } + } + + public bool TryUpsertEntity( + T entity, + ETag incomingETag, + TableUpdateMode updateMode, + bool mustExist, + [NotNullWhen(true)] out ETag? outgoingETag, + [NotNullWhen(false)] out EntityUpsertError? error) where T : ITableEntity + { + lock (_entities) + { + if (!CanUpsertEntityUnsafe(entity.PartitionKey, entity.RowKey, incomingETag, mustExist, out error)) + { + outgoingETag = null; + return false; + } + + UpsertEntityUnsafe(entity, updateMode, out var newEntityETag); + + outgoingETag = newEntityETag; + error = null; + return true; + } + } + + private void UpsertEntityUnsafe(T entity, TableUpdateMode updateMode, out ETag eTag) + where T : ITableEntity + { + InMemoryTableEntity newEntity; + + var key = (entity.PartitionKey, entity.RowKey); + + if (!_entities.TryGetValue(key, out var existingEntity)) + { + newEntity = InMemoryTableEntity.CreateNew(entity, _timeProvider); + } + else + { + newEntity = existingEntity.Update(entity, updateMode, _timeProvider); + } + + _entities[key] = newEntity; + eTag = newEntity.ETag; + } + + private bool CanUpsertEntityUnsafe(string partitionKey, string rowKey, ETag incomingETag, bool mustExist, [NotNullWhen(false)] out EntityUpsertError? error) + { + incomingETag.EnsureNotEmpty(); + + var key = (partitionKey, rowKey); + + var entityExists = _entities.TryGetValue(key, out var existingEntity); + + if (mustExist) + { + if (!entityExists) + { + error = new EntityUpsertError.EntityNotFound(_accountName, _tableName, partitionKey, rowKey); + return false; + } + } + + if (!incomingETag.IsEmpty()) + { + if (!ConditionChecker.CheckConditions(existingEntity?.ETag, ifMatch: incomingETag, null, out var conditionError)) + { + error = new EntityUpsertError.ConditionNotMet(_accountName, _tableName, partitionKey, rowKey, conditionError); + return false; + } + } + + error = null; + return true; + } + + public bool TryAddEntity(T entity, [NotNullWhen(true)] out ETag? eTag, [NotNullWhen(false)] out EntityAddError? error) where T : ITableEntity + { + lock (_entities) + { + if (!CanAddEntityUnsafe(entity.PartitionKey, entity.RowKey, out error)) + { + eTag = null; + return false; + } + + AddEntityUnsafe(entity, out var eTagUnsafe); + eTag = eTagUnsafe; + return true; + } + } + + private void AddEntityUnsafe(T entity, out ETag eTag) where T : ITableEntity + { + var key = (entity.PartitionKey, entity.RowKey); + + var newEntity = InMemoryTableEntity.CreateNew(entity, _timeProvider); + _entities[key] = newEntity; + eTag = newEntity.ETag; + } + + private bool CanAddEntityUnsafe(string partitionKey, string rowKey, [NotNullWhen(false)] out EntityAddError? error) + { + var key = (partitionKey, rowKey); + if (_entities.ContainsKey(key)) + { + error = new EntityAddError.EntityAlreadyExists(_accountName, _tableName, partitionKey, rowKey); + return false; + } + else + { + error = null; + return true; + } + } + + + + public bool TryDeleteEntity(string partitionKey, string rowKey, ETag ifMatch, [NotNullWhen(false)] out EntityDeleteError? error) + { + if (ifMatch.IsEmpty()) + { + ifMatch = ETag.All; + } + + lock (_entities) + { + if (!CanDeleteEntityUnsafe(partitionKey, rowKey, ifMatch, out error)) + { + if (error is EntityDeleteError.NotFound && (ifMatch.IsEmpty() || ifMatch == ETag.All)) + { + return true; + } + + return false; + } + + DeleteEntityUnsafe(partitionKey, rowKey); + error = null; + return true; + } + } + + private void DeleteEntityUnsafe(string partitionKey, string rowKey) + { + var key = (partitionKey, rowKey); + _entities.Remove(key); + } + + private bool CanDeleteEntityUnsafe(string partitionKey, string rowKey, ETag ifMatch, [NotNullWhen(false)] out EntityDeleteError? error) + { + var key = (partitionKey, rowKey); + + if (!_entities.TryGetValue(key, out var existingEntity)) + { + error = new EntityDeleteError.NotFound(_accountName, _tableName, partitionKey, rowKey); + return false; + } + + if (!ConditionChecker.CheckConditions(existingEntity?.ETag, ifMatch: ifMatch, null, out var conditionError)) + { + error = new EntityDeleteError.ConditionNotMet(_accountName, _tableName, partitionKey, rowKey, conditionError); + return false; + } + + error = null; + return true; + } + + public override string ToString() => $"{Service} / {_tableName}"; + + public TableItem AsItem() => TableModelFactory.TableItem(_tableName); + + public bool TrySubmitTransaction(IReadOnlyList actions, [NotNullWhen(true)] out IReadOnlyList? results, [NotNullWhen(false)] out EntityTransactionError? error) + { + const int maxEntities = 100; + + if (actions.Count > maxEntities) + { + results = null; + error = new EntityTransactionError.TooManyEntities(_accountName, _tableName, maxEntities, actions.Count); + return false; + } + + lock (_entities) + { + if (!ValidateTransactionUnsafe(actions, out error)) + { + results = null; + return false; + } + + var entityResults = ExecuteTransactionUnsafe(actions); + + results = entityResults; + error = null; + return true; + } + } + + private bool ValidateTransactionUnsafe(IReadOnlyList actions, [NotNullWhen(false)] out EntityTransactionError? error) + { + string? partitionKey = null; + + var rowKeys = new HashSet(); + + foreach (var action in actions) + { + var e = action.Entity; + + var eTag = ResolveETag(action); + + if (!rowKeys.Add(e.RowKey)) + { + error = new EntityTransactionError.EntityDuplicated(_accountName, _tableName, e.PartitionKey, e.RowKey); + return false; + } + + if (partitionKey is null) + { + partitionKey = e.PartitionKey; + } + else if (partitionKey != e.PartitionKey) + { + error = new EntityTransactionError.MultiplePartitionKeys(_accountName, _tableName); + return false; + } + + if (action.ActionType is TableTransactionActionType.Add) + { + if (!CanAddEntityUnsafe(e.PartitionKey, e.RowKey, out var entityError)) + { + error = new EntityTransactionError.FromEntityError(entityError); + return false; + } + } + else if (action.ActionType is TableTransactionActionType.UpdateMerge or TableTransactionActionType.UpdateReplace or TableTransactionActionType.UpsertMerge or TableTransactionActionType.UpsertReplace) + { + var mustExist = action.ActionType is TableTransactionActionType.UpdateMerge or TableTransactionActionType.UpdateReplace; + + if (!CanUpsertEntityUnsafe(e.PartitionKey, e.RowKey, eTag, mustExist: mustExist, out var entityError)) + { + error = new EntityTransactionError.FromEntityError(entityError); + + return false; + } + } + else if (action.ActionType is TableTransactionActionType.Delete) + { + if (!CanDeleteEntityUnsafe(e.PartitionKey, e.RowKey, eTag, out var entityError)) + { + error = new EntityTransactionError.FromEntityError(entityError); + return false; + } + } + else + { + throw new InvalidOperationException($"Unexpected action type: {action.ActionType}"); + } + } + + error = null; + return true; + } + + private static ETag ResolveETag(TableTransactionAction action) + { + if (!action.ETag.IsEmpty()) + { + return action.ETag; + } + + if (!action.Entity.ETag.IsEmpty()) + { + return action.Entity.ETag; + } + + return ETag.All; + } + + private List ExecuteTransactionUnsafe(IReadOnlyList actions) + { + var results = new List(); + + foreach (var action in actions) + { + var e = action.Entity; + + if (action.ActionType is TableTransactionActionType.Add) + { + AddEntityUnsafe(e, out var eTag); + results.Add(new(eTag)); + } + else if (action.ActionType is TableTransactionActionType.UpdateMerge or TableTransactionActionType.UpsertMerge) + { + UpsertEntityUnsafe(e, TableUpdateMode.Merge, out var eTag); + results.Add(new(eTag)); + } + else if (action.ActionType is TableTransactionActionType.UpdateReplace or TableTransactionActionType.UpsertReplace) + { + UpsertEntityUnsafe(e, TableUpdateMode.Replace, out var eTag); + results.Add(new(eTag)); + } + else if (action.ActionType is TableTransactionActionType.Delete) + { + DeleteEntityUnsafe(e.PartitionKey, e.RowKey); + results.Add(new(null)); + } + else + { + throw new InvalidOperationException($"Unexpected action type: {action.ActionType}"); + } + + } + + return results; + } + + public abstract class EntityError + { + public abstract RequestFailedException GetClientException(); + } + + public abstract class EntityAddError() : EntityError + { + public class EntityAlreadyExists(string accountName, string tableName, string partitionKey, string rowKey) : EntityAddError + { + public override RequestFailedException GetClientException() + { + return TableExceptionFactory.EntityAlreadyExists(accountName, tableName, partitionKey, rowKey); + } + } + } + + public abstract class EntityUpsertError() : EntityError() + { + public class ConditionNotMet(string accountName, string tableName, string partitionKey, string rowKey, ConditionError error) : EntityUpsertError + { + public override RequestFailedException GetClientException() + { + return TableExceptionFactory.ConditionNotMet(accountName, tableName, partitionKey, rowKey, error); + } + } + + public class EntityNotFound(string accountName, string tableName, string partitionKey, string rowKey) : EntityUpsertError + { + public override RequestFailedException GetClientException() + { + return TableExceptionFactory.EntityNotFound(accountName, tableName, partitionKey, rowKey); + } + } + + + } + + public abstract class EntityDeleteError() : EntityError() + { + + public class NotFound(string accountName, string tableName, string partitionKey, string rowKey) : EntityDeleteError + { + public override RequestFailedException GetClientException() + { + return TableExceptionFactory.EntityNotFound(accountName, tableName, partitionKey, rowKey); + } + } + + public class ConditionNotMet(string accountName, string tableName, string partitionKey, string rowKey, ConditionError error) : EntityDeleteError + { + public override RequestFailedException GetClientException() + { + return TableExceptionFactory.ConditionNotMet(accountName, tableName, partitionKey, rowKey, error); + } + } + + } + + public abstract class EntityTransactionError() : EntityError() + { + public class MultiplePartitionKeys(string accountName, string tableName) : EntityTransactionError + { + public override RequestFailedException GetClientException() + { + return TableExceptionFactory.MultiplePartitionsInTransaction(accountName, tableName); + } + } + + public class EntityDuplicated(string accountName, string tableName, string partitionKey, string rowKey) : EntityTransactionError + { + public override RequestFailedException GetClientException() + { + return TableExceptionFactory.DuplicateEntityInTransaction(accountName, tableName, partitionKey, rowKey); + } + } + + public class TooManyEntities(string accountName, string tableName, int maxCount, int actualCount) : EntityTransactionError + { + public override RequestFailedException GetClientException() + { + return TableExceptionFactory.TooManyEntitiesInTransaction(accountName, tableName, maxCount, actualCount); + } + } + + public class FromEntityError(EntityError error) : EntityTransactionError + { + public override RequestFailedException GetClientException() => error.GetClientException(); + } + } + + public record EntityTransactionResult(ETag? ETag); + +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/InMemoryTableEntity.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/InMemoryTableEntity.cs new file mode 100644 index 0000000..841b4e2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/InMemoryTableEntity.cs @@ -0,0 +1,173 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +using Azure; +using Azure.Data.Tables; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Internals; + +internal class InMemoryTableEntity +{ + private readonly TableEntity _inner; + + public class EdmType + { + [Key] + public required string PartitionKey { get; init; } + + [Key] + public required string RowKey { get; init; } + + public Dictionary? CustomFields { get; init; } + } + + private InMemoryTableEntity(TableEntity inner) + { + _inner = inner; + } + + public string PartitionKey => _inner.PartitionKey; + public string RowKey => _inner.RowKey; + public DateTimeOffset? Timestamp => _inner.Timestamp; + public ETag ETag => _inner.ETag; + + public object? GetPropertyValueOrNull(string propertyName) + { + if (_inner.TryGetValue(propertyName, out var value)) + { + return value; + } + + return null; + } + + public (string PartitionKey, string RowKey) Key => (PartitionKey, RowKey); + + public InMemoryTableEntity Update(T other, TableUpdateMode mode, TimeProvider timeProvider) where T : ITableEntity + { + if (other.PartitionKey != PartitionKey || other.RowKey != RowKey) + { + throw new InvalidOperationException("Cannot merge entities with different keys."); + } + + TableEntity newInner; + + if (mode is TableUpdateMode.Replace) + { + newInner = new(); + } + else if (mode is TableUpdateMode.Merge) + { + newInner = new(); + + foreach (var (key, value) in _inner) + { + newInner.Add(key, value); + } + } + else + { + throw new InvalidOperationException($"Unexpected update mode: {mode}."); + } + + foreach (var (key, value) in FromAzureTableEntity(other)._inner) + { + newInner.Add(key, value); + } + + newInner.ETag = GenerateETag(); + newInner.Timestamp = timeProvider.GetUtcNow(); + + return new(newInner); + } + + public InMemoryTableEntity WithNewETagAndTimestamp(DateTimeOffset timestamp) + { + var newInner = new TableEntity(); + + foreach (var (key, value) in _inner) + { + newInner.Add(key, value); + } + + newInner.ETag = new ETag(Guid.NewGuid().ToString()); + newInner.Timestamp = timestamp; + + return new(newInner); + } + + + + + public T ToAzureTableEntity() where T : ITableEntity + { + if (typeof(T) == typeof(TableEntity)) + { + return (T) (object) _inner; + } + + var entity = Activator.CreateInstance(); + + foreach (var (key, value) in _inner) + { + var property = entity.GetType().GetProperty(key, BindingFlags.Instance | BindingFlags.Public); + + if (property?.SetMethod is not null) + { + property.SetValue(entity, value); + } + } + + entity.PartitionKey = PartitionKey; + entity.RowKey = RowKey; + entity.ETag = ETag; + entity.Timestamp = Timestamp; + + return entity; + } + + public static InMemoryTableEntity CreateNew(T azureTableEntity, TimeProvider timeProvider) where T : ITableEntity + { + var entity = FromAzureTableEntity(azureTableEntity); + + entity._inner.ETag = GenerateETag(); + entity._inner.Timestamp = timeProvider.GetUtcNow(); + + return entity; + } + + private static ETag GenerateETag() => new($@"W/""{Guid.NewGuid()}"""); + + private static InMemoryTableEntity FromAzureTableEntity(T azureTableEntity) where T : ITableEntity + { + TableEntity inner; + + if (azureTableEntity is TableEntity tableEntity) + { + inner = tableEntity; + } + else + { + inner = new(); + + foreach (var property in azureTableEntity.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length > 0) + { + continue; + } + + var value = property.GetValue(azureTableEntity); + inner.Add(property.Name, value); + } + + } + + inner.PartitionKey = azureTableEntity.PartitionKey; + inner.RowKey = azureTableEntity.RowKey; + + return new InMemoryTableEntity(inner); + + } +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/InMemoryTableService.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/InMemoryTableService.cs new file mode 100644 index 0000000..852e75d --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/InMemoryTableService.cs @@ -0,0 +1,62 @@ +using System.Diagnostics.CodeAnalysis; + +using Spotflow.InMemory.Azure.Storage.Resources; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Internals; + +internal class InMemoryTableService +{ + private readonly Dictionary _tables = new(); + + public InMemoryTableService(InMemoryStorageAccount account) + { + Uri = CreateServiceUriFromAccountName(account.Name, account.Provider); + Account = account; + } + + public Uri Uri { get; } + public InMemoryStorageAccount Account { get; } + + public bool TryAddTable(string tableName, out InMemoryTable result) + { + lock (_tables) + { + if (_tables.TryGetValue(tableName, out var existingTable)) + { + result = existingTable; + return false; + } + + var newTable = new InMemoryTable(tableName, this); + _tables.Add(tableName, newTable); + + result = newTable; + return true; + } + } + + public bool TryGetTable(string tableName, [NotNullWhen(true)] out InMemoryTable? result) + { + lock (_tables) + { + return _tables.TryGetValue(tableName, out result); + } + } + + public bool TryDeleteTable(string tableName) + { + lock (_tables) + { + return _tables.Remove(tableName); + } + } + + public override string ToString() => Uri.ToString().TrimEnd('/'); + + public static Uri CreateServiceUriFromAccountName(string accountName, InMemoryStorageProvider provider) + { + return new($"https://{accountName}.table.{provider.HostnameSuffix}"); + } + + public Uri CreateTableSasUri(string tableName) => TableUriUtils.UriForTable(Uri, tableName); +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/TableExceptionFactory.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/TableExceptionFactory.cs new file mode 100644 index 0000000..245b6e2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/TableExceptionFactory.cs @@ -0,0 +1,106 @@ +using System.Runtime.CompilerServices; + +using Azure; +using Azure.Data.Tables.Models; + +using Spotflow.InMemory.Azure.Storage.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Internals; + +internal static class TableExceptionFactory +{ + public static HttpRequestException TableServiceNotFound(string accountName, InMemoryStorageProvider provider) + { + return new($"Host '{InMemoryTableService.CreateServiceUriFromAccountName(accountName, provider)}' not found."); + } + + public static RequestFailedException TableNotFound(string tableName, InMemoryTableService tableService) + { + return new(404, $"Table '{tableName}' not found in '{tableService}'.", TableErrorCode.ResourceNotFound.ToString(), null); + } + + public static RequestFailedException TableAlreadyExists(string accountName, string tableName) + { + return new(409, $"Table '{tableName}' already exists in account '{accountName}'.", TableErrorCode.TableAlreadyExists.ToString(), null); + + } + + public static RequestFailedException EntityNotFound(string accountName, string tableName, string partitionKey, string rowKey) + { + return new( + 404, + $"Entity '{partitionKey}/{rowKey}' not found in table '{tableName}' in account '{accountName}'.", + TableErrorCode.ResourceNotFound.ToString(), // Not a bug. Azure Tables API returns this code. + null); + } + + public static RequestFailedException EntityAlreadyExists(string accountName, string tableName, string partitionKey, string rowKey) + { + return new( + 409, + $"Entity '{partitionKey}/{rowKey}' already exist " + + $"in table '{tableName}' in account '{accountName}'.", + TableErrorCode.EntityAlreadyExists.ToString(), + null); + } + + public static RequestFailedException ConditionNotMet(string accountName, string tableName, string partitionKey, string rowKey, ConditionError error) + { + return new( + 412, + $"Update condition '{error.ConditionType}' not satisfied " + + $"for entity '{partitionKey}/{rowKey}' " + + $"in table '{tableName}' in account '{accountName}': {error.Message}", + TableErrorCode.UpdateConditionNotSatisfied.ToString(), + null); + } + + public static NotSupportedException MethodNotSupported([CallerMemberName] string? callerMemberName = null) + { + return new($"In-memory table storage client does not support method '{callerMemberName}'."); + } + + public static NotSupportedException FeatureNotSupported(string featureName) + { + return new($"In-memory table storage client does not support feature '{featureName}'."); + } + + public static RequestFailedException MultiplePartitionsInTransaction(string accountName, string tableName) + { + return new( + 400, + $"Entities with different partition keys in a single transaction are not allowed " + + $"for table '{tableName}' in account '{accountName}'.", + TableErrorCode.CommandsInBatchActOnDifferentPartitions.ToString(), + null); + } + + public static RequestFailedException TooManyEntitiesInTransaction(string accountName, string tableName, int maxCount, int actualCount) + { + return new( + 400, + $"At most {maxCount} entities can be present in the transaction. Found {actualCount} entities in transaction " + + $"for table '{tableName}' in account '{accountName}'.", + TableErrorCode.InvalidInput.ToString(), + null); + } + + public static RequestFailedException DuplicateEntityInTransaction(string accountName, string tableName, string partitionKey, string rowKey) + { + return new( + 400, + $"Entity '{partitionKey}/{rowKey}' is duplicated in the transaction " + + $"for table '{tableName}' in account '{accountName}'.", + TableErrorCode.InvalidDuplicateRow.ToString(), + null); + } + + public static RequestFailedException ServiceIsBusy(string accountName) + { + return new( + 503, + $"Table service in account '{accountName}' is busy.", + "ServerBusy", + null); + } +} diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/TableUriUtils.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/TableUriUtils.cs new file mode 100644 index 0000000..782083a --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/TableUriUtils.cs @@ -0,0 +1,66 @@ +using Azure.Data.Tables; + +using Spotflow.InMemory.Azure.Storage.Internals; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Internals; + +internal static class TableUriUtils +{ + public static TableUriBuilder BuilderForService(string? connectionString, Uri? uri, InMemoryStorageProvider provider) + { + return Builder(connectionString, uri, null, provider); + } + + public static TableUriBuilder BuilderForTable(string? connectionString, Uri? uri, string? tableName, InMemoryStorageProvider provider) + { + var builder = Builder(connectionString, uri, tableName, provider); + + if (string.IsNullOrWhiteSpace(builder.Tablename)) + { + throw new InvalidOperationException("Table name must be specified when creating a table client."); + } + + return builder; + } + + public static Uri UriForTable(Uri serviceUri, string tableName) + { + var builder = Builder(null, serviceUri, tableName, null); + return builder.ToUri(); + } + + private static TableUriBuilder Builder(string? connectionString, Uri? uri, string? tableName, InMemoryStorageProvider? provider) + { + if (connectionString is not null && uri is not null) + { + throw new InvalidOperationException("Both a connection string and a URI cannot be provided."); + } + + if (uri is null) + { + if (connectionString is null) + { + throw new InvalidOperationException("Either a connection string or a URI must be provided."); + } + + if (provider is null) + { + throw new InvalidOperationException("A provider must be provided when using a connection string."); + } + + var accountName = StorageConnectionStringUtils.GetAccountNameFromConnectionString(connectionString); + uri = InMemoryTableService.CreateServiceUriFromAccountName(accountName, provider); + } + + var builder = new TableUriBuilder(uri); + + if (tableName is not null) + { + builder.Tablename = tableName; + } + + return builder; + } + +} + diff --git a/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/TextQueryFilterMatcher.cs b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/TextQueryFilterMatcher.cs new file mode 100644 index 0000000..5c8f647 --- /dev/null +++ b/src/Spotflow.InMemory.Azure.Storage/Tables/Internals/TextQueryFilterMatcher.cs @@ -0,0 +1,286 @@ +using System.Diagnostics.CodeAnalysis; + +using Microsoft.Extensions.Logging; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; +using Microsoft.OData.UriParser; + +namespace Spotflow.InMemory.Azure.Storage.Tables.Internals; + +internal class TextQueryFilterMatcher +{ + private static readonly Uri _baseUri; + private static readonly IEdmModel _edmModel; + private static readonly string _defaultEntitySetName; + + private readonly SingleValueNode? _filterExpression; + private readonly ILoggerFactory _loggerFactory; + + + static TextQueryFilterMatcher() + { + _baseUri = new("https://example.com"); + _defaultEntitySetName = "Entities"; + + var builder = new ODataConventionModelBuilder(); + + builder.EntitySet(_defaultEntitySetName); + + _edmModel = builder.GetEdmModel(); + } + + public TextQueryFilterMatcher(string? filter, ILoggerFactory loggerFactory) + { + if (filter is null) + { + _filterExpression = null; + } + else + { + var path = $"{_defaultEntitySetName}?{Uri.EscapeDataString("$filter")}={Uri.EscapeDataString(filter)}"; + + var uri = new Uri(_baseUri, path); + + var parser = new ODataUriParser(_edmModel, _baseUri, uri); + + _filterExpression = parser.ParseFilter().Expression; + } + + _loggerFactory = loggerFactory; + + } + + + public bool IsMatch(InMemoryTableEntity entity) + { + if (_filterExpression is null) + { + return true; + } + + var visitor = new ConditionVisitor(entity, _loggerFactory); + + return _filterExpression.Accept(visitor); + + } + + private static readonly BinaryOperatorKind[] _logicalOperators = [BinaryOperatorKind.Or, BinaryOperatorKind.And]; + private static readonly BinaryOperatorKind[] _comparisonOperators = [ + BinaryOperatorKind.Equal, + BinaryOperatorKind.LessThan, + BinaryOperatorKind.LessThanOrEqual, + BinaryOperatorKind.GreaterThan, + BinaryOperatorKind.GreaterThanOrEqual + ]; + + private class ConditionVisitor(InMemoryTableEntity entity, ILoggerFactory loggerFactory) : QueryNodeVisitor + { + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public override bool Visit(BinaryOperatorNode nodeIn) + { + if (Array.IndexOf(_logicalOperators, nodeIn.OperatorKind) >= 0) + { + using var conditionLogScope = _logger.BeginScope("Condition: {left} {Condition} {right}", nodeIn.Left.Kind, nodeIn.OperatorKind, nodeIn.Right.Kind); + + _logger.LogInformation("Visiting left side."); + + var left = nodeIn.Left.Accept(this); + + _logger.LogInformation("Visiting right side."); + + var right = nodeIn.Right.Accept(this); + + return nodeIn.OperatorKind switch + { + BinaryOperatorKind.Or => left || right, + BinaryOperatorKind.And => left && right, + _ => throw new InvalidOperationException($"Unexpected condition operator: {nodeIn.OperatorKind}.") + }; + } + + if (Array.IndexOf(_comparisonOperators, nodeIn.OperatorKind) >= 0) + { + using var valueLogScope = _logger.BeginScope("Value: {left} {Operator} {right}", nodeIn.Left.Kind, nodeIn.OperatorKind, nodeIn.Right.Kind); + + var valueVisitor = new ValueVisitor(entity); + + _logger.LogInformation("Visiting left side."); + + var left = nodeIn.Left.Accept(valueVisitor); + + _logger.LogInformation("Visiting right side."); + + var right = nodeIn.Right.Accept(valueVisitor); + + return nodeIn.OperatorKind switch + { + BinaryOperatorKind.Equal => left.IsEqual(right), + BinaryOperatorKind.LessThan => left.IsLessThan(right), + BinaryOperatorKind.LessThanOrEqual => left.IsLessThanOrEqual(right), + BinaryOperatorKind.GreaterThan => left.IsGreaterThan(right), + BinaryOperatorKind.GreaterThanOrEqual => left.IsGreaterThanOrEqual(right), + _ => throw new InvalidOperationException($"Unexpected comparison operator: {nodeIn.OperatorKind}") + }; + } + + throw new InvalidOperationException($"Unexpected operator: {nodeIn.OperatorKind}."); + + } + } + + private class ValueVisitor(InMemoryTableEntity entity) : QueryNodeVisitor + { + public override InMemoryTableEntityPropertyValue Visit(ConstantNode nodeIn) + { + return FromObject(nodeIn.Value); + } + + public override InMemoryTableEntityPropertyValue Visit(ConvertNode nodeIn) + { + return nodeIn.Source.Accept(this); + } + + public override InMemoryTableEntityPropertyValue Visit(SingleValuePropertyAccessNode nodeIn) + { + var propertyName = nodeIn.Property.Name; + + var value = entity.GetPropertyValueOrNull(propertyName); + return FromObject(propertyName, value); + } + + public override InMemoryTableEntityPropertyValue Visit(SingleValueOpenPropertyAccessNode nodeIn) + { + var propertyName = nodeIn.Name; + + var value = entity.GetPropertyValueOrNull(propertyName); + return FromObject(propertyName, value); + } + + private static InMemoryTableEntityPropertyValue FromObject(string propertyName, object? value) + { + if (InMemoryTableEntityPropertyValue.TryFromObject(value, out var result)) + { + return result; + } + + throw new NotSupportedException($"Property '{propertyName}' has unsupported type: {value} ({value?.GetType().ToString() ?? ""})"); + } + + private static InMemoryTableEntityPropertyValue FromObject(object? value) + { + if (InMemoryTableEntityPropertyValue.TryFromObject(value, out var result)) + { + return result; + } + + throw new NotSupportedException($"Value '{value}' has unsupported type: ({value?.GetType().ToString() ?? ""})"); + } + + } + + private abstract record InMemoryTableEntityPropertyValue + { + + + public virtual bool IsEqual(InMemoryTableEntityPropertyValue other) => this == other; + public abstract bool IsLessThan(InMemoryTableEntityPropertyValue other); + public abstract bool IsLessThanOrEqual(InMemoryTableEntityPropertyValue other); + public abstract bool IsGreaterThan(InMemoryTableEntityPropertyValue other); + public abstract bool IsGreaterThanOrEqual(InMemoryTableEntityPropertyValue other); + + + public record None : InMemoryTableEntityPropertyValue + { + public static None Instance { get; } = new(); + + public override bool IsGreaterThan(InMemoryTableEntityPropertyValue other) => false; + public override bool IsGreaterThanOrEqual(InMemoryTableEntityPropertyValue other) => false; + public override bool IsLessThan(InMemoryTableEntityPropertyValue other) => false; + public override bool IsLessThanOrEqual(InMemoryTableEntityPropertyValue other) => false; + } + + public record String(string Value) : InMemoryTableEntityPropertyValue + { + public override bool IsGreaterThan(InMemoryTableEntityPropertyValue other) => Compare(other) > 0; + public override bool IsGreaterThanOrEqual(InMemoryTableEntityPropertyValue other) => Compare(other) >= 0; + public override bool IsLessThan(InMemoryTableEntityPropertyValue other) => Compare(other) < 0; + public override bool IsLessThanOrEqual(InMemoryTableEntityPropertyValue other) => Compare(other) <= 0; + + private int? Compare(InMemoryTableEntityPropertyValue other) + { + if (other is not String otherString) + { + return null; + } + + return string.Compare(Value, otherString.Value, StringComparison.Ordinal); + } + } + + public record Integer(long Value) : InMemoryTableEntityPropertyValue + { + public override bool IsGreaterThan(InMemoryTableEntityPropertyValue other) => Compare(other) > 0; + public override bool IsGreaterThanOrEqual(InMemoryTableEntityPropertyValue other) => Compare(other) >= 0; + public override bool IsLessThan(InMemoryTableEntityPropertyValue other) => Compare(other) < 0; + public override bool IsLessThanOrEqual(InMemoryTableEntityPropertyValue other) => Compare(other) <= 0; + + private int? Compare(InMemoryTableEntityPropertyValue other) + { + if (other is not Integer otherInteger) + { + return null; + } + + return Value.CompareTo(otherInteger.Value); + } + } + + public record Double(double Value) : InMemoryTableEntityPropertyValue + { + + public override bool IsEqual(InMemoryTableEntityPropertyValue other) => Compare(other) == 0; + public override bool IsGreaterThan(InMemoryTableEntityPropertyValue other) => Compare(other) > 0; + public override bool IsGreaterThanOrEqual(InMemoryTableEntityPropertyValue other) => Compare(other) >= 0; + public override bool IsLessThan(InMemoryTableEntityPropertyValue other) => Compare(other) < 0; + public override bool IsLessThanOrEqual(InMemoryTableEntityPropertyValue other) => Compare(other) <= 0; + + private int? Compare(InMemoryTableEntityPropertyValue other) + { + if (other is not Double otherDouble) + { + return null; + } + + var diff = Math.Abs(Value - otherDouble.Value); + + const double epsilon = 1.192092896e-07F; + + if (diff < epsilon) + { + return 0; + } + + return Value.CompareTo(otherDouble.Value); + } + } + + public static bool TryFromObject(object? value, [NotNullWhen(true)] out InMemoryTableEntityPropertyValue? result) + { + result = value switch + { + null => None.Instance, + string v => new String(v), + int v => new Integer(v), + long v => new Integer(v), + double v => new Double(v), + float v => new Double(v), + _ => null + }; + + return result is not null; + } + } + +} + diff --git a/src/Spotflow.InMemory.Azure/Auth/NoOpTokenCredential.cs b/src/Spotflow.InMemory.Azure/Auth/NoOpTokenCredential.cs new file mode 100644 index 0000000..88e6804 --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Auth/NoOpTokenCredential.cs @@ -0,0 +1,20 @@ +using Azure.Core; + +namespace Spotflow.InMemory.Azure.Auth; + +public sealed class NoOpTokenCredential : TokenCredential +{ + public static NoOpTokenCredential Instance { get; } = new(); + + private NoOpTokenCredential() { } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken("no-op", default); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new(new AccessToken("no-op", default)); + } +} diff --git a/src/Spotflow.InMemory.Azure/Hooks/BaseHookFilter.cs b/src/Spotflow.InMemory.Azure/Hooks/BaseHookFilter.cs new file mode 100644 index 0000000..10c78b4 --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Hooks/BaseHookFilter.cs @@ -0,0 +1,6 @@ +namespace Spotflow.InMemory.Azure.Hooks; + +public abstract record BaseHookFilter +{ + public abstract bool Covers(TContext context); +} diff --git a/src/Spotflow.InMemory.Azure/Hooks/ConstantDelayGenerator.cs b/src/Spotflow.InMemory.Azure/Hooks/ConstantDelayGenerator.cs new file mode 100644 index 0000000..c29530d --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Hooks/ConstantDelayGenerator.cs @@ -0,0 +1,17 @@ +namespace Spotflow.InMemory.Azure.Hooks; + +public class ConstantDelayGenerator : IDelayGenerator +{ + private readonly TimeSpan _delay; + + public ConstantDelayGenerator(TimeSpan delay) + { + if (delay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(delay), delay, "Delay must be greater than or equal to zero."); + } + _delay = delay; + } + + public TimeSpan Next() => _delay; +} diff --git a/src/Spotflow.InMemory.Azure/Hooks/ExponentialDelayGenerator.cs b/src/Spotflow.InMemory.Azure/Hooks/ExponentialDelayGenerator.cs new file mode 100644 index 0000000..bbe53e1 --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Hooks/ExponentialDelayGenerator.cs @@ -0,0 +1,60 @@ +namespace Spotflow.InMemory.Azure.Hooks; + +public class ExponentialDelayGenerator : IDelayGenerator +{ + private readonly object? _syncObj; + private readonly Random _random; + private readonly double _offset; + private readonly double _inversedNegativeScaledRate; + + /// + /// Delays generated from an exponential distribution. + /// + /// With default settings, minimal delay is 10ms and the 98.75% of delays will be less than 4.01 seconds. + /// + /// Sets the rate (lambda parameter) of the distribution. Must be > 0. + /// Multiples the sample drawn from the distribution. Must be > 0. + /// Value that is added to the sample drawn from the distribution, after it is scaled with parameter. Must be >= 0. + /// Can be used to generate sample in a deterministic way. + public ExponentialDelayGenerator(double rate = 1.5, double scale = 1, double offsetSeconds = 0.01, int? seed = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(rate); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(scale); + ArgumentOutOfRangeException.ThrowIfNegative(offsetSeconds); + + if (seed is null) + { + _random = Random.Shared; + } + else + { + _random = new Random(seed.Value); + _syncObj = new(); + } + + _offset = offsetSeconds; + _inversedNegativeScaledRate = -scale / rate; + } + + public TimeSpan Next() + { + // offset + scale * (-1 / lambda) * log(1 - U) + + var uniformSample = SampleFromUniform(); + var totalSeconds = _offset + (_inversedNegativeScaledRate * Math.Log(1 - uniformSample)); + return TimeSpan.FromSeconds(totalSeconds); + } + + private double SampleFromUniform() + { + if (_syncObj is null) + { + return _random.NextDouble(); + } + + lock (_syncObj) + { + return _random.NextDouble(); + } + } +} diff --git a/src/Spotflow.InMemory.Azure/Hooks/HookFunc.cs b/src/Spotflow.InMemory.Azure/Hooks/HookFunc.cs new file mode 100644 index 0000000..7543fad --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Hooks/HookFunc.cs @@ -0,0 +1,3 @@ +namespace Spotflow.InMemory.Azure.Hooks; + +public delegate Task HookFunc(TContext context); diff --git a/src/Spotflow.InMemory.Azure/Hooks/IDelayGenerator.cs b/src/Spotflow.InMemory.Azure/Hooks/IDelayGenerator.cs new file mode 100644 index 0000000..ce900c2 --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Hooks/IDelayGenerator.cs @@ -0,0 +1,6 @@ +namespace Spotflow.InMemory.Azure.Hooks; + +public interface IDelayGenerator +{ + TimeSpan Next(); +} diff --git a/src/Spotflow.InMemory.Azure/Hooks/IHookRegistration.cs b/src/Spotflow.InMemory.Azure/Hooks/IHookRegistration.cs new file mode 100644 index 0000000..67bc1a9 --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Hooks/IHookRegistration.cs @@ -0,0 +1,8 @@ +namespace Spotflow.InMemory.Azure.Hooks; + +public interface IHookRegistration +{ + void Disable(); + void DisableAfter(int invocationCount); + void Enable(); +} diff --git a/src/Spotflow.InMemory.Azure/Hooks/Internals/HooksExecutor.cs b/src/Spotflow.InMemory.Azure/Hooks/Internals/HooksExecutor.cs new file mode 100644 index 0000000..b5a6080 --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Hooks/Internals/HooksExecutor.cs @@ -0,0 +1,172 @@ +using System.Collections.Concurrent; + +namespace Spotflow.InMemory.Azure.Hooks.Internals; + +internal class HooksExecutor + where TFilter : BaseHookFilter + where TBaseContext : class +{ + private readonly ConcurrentDictionary> _typeChainCache = []; + private readonly ConcurrentDictionary _hooks = []; + + public IHookRegistration AddHook(HookFunc hook, TFilter filter) where TContext : TBaseContext + { + var hooks = _hooks.GetOrAdd(typeof(TContext), _ => new()); + + var reg = HookRegistration.Create(hook, filter); + + hooks.Add(reg); + + return reg; + } + + public async Task ExecuteHooksAsync(TContext context) where TContext : TBaseContext + { + var typeChain = _typeChainCache.GetOrAdd(typeof(TContext), GetTypeChain); + + for (var i = typeChain.Count - 1; i >= 0; i--) + { + if (!_hooks.TryGetValue(typeChain[i], out var scopedHooks)) + { + continue; + } + + await scopedHooks.InvokeAsync(context).ConfigureAwait(ConfigureAwaitOptions.None); + } + } + + private static List GetTypeChain(Type contextType) + { + var typeChain = new List(); + var currentContextType = contextType; + + while (true) + { + typeChain.Add(currentContextType); + + if (currentContextType == typeof(TBaseContext)) + { + break; + } + + currentContextType = currentContextType.BaseType ?? throw new InvalidOperationException($"Unexpected end of type chain on type '{currentContextType.Name}'."); + } + + return typeChain; + } + + private class HookCollection + { + private readonly object _syncObj = new(); + + private HookRegistration[] _hooks = []; + + public async Task InvokeAsync(TContext context) where TContext : TBaseContext + { + HookRegistration[] hooksLocal; + lock (_syncObj) + { + hooksLocal = _hooks; + } + + foreach (var hook in hooksLocal) + { + await hook.InvokeAsync(context).ConfigureAwait(ConfigureAwaitOptions.None); + } + + } + + public void Add(HooksExecutor.HookRegistration reg) + { + lock (_syncObj) + { + var old = _hooks; + _hooks = new HookRegistration[_hooks.Length + 1]; + old.CopyTo(_hooks, 0); + _hooks[^1] = reg; + } + } + } + + + private class HookRegistration(HookFunc hook, BaseHookFilter filter) : IHookRegistration + { + private readonly object _syncObj = new(); + + private bool _isDisabled = false; + private int? _disableAfter = null; + private long _invocationCount = 0; + + public void Disable() + { + lock (_syncObj) + { + _isDisabled = true; + _disableAfter = null; + _invocationCount = 0; + } + } + + public void DisableAfter(int invocationCount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(invocationCount); + + lock (_syncObj) + { + if (_isDisabled) + { + throw new InvalidOperationException("Hook is already disabled."); + } + + _isDisabled = false; + _disableAfter = invocationCount; + _invocationCount = 0; + } + } + + public void Enable() + { + lock (_syncObj) + { + _isDisabled = false; + _disableAfter = null; + _invocationCount = 0; + } + } + + public async Task InvokeAsync(TContext context) where TContext : TBaseContext + { + if (!filter.Covers(context)) + { + return; + } + + lock (_syncObj) + { + _invocationCount++; + + if (_invocationCount > _disableAfter) + { + _isDisabled = true; + } + + if (_isDisabled) + { + return; + } + + } + + await hook(context).ConfigureAwait(ConfigureAwaitOptions.None); + + } + + public static HookRegistration Create(HookFunc hook, BaseHookFilter filter) where TContext : TBaseContext + { + Task hookCasted(TBaseContext ctx) => hook((TContext) ctx); + + return new(hookCasted, filter); + } + + } +} diff --git a/src/Spotflow.InMemory.Azure/Internals/InMemoryNullableResponse.cs b/src/Spotflow.InMemory.Azure/Internals/InMemoryNullableResponse.cs new file mode 100644 index 0000000..da0e490 --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Internals/InMemoryNullableResponse.cs @@ -0,0 +1,22 @@ +using Azure; + +namespace Spotflow.InMemory.Azure.Internals; + +public class InMemoryNullableResponse : NullableResponse +{ + public override bool HasValue { get; } + + public override T Value { get; } + + private InMemoryNullableResponse(T value, bool hasValue) + { + Value = value; + HasValue = hasValue; + } + + public static InMemoryNullableResponse FromValue(T value) => new(value, true); + + public static InMemoryNullableResponse FromNull() => new(default!, false); + + public override Response GetRawResponse() => throw new NotSupportedException(); +} diff --git a/src/Spotflow.InMemory.Azure/Internals/InMemoryPageable.cs b/src/Spotflow.InMemory.Azure/Internals/InMemoryPageable.cs new file mode 100644 index 0000000..92a160f --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Internals/InMemoryPageable.cs @@ -0,0 +1,96 @@ +using Azure; + +namespace Spotflow.InMemory.Azure.Internals; + +internal static class InMemoryPageable +{ + internal class YieldingAsync(IReadOnlyList items, int maxPageSize) : AsyncPageable where T : notnull + { + private readonly int _maxPageSize = maxPageSize <= 0 ? throw new ArgumentOutOfRangeException(nameof(maxPageSize)) : maxPageSize; + + public override async IAsyncEnumerable> AsPages(string? continuationToken = null, int? pageSizeHint = null) + { + if (pageSizeHint <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSizeHint)); + } + + await Task.Yield(); + + var pages = PreparePages(items, _maxPageSize, pageSizeHint); + + var shouldReturnPage = continuationToken is null; + + foreach (var page in pages) + { + if (shouldReturnPage) + { + await Task.Yield(); + yield return page; + } + else if (page.ContinuationToken == continuationToken) + { + shouldReturnPage = true; + } + } + } + } + + public class Sync(IReadOnlyList items, int maxPageSize) : Pageable where T : notnull + { + private readonly int _maxPageSize = maxPageSize <= 0 ? throw new ArgumentOutOfRangeException(nameof(maxPageSize)) : maxPageSize; + + public override IEnumerable> AsPages(string? continuationToken = null, int? pageSizeHint = null) + { + if (pageSizeHint <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSizeHint)); + } + + var pages = PreparePages(items, _maxPageSize, pageSizeHint); + + var shouldReturnPage = continuationToken is null; + + foreach (var page in pages) + { + if (shouldReturnPage) + { + yield return page; + } + else if (page.ContinuationToken == continuationToken) + { + shouldReturnPage = true; + } + } + } + } + + + private static IEnumerable> PreparePages(IReadOnlyList items, int maxPageSize, int? pageSizeHint) + { + if (pageSizeHint <= 0) + { + throw new InvalidOperationException(nameof(pageSizeHint)); + } + + var pageSize = Math.Min(pageSizeHint ?? maxPageSize, maxPageSize); + + var numberOfPages = (int) Math.Ceiling((double) items.Count / pageSize); + + for (var pageIndex = 0; pageIndex < numberOfPages; pageIndex++) + { + var isLastPage = pageIndex == numberOfPages - 1; + + var continuationToken = isLastPage ? null : $"page-{pageIndex + 1}"; + + var itemsInPage = items.Skip(pageIndex * pageSize).Take(pageSize).ToList(); + + var page = Page.FromValues(itemsInPage, continuationToken, new InMemoryResponse(200)); + + yield return page; + } + + } +} + + diff --git a/src/Spotflow.InMemory.Azure/Internals/InMemoryResponse.cs b/src/Spotflow.InMemory.Azure/Internals/InMemoryResponse.cs new file mode 100644 index 0000000..e873182 --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Internals/InMemoryResponse.cs @@ -0,0 +1,92 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; + +using Azure; +using Azure.Core; + +namespace Spotflow.InMemory.Azure.Internals; + +public class InMemoryResponse : Response +{ + private readonly Dictionary> _headers = []; + + public static Response FromValue(T value, int status, ETag? eTag = null) => FromValue(value, new InMemoryResponse(status, eTag: eTag)); + + + public InMemoryResponse(int status, ETag? eTag = null) + { + Status = status; + ClientRequestId = Guid.NewGuid().ToString(); + + if (eTag != null) + { + AddHeaderValue("ETag", eTag.Value.ToString()); + } + + } + + public override int Status { get; } + + public override string ReasonPhrase + { + get + { + var statusEnum = (HttpStatusCode) Status; + + if (Enum.IsDefined(statusEnum)) + { + return statusEnum.ToString(); + } + + return Status.ToString(); + } + } + + public override Stream? ContentStream { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override string ClientRequestId { get; set; } + public override void Dispose() { } + protected override bool ContainsHeader(string name) => _headers.ContainsKey(name); + + protected override IEnumerable EnumerateHeaders() => _headers.SelectMany(kvp => kvp.Value.Select(v => new HttpHeader(kvp.Key, v))); + + protected override bool TryGetHeader(string name, [NotNullWhen(true)] out string? value) + { + if (_headers.TryGetValue(name, out var valuesRaw)) + { + value = string.Join(",", valuesRaw); + return true; + } + else + { + value = null; + return false; + } + } + + + protected override bool TryGetHeaderValues(string name, [NotNullWhen(true)] out IEnumerable? values) + { + if (_headers.TryGetValue(name, out var valuesRaw)) + { + values = valuesRaw; + return true; + } + else + { + values = null; + return false; + } + } + + private void AddHeaderValue(string headerName, string value) + { + if (!_headers.TryGetValue(headerName, out var values)) + { + values = new(1); + _headers[headerName] = values; + } + + values.Add(value); + } + +} diff --git a/src/Spotflow.InMemory.Azure/Internals/TaskExtensions.cs b/src/Spotflow.InMemory.Azure/Internals/TaskExtensions.cs new file mode 100644 index 0000000..771e2d8 --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Internals/TaskExtensions.cs @@ -0,0 +1,16 @@ +using System.Runtime.CompilerServices; + +namespace Spotflow.InMemory.Azure.Internals; + +internal static class TaskExtensions +{ + public static T EnsureCompleted(this Task task) + { + return task.GetAwaiter().GetResult(); + } + + public static T EnsureCompleted(this ConfiguredTaskAwaitable task) + { + return task.GetAwaiter().GetResult(); + } +} diff --git a/src/Spotflow.InMemory.Azure/Spotflow.InMemory.Azure.csproj b/src/Spotflow.InMemory.Azure/Spotflow.InMemory.Azure.csproj new file mode 100644 index 0000000..90fe37c --- /dev/null +++ b/src/Spotflow.InMemory.Azure/Spotflow.InMemory.Azure.csproj @@ -0,0 +1,41 @@ + + + + Drop-in fakes of Azure .NET SDKs to make your test blazing-fast and reliable. This is a core package that is only useful with other related packages. + $(PackageTags) + true + README.md + + + + + + + + + <_Parameter1>Spotflow.InMemory.Azure.EventHubs + + + + <_Parameter1>Spotflow.InMemory.Azure.ServiceBus + + + + <_Parameter1>Spotflow.InMemory.Azure.Storage + + + + <_Parameter1>Spotflow.InMemory.Azure.KeyVault + + + + <_Parameter1>Tests + + + + + + + + + diff --git a/tests/Set-AzureEnvironment.ps1 b/tests/Set-AzureEnvironment.ps1 new file mode 100644 index 0000000..49684bb --- /dev/null +++ b/tests/Set-AzureEnvironment.ps1 @@ -0,0 +1,20 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory = $true)][string] $UseAzure, + [Parameter(Mandatory = $true)][string] $TenantId, + [Parameter(Mandatory = $true)][string] $SubscriptionId, + [Parameter(Mandatory = $true)][string] $ResourceGroupName, + [Parameter(Mandatory = $true)][string] $StorageAccountName, + [Parameter(Mandatory = $true)][string] $ServiceBusNamespaceName, + [Parameter(Mandatory = $true)][string] $KeyVaultName + ) + +$env:SPOTFLOW_USE_AZURE = $UseAzure +$env:AZURE_TENANT_ID = $TenantId +$env:AZURE_SUBSCRIPTION_ID = $SubscriptionId +$env:AZURE_RESOURCE_GROUP_NAME = $ResourceGroupName +$env:AZURE_STORAGE_ACCOUNT_NAME = $StorageAccountName +$env:AZURE_SERVICE_BUS_NAMESPACE_NAME = $ServiceBusNamespaceName +$env:AZURE_KEY_VAULT_NAME = $KeyVaultName + +Write-Host "Azure environment set." diff --git a/tests/Tests/EventHub/EventHubConsumerClientTests.cs b/tests/Tests/EventHub/EventHubConsumerClientTests.cs new file mode 100644 index 0000000..d88df5a --- /dev/null +++ b/tests/Tests/EventHub/EventHubConsumerClientTests.cs @@ -0,0 +1,25 @@ +using Spotflow.InMemory.Azure.EventHubs; + +namespace Tests.EventHub; + +[TestClass] +public class EventHubConsumerClientTests +{ + [TestMethod] + public async Task Constructor_With_Connection_String_Should_Succeed() + { + var provider = new InMemoryEventHubProvider(); + + var eventHub = provider.AddNamespace().AddEventHub("test", 2); + + var connectionString = eventHub.CreateConnectionString(); + + await using var client = new InMemoryEventHubConsumerClient("cg", connectionString, provider); + + client.EventHubName.Should().Be("test"); + client.FullyQualifiedNamespace.Should().Be(eventHub.Namespace.FullyQualifiedNamespace); + client.Identifier.Should().NotBeNullOrWhiteSpace(); + client.IsClosed.Should().BeFalse(); + client.ConsumerGroup.Should().Be("cg"); + } +} diff --git a/tests/Tests/EventHub/EventHubNamespaceTests.cs b/tests/Tests/EventHub/EventHubNamespaceTests.cs new file mode 100644 index 0000000..9a7a139 --- /dev/null +++ b/tests/Tests/EventHub/EventHubNamespaceTests.cs @@ -0,0 +1,21 @@ +using Azure.Messaging.EventHubs; + +using Spotflow.InMemory.Azure.EventHubs; + +namespace Tests.EventHub; + +[TestClass] +public class EventHubNamespaceTests +{ + [TestMethod] + public void ConnectionString_ShouldBeReturned() + { + var eventHubNamespace = new InMemoryEventHubProvider().AddNamespace(); + var connectionString = eventHubNamespace.CreateConnectionString(); + + var connection = new EventHubConnection(connectionString, "test-eh"); + + connection.FullyQualifiedNamespace.Should().Be(eventHubNamespace.FullyQualifiedNamespace); + connection.EventHubName.Should().Be("test-eh"); + } +} diff --git a/tests/Tests/EventHub/EventHubProducerClientTests.cs b/tests/Tests/EventHub/EventHubProducerClientTests.cs new file mode 100644 index 0000000..5a2d78d --- /dev/null +++ b/tests/Tests/EventHub/EventHubProducerClientTests.cs @@ -0,0 +1,53 @@ +using Azure.Messaging.EventHubs; +using Azure.Messaging.EventHubs.Consumer; +using Azure.Messaging.EventHubs.Producer; + +using Spotflow.InMemory.Azure.EventHubs; + +namespace Tests.EventHub; + +[TestClass] +public class EventHubProducerClientTests +{ + [TestMethod] + public async Task Constructor_With_Connection_String_Should_Succeed() + { + var provider = new InMemoryEventHubProvider(); + + var eventHub = provider.AddNamespace().AddEventHub("test", 2); + + var connectionString = eventHub.CreateConnectionString(); + + await using var client = new InMemoryEventHubProducerClient(connectionString, provider); + + client.EventHubName.Should().Be("test"); + client.FullyQualifiedNamespace.Should().Be(eventHub.Namespace.FullyQualifiedNamespace); + client.Identifier.Should().NotBeNullOrWhiteSpace(); + client.IsClosed.Should().BeFalse(); + } + + + [TestMethod] + public async Task SystemProperties_ShouldBeSent() + { + var eventHub = new InMemoryEventHubProvider().AddNamespace().AddEventHub("test-eh", 1); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + await using var consumer = InMemoryPartitionReceiver.FromEventHub("$default", "0", EventPosition.Earliest, eventHub); + + var sentEventData = new EventData { MessageId = "test-mi", ContentType = "test-ct", CorrelationId = "test-ci" }; + + await producer.SendAsync([sentEventData], new SendEventOptions { PartitionKey = "test-pk" }); + + var batch = await consumer.ReceiveBatchAsync(100, TimeSpan.Zero); + + var eventData = batch.Should().ContainSingle().Which; + + eventData.MessageId.Should().Be("test-mi"); + eventData.ContentType.Should().Be("test-ct"); + eventData.CorrelationId.Should().Be("test-ci"); + eventData.PartitionKey.Should().Be("test-pk"); + + } + +} diff --git a/tests/Tests/EventHub/EventHubTests.cs b/tests/Tests/EventHub/EventHubTests.cs new file mode 100644 index 0000000..ea7f4ad --- /dev/null +++ b/tests/Tests/EventHub/EventHubTests.cs @@ -0,0 +1,141 @@ +using Azure.Messaging.EventHubs; +using Azure.Messaging.EventHubs.Consumer; +using Azure.Messaging.EventHubs.Producer; + +using Spotflow.InMemory.Azure.EventHubs; + +namespace Tests.EventHub; + +[TestClass] +public class EventHubTests +{ + [TestMethod] + public void ConnectionString_ShouldBeReturned() + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 2); + + var connectionString = eventHub.CreateConnectionString(); + + var connection = new EventHubConnection(connectionString); + + connection.FullyQualifiedNamespace.Should().Be(eventHub.Namespace.FullyQualifiedNamespace); + connection.EventHubName.Should().Be(eventHub.Name); + } + + + [TestMethod] + [DataRow(123, null, null, 985, 909, DisplayName = "Seed 123")] + [DataRow(456, null, null, 953, 620, DisplayName = "Seed 456")] + [DataRow(456, null, 200000, 190438, 123247, DisplayName = "Seed 456 with max value")] + [DataRow(null, null, null, 474, 57, DisplayName = "Default seed")] + [DataRow(null, null, 200000, 93786, 9642, DisplayName = "Default seed with max value")] + [DataRow(42, 32, 38, 36, 32, DisplayName = "Seed 42 with min and max value")] + [DataRow(42, null, 12, 12, 10, DisplayName = "Seed 42 with max value close to min value")] + public async Task InitialSequenceNumbers_WithRandomization_WithSeed_ShouldBeUsed(int? seed, int? min, int? max, int expectedSequenceNumberForPartition0, int expectedSequenceNumberForPartition1) + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 2, options => + { + options.RandomizeInitialSequenceNumbers = true; + + if (seed.HasValue) + { + options.RandomizationSeed = seed; + } + + if (min.HasValue) + { + options.MinRandomInitialSequenceNumber = min.Value; + } + + if (max.HasValue) + { + options.MaxRandomInitialSequenceNumber = max.Value; + } + + }); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + await using var consumer0 = InMemoryPartitionReceiver.FromEventHub("$default", "0", EventPosition.Earliest, eventHub); + await using var consumer1 = InMemoryPartitionReceiver.FromEventHub("$default", "1", EventPosition.Earliest, eventHub); + + var sentEventData = new EventData(); + + await producer.SendAsync([sentEventData], new SendEventOptions { PartitionId = "0" }); + await producer.SendAsync([sentEventData], new SendEventOptions { PartitionId = "1" }); + + var batch0 = await consumer0.ReceiveBatchAsync(100, TimeSpan.Zero); + var batch1 = await consumer1.ReceiveBatchAsync(100, TimeSpan.Zero); + + batch0.Should().ContainSingle().Which.SequenceNumber.Should().Be(expectedSequenceNumberForPartition0); + batch1.Should().ContainSingle().Which.SequenceNumber.Should().Be(expectedSequenceNumberForPartition1); + + eventHub.GetInitialSequenceNumber("0").Should().Be(expectedSequenceNumberForPartition0); + eventHub.GetInitialSequenceNumber("1").Should().Be(expectedSequenceNumberForPartition1); + } + + [TestMethod] + + public async Task InitialSequenceNumbers_WithRandomization_WithoutSeed_ShouldBeUsed() + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 1, options => + { + options.RandomizeInitialSequenceNumbers = true; + options.RandomizationSeed = null; + }); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + await using var consumer = InMemoryPartitionReceiver.FromEventHub("$default", "0", EventPosition.Earliest, eventHub); + + const int eventsCount = 100_000; + + foreach (var i in Enumerable.Range(0, eventsCount)) + { + await producer.SendAsync([new EventData()], new SendEventOptions { PartitionId = "0" }); + } + + var batch = await consumer.ReceiveBatchAsync(100, TimeSpan.Zero); + + batch.Should().AllSatisfy(e => e.SequenceNumber.Should().BeInRange(1, 1900 + eventsCount)); + + + eventHub.GetInitialSequenceNumber("0").Should().BeInRange(1, 1000 + eventsCount); + + + } + + [TestMethod] + + public async Task InitialSequenceNumbers_ShouldBeZero() + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 2); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + await using var consumer0 = InMemoryPartitionReceiver.FromEventHub("$default", "0", EventPosition.Earliest, eventHub); + await using var consumer1 = InMemoryPartitionReceiver.FromEventHub("$default", "1", EventPosition.Earliest, eventHub); + + var sentEventData = new EventData(); + + await producer.SendAsync([sentEventData], new SendEventOptions { PartitionId = "0" }); + await producer.SendAsync([sentEventData], new SendEventOptions { PartitionId = "1" }); + + var batch0 = await consumer0.ReceiveBatchAsync(100, TimeSpan.Zero); + var batch1 = await consumer1.ReceiveBatchAsync(100, TimeSpan.Zero); + + batch0.Should().ContainSingle().Which.SequenceNumber.Should().Be(0); + batch1.Should().ContainSingle().Which.SequenceNumber.Should().Be(0); + + eventHub.GetInitialSequenceNumber("0").Should().Be(0); + eventHub.GetInitialSequenceNumber("1").Should().Be(0); + + + } + +} diff --git a/tests/Tests/EventHub/FaultInjectionTests.cs b/tests/Tests/EventHub/FaultInjectionTests.cs new file mode 100644 index 0000000..b4ac4e6 --- /dev/null +++ b/tests/Tests/EventHub/FaultInjectionTests.cs @@ -0,0 +1,29 @@ +using Azure.Messaging.EventHubs; + +using Spotflow.InMemory.Azure.EventHubs; + +namespace Tests.EventHub; + +[TestClass] +public class FaultInjectionTests +{ + [TestMethod] + public async Task Service_Is_Busy_With_Manual_Fault_Disable_Should_Succeed() + { + var provider = new InMemoryEventHubProvider(); + + var hook = provider.AddHook(hook => hook.Before(ctx => ctx.Faults().ServiceIsBusy())); + + var eventHub = provider.AddNamespace("test-ns").AddEventHub("test-eh", 1); + + var producerClient = InMemoryEventHubProducerClient.FromEventHub(eventHub); + + var act = () => producerClient.SendAsync([new EventData()]); + + await act.Should().ThrowAsync().WithMessage("Event hub 'test-eh' in namespace 'test-ns' is busy. (test-eh). *"); + + hook.Disable(); + + await act.Should().NotThrowAsync(); + } +} diff --git a/tests/Tests/EventHub/HooksTests.cs b/tests/Tests/EventHub/HooksTests.cs new file mode 100644 index 0000000..3a5a3bb --- /dev/null +++ b/tests/Tests/EventHub/HooksTests.cs @@ -0,0 +1,260 @@ +using Azure.Messaging.EventHubs; +using Azure.Messaging.EventHubs.Consumer; + +using Spotflow.InMemory.Azure.EventHubs; +using Spotflow.InMemory.Azure.EventHubs.Hooks; +using Spotflow.InMemory.Azure.EventHubs.Hooks.Contexts; +using Spotflow.InMemory.Azure.Hooks; + +using Tests.Utils; + +namespace Tests.EventHub; + +[TestClass] +public class HooksTests +{ + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Send_Hooks_Should_Execute() + { + const string namespaceName = "test-ns"; + const string eventHubName = "test-eh"; + + var provider = new InMemoryEventHubProvider(); + + var eventHub = provider.AddNamespace(namespaceName).AddEventHub(eventHubName, 2); + + var connectionString = eventHub.CreateConnectionString(); + + await using var client = new InMemoryEventHubProducerClient(connectionString, provider); + + SendBeforeHookContext? capturedBeforeContext = null; + SendAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForProducer().BeforeSend(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(builder => builder.ForProducer().AfterSend(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + var eventData = new EventData { MessageId = "test-mi", ContentType = "test-ct", CorrelationId = "test-ci" }; + + await client.SendAsync([eventData]); + + var scope = new EventHubScope(namespaceName, eventHubName); + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.Operation.Should().Be(ProducerOperations.Send); + capturedBeforeContext?.EventBatch.Should().HaveCount(1); + capturedBeforeContext?.EventBatch[0].Should().BeEquivalentTo(eventData); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.Operation.Should().Be(ProducerOperations.Send); + capturedAfterContext?.EventBatch.Should().HaveCount(1); + capturedAfterContext?.EventBatch[0].Should().BeEquivalentTo(eventData); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Receive_Batch_Hooks_Should_Execute() + { + const string namespaceName = "test-ns"; + const string eventHubName = "test-eh"; + + var provider = new InMemoryEventHubProvider(); + + var eventHub = provider.AddNamespace(namespaceName).AddEventHub(eventHubName, 1); + + var connectionString = eventHub.CreateConnectionString(); + + await using var producerClient = new InMemoryEventHubProducerClient(connectionString, provider); + await using var consumerClient = InMemoryPartitionReceiver.FromEventHub("0", EventPosition.Earliest, eventHub); + + ReceiveBatchBeforeHookContext? capturedBeforeContext = null; + ReceiveBatchAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForConsumer().BeforeReceiveBatch(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(builder => builder.ForConsumer().AfterReceiveBatch(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + var eventData = new EventData { MessageId = "test-mi", ContentType = "test-ct", CorrelationId = "test-ci", Data = BinaryData.FromString("test-data") }; + + await producerClient.SendAsync([eventData]); + + await consumerClient.ReceiveBatchAsync(10); + + var scope = new EventHubScope(namespaceName, eventHubName); + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.Operation.Should().Be(ConsumerOperations.ReceiveBatch); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.Operation.Should().Be(ConsumerOperations.ReceiveBatch); + capturedAfterContext?.EventBatch.Should().HaveCount(1); + capturedAfterContext?.EventBatch[0].Data?.ToString().Should().BeEquivalentTo(eventData.Data.ToString()); + capturedAfterContext?.EventBatch[0].Properties.Should().BeEquivalentTo(eventData.Properties); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Hooks_With_Different_Scope_Should_Not_Execute() + { + const string namespaceName = "test-ns"; + const string eventHubName = "test-eh"; + + var provider = new InMemoryEventHubProvider(); + + var eventHub = provider.AddNamespace(namespaceName).AddEventHub(eventHubName, 2); + + var connectionString = eventHub.CreateConnectionString(); + + await using var client = new InMemoryEventHubProducerClient(connectionString, provider); + + HookFunc failingBeforeHook = _ => throw new InvalidOperationException("This hook should not execute."); + HookFunc failingAfterHook = _ => throw new InvalidOperationException("This hook should not execute."); + + provider.AddHook(builder => builder.ForProducer(eventHubNamespaceName: "different").BeforeSend(failingBeforeHook)); + provider.AddHook(builder => builder.ForProducer(eventHubName: "different").BeforeSend(failingBeforeHook)); + + + provider.AddHook(builder => builder.ForProducer(eventHubNamespaceName: "different").AfterSend(failingAfterHook)); + provider.AddHook(builder => builder.ForProducer(eventHubName: "different").AfterSend(failingAfterHook)); + + + var eventData = new EventData { MessageId = "test-mi", ContentType = "test-ct", CorrelationId = "test-ci" }; + + await client.SendAsync([eventData]); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Parent_Hook_Should_Execute() + { + const string namespaceName = "test-ns"; + const string eventHubName = "test-eh"; + + var provider = new InMemoryEventHubProvider(); + + var eventHub = provider.AddNamespace(namespaceName).AddEventHub(eventHubName, 2); + + var connectionString = eventHub.CreateConnectionString(); + + await using var client = new InMemoryEventHubProducerClient(connectionString, provider); + + EventHubBeforeHookContext? capturedBeforeContext = null; + EventHubAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.Before(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(builder => builder.After(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + var eventData = new EventData { MessageId = "test-mi", ContentType = "test-ct", CorrelationId = "test-ci" }; + + await client.SendAsync([eventData]); + + var scope = new EventHubScope(namespaceName, eventHubName); + + capturedBeforeContext.Should().NotBeNull(); + + capturedBeforeContext.Should().BeOfType(); + + ((SendBeforeHookContext) capturedBeforeContext!).Operation.Should().Be(ProducerOperations.Send); + + capturedAfterContext.Should().NotBeNull(); + + capturedAfterContext.Should().BeOfType(); + ((SendAfterHookContext) capturedAfterContext!).Operation.Should().Be(ProducerOperations.Send); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Targeted_Hooks_Should_Execute() + { + const string namespaceName = "test-ns"; + const string eventHubName = "test-eh"; + + var provider = new InMemoryEventHubProvider(); + + var eventHub = provider.AddNamespace(namespaceName).AddEventHub(eventHubName, 2); + + var connectionString = eventHub.CreateConnectionString(); + + await using var client = new InMemoryEventHubProducerClient(connectionString, provider); + + ProducerBeforeHookContext? capturedBeforeContext = null; + ProducerAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForProducer().Before(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + }, ProducerOperations.Send)); + + provider.AddHook(builder => builder.ForProducer().After(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + }, ProducerOperations.Send)); + + var eventData = new EventData { MessageId = "test-mi", ContentType = "test-ct", CorrelationId = "test-ci" }; + + await client.SendAsync([eventData]); + + var scope = new EventHubScope(namespaceName, eventHubName); + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.Operation.Should().Be(ProducerOperations.Send); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.Operation.Should().Be(ProducerOperations.Send); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Hooks_With_Different_Target_Should_Not_Execute() + { + const string namespaceName = "test-ns"; + const string eventHubName = "test-eh"; + + var provider = new InMemoryEventHubProvider(); + + var eventHub = provider.AddNamespace(namespaceName).AddEventHub(eventHubName, 2); + + var connectionString = eventHub.CreateConnectionString(); + + await using var client = new InMemoryEventHubProducerClient(connectionString, provider); + + provider.AddHook( + builder => builder.ForConsumer().Before(_ => throw new InvalidOperationException("This hook should not execute."), ConsumerOperations.ReceiveBatch)); + + provider.AddHook( + builder => builder.ForConsumer().After(_ => throw new InvalidOperationException("This hook should not execute."), ConsumerOperations.ReceiveBatch)); + + var eventData = new EventData { MessageId = "test-mi", ContentType = "test-ct", CorrelationId = "test-ci" }; + + await client.SendAsync([eventData]); + } +} diff --git a/tests/Tests/EventHub/PartitionReceiverTests.cs b/tests/Tests/EventHub/PartitionReceiverTests.cs new file mode 100644 index 0000000..2e6b3e4 --- /dev/null +++ b/tests/Tests/EventHub/PartitionReceiverTests.cs @@ -0,0 +1,268 @@ +using Azure.Messaging.EventHubs; +using Azure.Messaging.EventHubs.Consumer; + +using Microsoft.Extensions.Time.Testing; + +using Spotflow.InMemory.Azure.EventHubs; +using Spotflow.InMemory.Azure.Storage.Blobs.Internals; + +namespace Tests.EventHub; + +[TestClass] +public class PartitionReceiverTests +{ + [TestMethod] + public async Task Constructor_With_Connection_String_Should_Succeed() + { + var provider = new InMemoryEventHubProvider(); + + var eventHub = provider.AddNamespace().AddEventHub("test", 2); + + var connectionString = eventHub.CreateConnectionString(); + + await using var client = new InMemoryPartitionReceiver("cg", "pid", EventPosition.Earliest, connectionString, provider); + + client.EventHubName.Should().Be("test"); + client.FullyQualifiedNamespace.Should().Be(eventHub.Namespace.FullyQualifiedNamespace); + client.Identifier.Should().NotBeNullOrWhiteSpace(); + client.IsClosed.Should().BeFalse(); + client.ConsumerGroup.Should().Be("cg"); + client.InitialPosition.Should().Be(EventPosition.Earliest); + client.PartitionId.Should().Be("pid"); + } + + [TestMethod] + public async Task SpecificStartingPosition_Inclusive_ShouldReturnOnlySpecificEvents() + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 1) + .AddConsumerGroup("test-cg"); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + await using var receiver = InMemoryPartitionReceiver.FromEventHub("test-cg", "0", EventPosition.FromSequenceNumber(1, isInclusive: true), eventHub); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-0"))]); + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-1"))]); + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-2"))]); + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-3"))]); + + var batch = await receiver.ReceiveBatchAsync(100); + + batch.Select(e => e.EventBody.ToString()).Should().Equal(["test-data-1", "test-data-2", "test-data-3"]); + + } + + [TestMethod] + public async Task FutureStartingPosition_ShouldReturnOnlyFutureEvents() + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 1) + .AddConsumerGroup("test-cg"); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + await using var receiver = InMemoryPartitionReceiver.FromEventHub("test-cg", "0", EventPosition.FromSequenceNumber(3, isInclusive: true), eventHub); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-0"))]); + + var batch1 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + batch1.Should().BeEmpty(); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-1"))]); + + var batch2 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + batch2.Should().BeEmpty(); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-2"))]); + + var batch3 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + batch3.Should().BeEmpty(); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-3"))]); + + var batch4 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + batch4.Should().ContainSingle(e => e.EventBody.ToString() == "test-data-3"); + } + + [TestMethod] + public async Task SpecificStartingPosition_Exclusive_ShouldReturnOnlySpecificEvents() + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 1) + .AddConsumerGroup("test-cg"); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-0"))]); + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-1"))]); + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-2"))]); + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-3"))]); + + await using var receiver = InMemoryPartitionReceiver.FromEventHub("test-cg", "0", EventPosition.FromSequenceNumber(1, isInclusive: false), eventHub); + + var batch = await receiver.ReceiveBatchAsync(100); + + batch.Select(e => e.EventBody.ToString()).Should().Equal(["test-data-2", "test-data-3"]); + + } + + + [TestMethod] + public async Task LatestStartingPosition_ShouldReturnOnlyNewEvents() + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 1) + .AddConsumerGroup("test-cg"); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-1"))]); + + await using var receiver = InMemoryPartitionReceiver.FromEventHub("test-cg", "0", EventPosition.Latest, eventHub); + + var batch1 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + + batch1.Should().BeEmpty(); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-2"))]); + + var batch2 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + + batch2.Should().ContainSingle(e => e.EventBody.ToString() == "test-data-2"); + + } + + [TestMethod] + public async Task EarliestStartingPosition_ShouldReturnAllEvents() + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 1) + .AddConsumerGroup("test-cg"); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-1"))]); + + await using var receiver = InMemoryPartitionReceiver.FromEventHub("test-cg", "0", EventPosition.Earliest, eventHub); + + var batch1 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + + batch1.Should().ContainSingle(e => e.EventBody.ToString() == "test-data-1"); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-2"))]); + + var batch2 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + + batch2.Should().ContainSingle(e => e.EventBody.ToString() == "test-data-2"); + + } + + [TestMethod] + public async Task By_Default_There_Should_Be_Default_Consumer_Group_And_Position_Is_Set_To_Earliest() + { + var eventHub = new InMemoryEventHubProvider() + .AddNamespace() + .AddEventHub("test-eh", 1); + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-1"))]); + + await using var receiver = InMemoryPartitionReceiver.FromEventHub("0", EventPosition.Earliest, eventHub); + + var batch1 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + + batch1.Should().ContainSingle(e => e.EventBody.ToString() == "test-data-1"); + + await producer.SendAsync([new EventData(BinaryData.FromString("test-data-2"))]); + + var batch2 = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + + batch2.Should().ContainSingle(e => e.EventBody.ToString() == "test-data-2"); + + } + + + + [TestMethod] + public async Task Last_Enqueued_Event_Properties_Should_Be_Refreshed() + { + var timeProvider = new FakeTimeProvider(); + + var eventHub = new InMemoryEventHubProvider(timeProvider: timeProvider) + .AddNamespace() + .AddEventHub("test-eh", 1); + + await using var receiver = InMemoryPartitionReceiver.FromEventHub("0", EventPosition.Earliest, eventHub); + + // Initial LastEnqueuedEventProperties shuold be default + + receiver.ReadLastEnqueuedEventProperties().Should().Be(default(LastEnqueuedEventProperties)); + + // Receive several empty batches which should yield default LastEnqueuedEventProperties. + + for (var i = 0; i < 16; i++) + { + var batch0Task = receiver.ReceiveBatchAsync(100, TimeSpan.FromSeconds(16)); + + while (!batch0Task.IsCompleted) + { + timeProvider.Advance(TimeSpan.FromSeconds(4)); + } + + var batch0 = await batch0Task; + + batch0.Should().BeEmpty(); + + var properties0 = receiver.ReadLastEnqueuedEventProperties(); + + properties0.Should().NotBeNull(); + properties0.Should().Be(default(LastEnqueuedEventProperties)); + + } + + await using var producer = InMemoryEventHubProducerClient.FromEventHub(eventHub); + var data = BinaryData.FromString("test-data-1"); + + // Send first event + + await producer.SendAsync([new EventData(data)]); + + // Properties after first event without receiving should remain default. + + receiver.ReadLastEnqueuedEventProperties().Should().Be(default(LastEnqueuedEventProperties)); + + // Refresh partition properties by receiving & check properties + + _ = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + + var properties1 = receiver.ReadLastEnqueuedEventProperties(); + properties1.SequenceNumber.Should().Be(0); + properties1.Offset.Should().Be(0); + + + // Send second event + await producer.SendAsync([new EventData(data)]); + + // Properties after second event without receiving should be same as previously. + + var properties2 = receiver.ReadLastEnqueuedEventProperties(); + properties2.SequenceNumber.Should().Be(0); + properties2.Offset.Should().Be(0); + + _ = await receiver.ReceiveBatchAsync(100, TimeSpan.Zero); + + // Refresh partition properties by receiving & check properties + + var properties3 = receiver.ReadLastEnqueuedEventProperties(); + + properties3.SequenceNumber.Should().Be(1); + properties3.Offset.Should().Be(data.GetLenght()); + + } + +} diff --git a/tests/Tests/Hooks/ExponentialDelayGeneratorTests.cs b/tests/Tests/Hooks/ExponentialDelayGeneratorTests.cs new file mode 100644 index 0000000..7eef822 --- /dev/null +++ b/tests/Tests/Hooks/ExponentialDelayGeneratorTests.cs @@ -0,0 +1,31 @@ +using Spotflow.InMemory.Azure.Hooks; + +namespace Tests.Hooks; + +[TestClass] +public class ExponentialDelayGeneratorTests +{ + [TestMethod] + [DataRow(42)] + [DataRow(43)] + [DataRow(44)] + [DataRow(45)] + [DataRow(46)] + [DataRow(47)] + public void Generated_Delays_For_Default_Setting_Should_Be_In_Range(int seed) + { + var generator = new ExponentialDelayGenerator(seed: seed); + + const int samplesCount = 1000; + + var samples = new double[samplesCount]; + + for (var i = 0; i < samplesCount; i++) + { + samples[i] = generator.Next().TotalSeconds; + } + + samples.Should().AllSatisfy(s => s.Should().BeGreaterThan(0.01)); + samples.Order().Take((int) (0.95 * 1000)).Should().AllSatisfy(s => s.Should().BeLessThan(4.01)); + } +} diff --git a/tests/Tests/KeyVault/SecretClientTests.cs b/tests/Tests/KeyVault/SecretClientTests.cs new file mode 100644 index 0000000..ad53b62 --- /dev/null +++ b/tests/Tests/KeyVault/SecretClientTests.cs @@ -0,0 +1,240 @@ +using Azure; +using Azure.Security.KeyVault.Secrets; + +using Spotflow.InMemory.Azure.KeyVault; +using Spotflow.InMemory.Azure.KeyVault.Secrets; +using Spotflow.InMemory.Azure.KeyVault.Secrets.Hooks.Contexts; + +using Tests.Utils; + +namespace Tests.KeyVault; + +[TestClass] +public class SecretClientTests +{ + [TestMethod] + public void Constructor_ShouldSucceed() + { + var vault = new InMemoryKeyVaultProvider().AddVault(); + + var client = new InMemorySecretClient(vault.VaultUri, vault.Provider); + + client.VaultUri.Should().Be(vault.VaultUri); + } + + + [TestMethod] + public void Constructor_FromVault_ShouldSucceed() + { + var vault = new InMemoryKeyVaultProvider().AddVault(); + + var client = InMemorySecretClient.FromVault(vault); + + client.VaultUri.Should().Be(vault.VaultUri); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void SetSecret_New_Should_Succeed() + { + var client = ImplementationProvider.GetSecretClient(); + + var secretName = Guid.NewGuid().ToString(); + + try + { + var response = client.SetSecret(secretName, "test-value"); + + var version = response.Value.Properties.Version; + + var expectedSecretId = new Uri(client.VaultUri, $"/secrets/{secretName}/{version}"); + + response.Value.Name.Should().Be(secretName); + response.Value.Value.Should().Be("test-value"); + response.Value.Properties.Version.Should().HaveLength(32); + response.Value.Id.Should().Be(expectedSecretId); + response.Value.Properties.CreatedOn.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(10)); + response.Value.Properties.UpdatedOn.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(10)); + response.Value.Properties.ContentType.Should().BeNull(); + response.Value.Properties.Tags.Should().BeEmpty(); + } + finally + { + Task.Run(() => client.StartDeleteSecret(secretName)); + } + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void GetSecret_Missing_Should_Fail() + { + var client = ImplementationProvider.GetSecretClient(); + + var secretName = Guid.NewGuid().ToString(); + + var act = () => client.GetSecret(secretName); + + act.Should() + .Throw() + .Where(e => e.Status == 404) + .Where(e => e.ErrorCode == "SecretNotFound"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void GetSecret_Existing_Should_Return_Latest_Version() + { + var client = ImplementationProvider.GetSecretClient(); + + var secretName = Guid.NewGuid().ToString(); + + try + { + client.SetSecret(secretName, "test-value-1"); + + client.GetSecret(secretName).Value.Value.Should().Be("test-value-1"); + + client.SetSecret(secretName, "test-value-2"); + + client.GetSecret(secretName).Value.Value.Should().Be("test-value-2"); + } + finally + { + Task.Run(() => client.StartDeleteSecret(secretName)); + } + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void GetPropertiesOfSecrets_Should_List_Active_Secrets() + { + var client = ImplementationProvider.GetSecretClient(); + + var secretName = Guid.NewGuid().ToString(); + + try + { + client.SetSecret(secretName, "test-value"); + + client.GetPropertiesOfSecrets().Should().ContainSingle(s => s.Name == secretName); + + client.StartDeleteSecret(secretName).WaitForCompletion(); + + client.GetPropertiesOfSecrets().Should().BeEmpty(); + + } + finally + { + Task.Run(() => client.StartDeleteSecret(secretName)); + } + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void StartDeleteSecret_Should_Make_Secret_Not_Listed() + { + var client = ImplementationProvider.GetSecretClient(); + + var secretName = Guid.NewGuid().ToString(); + + client.SetSecret(secretName, "test-value"); + + var secretsBeforeDelete = client.GetPropertiesOfSecrets().Where(s => s.Name == secretName).ToList(); + + secretsBeforeDelete.Should().ContainSingle(); + + client.StartDeleteSecret(secretName); + + var secretsAfterDelete = client.GetPropertiesOfSecrets().Where(s => s.Name == secretName).ToList(); + + secretsAfterDelete.Should().BeEmpty(); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Get_Disabled_Secret_Should_Fail_But_Set_Should_Succeed() + { + var client = ImplementationProvider.GetSecretClient(); + + var secretName = Guid.NewGuid().ToString(); + + var secret = new KeyVaultSecret(secretName, "test-value"); + secret.Properties.Enabled = false; + + client.SetSecret(secret); + + try + { + var getAct = () => client.GetSecret(secretName); + var setAct = () => client.SetSecret(secretName, "new-value"); + + getAct.Should() + .Throw() + .Where(ex => ex.Status == 403) + .Where(ex => ex.ErrorCode == "Forbidden") + .WithMessage("Operation get is not allowed on a disabled secret.*"); + + setAct.Should().NotThrow(); + + } + finally + { + client.StartDeleteSecret(secretName); + } + + } + + [TestMethod] + public void SetSecret_And_GetSecret_Hooks_Should_Be_Called() + { + var provider = new InMemoryKeyVaultProvider(); + + SetSecretBeforeHookContext? capturedBeforeSet = null; + SetSecretAfterHookContext? capturedAfterSet = null; + GetSecretBeforeHookContext? capturedBeforeGet = null; + GetSecretAfterHookContext? capturedAfterGet = null; + + provider.AddHook(hook => hook.ForSecrets().BeforeSetSecret(ctx => + { + capturedBeforeSet = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(hook => hook.ForSecrets().AfterSetSecret(ctx => + { + capturedAfterSet = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(hook => hook.ForSecrets().BeforeGetSecret(ctx => + { + capturedBeforeGet = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(hook => hook.ForSecrets().AfterGetSecret(ctx => + { + capturedAfterGet = ctx; + return Task.CompletedTask; + })); + + var vault = provider.AddVault(); + + var client = InMemorySecretClient.FromVault(vault); + + client.SetSecret("name", "value"); + client.GetSecret("name"); + + capturedBeforeSet.Should().NotBeNull(); + capturedAfterSet.Should().NotBeNull(); + capturedBeforeGet.Should().NotBeNull(); + capturedAfterGet.Should().NotBeNull(); + + capturedBeforeSet!.Secret.Value.Should().Be("value"); + capturedAfterSet!.CreatedSecret.Properties.Version.Should().MatchRegex("[a-f0-9]{32}"); + capturedAfterGet!.Secret.Value.Should().Be("value"); + + } + +} diff --git a/tests/Tests/ServiceBus/FluentAssertionsTests.cs b/tests/Tests/ServiceBus/FluentAssertionsTests.cs new file mode 100644 index 0000000..5fc3171 --- /dev/null +++ b/tests/Tests/ServiceBus/FluentAssertionsTests.cs @@ -0,0 +1,95 @@ +using Azure.Messaging.ServiceBus; + +using FluentAssertions.Execution; + +using Spotflow.InMemory.Azure.ServiceBus; +using Spotflow.InMemory.Azure.ServiceBus.FluentAssertions; + +namespace Tests.ServiceBus; + +[TestClass] +public class FluentAssertionsTests +{ + [TestMethod] + public async Task New_Queue_Should_Be_Empty() + { + var provider = new InMemoryServiceBusProvider(); + + var queue = provider.AddNamespace().AddQueue("test-queue"); + + await queue.Should().BeEmptyAsync(); + } + + [TestMethod] + public async Task New_Subscription_Should_Be_Empty() + { + var provider = new InMemoryServiceBusProvider(); + + var subscription = provider.AddNamespace().AddTopic("test-queue").AddSubscription("test"); + + await subscription.Should().BeEmptyAsync(); + + } + + [TestMethod] + public async Task Non_Empty_Queue_Should_Not_Be_Empty_And_Then_Become_Empty() + { + var provider = new InMemoryServiceBusProvider(); + + var queue = provider.AddNamespace().AddQueue("test-queue"); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + await using var producerClient = client.CreateSender("test-queue"); + await using var consumerClient = client.CreateReceiver("test-queue"); + + await producerClient.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + + try + { + await queue.Should().BeEmptyAsync(maxWaitTime: TimeSpan.FromMilliseconds(100)); + + throw new AssertionFailedException("Should have thrown an exception"); + } + catch (AssertFailedException ex) + { + ex.Message.Should().Be("Entity \"test-queue\" should be empty but 1L messages found after 0.1 seconds."); + } + + var message = await consumerClient.ReceiveMessageAsync(); + await consumerClient.CompleteMessageAsync(message); + + await queue.Should().BeEmptyAsync(maxWaitTime: TimeSpan.FromMilliseconds(100)); + } + + [TestMethod] + public async Task Non_Empty_Subscription_Should_Not_Be_Empty_And_Then_Become_Empty() + { + var provider = new InMemoryServiceBusProvider(); + + var topic = provider.AddNamespace().AddTopic("test-topic"); + var subscription = topic.AddSubscription("test"); + + await using var client = InMemoryServiceBusClient.FromNamespace(topic.Namespace); + await using var producerClient = client.CreateSender("test-topic"); + await using var consumerClient = client.CreateReceiver("test-topic", "test"); + + await producerClient.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + + try + { + await subscription.Should().BeEmptyAsync(maxWaitTime: TimeSpan.FromMilliseconds(100)); + + throw new AssertionFailedException("Should have thrown an exception"); + } + catch (AssertFailedException ex) + { + ex.Message.Should().Be("Entity \"test-topic/test\" should be empty but 1L messages found after 0.1 seconds."); + } + + var message = await consumerClient.ReceiveMessageAsync(); + await consumerClient.CompleteMessageAsync(message); + + await subscription.Should().BeEmptyAsync(maxWaitTime: TimeSpan.FromMilliseconds(100)); + } + +} diff --git a/tests/Tests/ServiceBus/HooksTests.cs b/tests/Tests/ServiceBus/HooksTests.cs new file mode 100644 index 0000000..991868e --- /dev/null +++ b/tests/Tests/ServiceBus/HooksTests.cs @@ -0,0 +1,258 @@ +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.ServiceBus; +using Spotflow.InMemory.Azure.ServiceBus.Hooks; +using Spotflow.InMemory.Azure.ServiceBus.Hooks.Contexts; + +using Tests.Utils; + +namespace Tests.ServiceBus; + +[TestClass] +public class HooksTests +{ + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Send_Batch_Hooks_Should_Execute() + { + const string namespaceName = "test-namespace"; + const string queueName = "test-queue"; + + var provider = new InMemoryServiceBusProvider(); + var ns = provider.AddNamespace(namespaceName); + ns.AddQueue(queueName); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + await using var sender = client.CreateSender(queueName); + + SendBatchBeforeHookContext? capturedBeforeContext = null; + SendBatchAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForProducer().BeforeSendBatch(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(builder => builder.ForProducer().AfterSendBatch(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + using var batch = await sender.CreateMessageBatchAsync(); + + batch.TryAddMessage(new(BinaryData.FromString("Message 1"))); + + await sender.SendMessagesAsync(batch); + + await using var receiver = client.CreateReceiver(queueName); + + await receiver.ReceiveMessagesAsync(3, TimeSpan.FromMilliseconds(100)); + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.Messages.Should().HaveCount(1); + capturedBeforeContext?.Messages[0].Body.ToString().Should().Be("Message 1"); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.Messages.Should().HaveCount(1); + capturedAfterContext?.Messages[0].Body.ToString().Should().Be("Message 1"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Receive_Batch_Hooks_Should_Execute() + { + const string namespaceName = "test-namespace"; + const string queueName = "test-queue"; + + var provider = new InMemoryServiceBusProvider(); + var ns = provider.AddNamespace(namespaceName); + ns.AddQueue(queueName); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + await using var sender = client.CreateSender(queueName); + + ReceiveBatchBeforeHookContext? capturedBeforeContext = null; + ReceiveBatchAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForConsumer().AfterReceiveBatch(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(builder => builder.ForConsumer().AfterReceiveBatch(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + using var batch = await sender.CreateMessageBatchAsync(); + + batch.TryAddMessage(new(BinaryData.FromString("Message 1"))); + + await sender.SendMessagesAsync(batch); + + await using var receiver = client.CreateReceiver(queueName); + + await receiver.ReceiveMessagesAsync(3, TimeSpan.FromMilliseconds(100)); + + capturedBeforeContext.Should().NotBeNull(); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.Messages.Should().HaveCount(1); + capturedAfterContext?.Messages[0].Body.ToString().Should().Be("Message 1"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Hooks_With_Different_Scope_Should_Not_Execute() + { + const string namespaceName = "test-namespace"; + const string queueName = "test-queue"; + + var provider = new InMemoryServiceBusProvider(); + var ns = provider.AddNamespace(namespaceName); + ns.AddQueue(queueName); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + await using var sender = client.CreateSender(queueName); + + HookFunc failingBeforeHook = _ => throw new InvalidOperationException("This hook should not execute"); + HookFunc failingAfterHook = _ => throw new InvalidOperationException("This hook should not execute"); + + provider.AddHook(builder => builder.ForProducer(serviceBusNamespaceName: "different").BeforeSendBatch(failingBeforeHook)); + provider.AddHook(builder => builder.ForProducer(entityPath: "different").BeforeSendBatch(failingBeforeHook)); + provider.AddHook(builder => builder.ForProducer(serviceBusNamespaceName: "different").AfterSendBatch(failingAfterHook)); + provider.AddHook(builder => builder.ForProducer(entityPath: "different").AfterSendBatch(failingAfterHook)); + + using var batch = await sender.CreateMessageBatchAsync(); + + batch.TryAddMessage(new(BinaryData.FromString("Message 1"))); + + await sender.SendMessagesAsync(batch); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Parent_Hook_Should_Execute() + { + const string namespaceName = "test-namespace"; + const string queueName = "test-queue"; + + var provider = new InMemoryServiceBusProvider(); + var ns = provider.AddNamespace(namespaceName); + ns.AddQueue(queueName); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + await using var sender = client.CreateSender(queueName); + + ServiceBusBeforeHookContext? capturedBeforeContext = null; + ServiceBusAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.Before(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(builder => builder.After(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + using var batch = await sender.CreateMessageBatchAsync(); + + batch.TryAddMessage(new(BinaryData.FromString("Message 1"))); + + await sender.SendMessagesAsync(batch); + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext.Should().BeOfType(); + + ((SendBatchBeforeHookContext) capturedBeforeContext!).Operation.Should().Be(ProducerOperations.SendBatch); + capturedBeforeContext.ServiceBusNamespaceName.Should().Be(namespaceName); + capturedBeforeContext.EntityPath.Should().Be(queueName); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext.Should().BeOfType(); + + ((SendBatchAfterHookContext) capturedAfterContext!).Operation.Should().Be(ProducerOperations.SendBatch); + capturedAfterContext.ServiceBusNamespaceName.Should().Be(namespaceName); + capturedAfterContext.EntityPath.Should().Be(queueName); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Targeted_Hooks_Should_Execute() + { + const string namespaceName = "test-namespace"; + const string queueName = "test-queue"; + + var provider = new InMemoryServiceBusProvider(); + var ns = provider.AddNamespace(namespaceName); + ns.AddQueue(queueName); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + await using var sender = client.CreateSender(queueName); + + ProducerBeforeHookContext? capturedBeforeContext = null; + ProducerAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForProducer().Before(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + }, ProducerOperations.SendBatch)); + + provider.AddHook(builder => builder.ForProducer().After(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + }, ProducerOperations.SendBatch)); + + using var batch = await sender.CreateMessageBatchAsync(); + + batch.TryAddMessage(new(BinaryData.FromString("Message 1"))); + + await sender.SendMessagesAsync(batch); + + capturedBeforeContext.Should().NotBeNull(); + + capturedBeforeContext?.Operation.Should().Be(ProducerOperations.SendBatch); + capturedBeforeContext?.ServiceBusNamespaceName.Should().Be(namespaceName); + capturedBeforeContext?.EntityPath.Should().Be(queueName); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.Operation.Should().Be(ProducerOperations.SendBatch); + capturedAfterContext?.ServiceBusNamespaceName.Should().Be(namespaceName); + capturedAfterContext?.EntityPath.Should().Be(queueName); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Hooks_With_Different_Target_Should_Not_Execute() + { + const string namespaceName = "test-namespace"; + const string queueName = "test-queue"; + + var provider = new InMemoryServiceBusProvider(); + var ns = provider.AddNamespace(namespaceName); + ns.AddQueue(queueName); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + await using var sender = client.CreateSender(queueName); + + provider.AddHook(builder => builder.ForConsumer().Before(_ => throw new InvalidOperationException("This hook should not execute"), ConsumerOperations.ReceiveBatch)); + + provider.AddHook(builder => builder.ForConsumer().After(_ => throw new InvalidOperationException("This hook should not execute"), ConsumerOperations.ReceiveBatch)); + + using var batch = await sender.CreateMessageBatchAsync(); + + batch.TryAddMessage(new(BinaryData.FromString("Message 1"))); + + await sender.SendMessagesAsync(batch); + } +} diff --git a/tests/Tests/ServiceBus/ServiceBusClientTests.cs b/tests/Tests/ServiceBus/ServiceBusClientTests.cs new file mode 100644 index 0000000..84dfe2b --- /dev/null +++ b/tests/Tests/ServiceBus/ServiceBusClientTests.cs @@ -0,0 +1,24 @@ +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.ServiceBus; + +namespace Tests.ServiceBus; + +[TestClass] + +public class ServiceBusClientTests +{ + [TestMethod] + public async Task Constructor_With_Connection_String_Should_Succeed() + { + var serviceBusProvider = new InMemoryServiceBusProvider(); + var queue = serviceBusProvider.AddNamespace().AddQueue("my-queue"); + + await using var client = new InMemoryServiceBusClient(queue.Namespace.CreateConnectionString(), serviceBusProvider); + + client.FullyQualifiedNamespace.Should().Be(queue.Namespace.FullyQualifiedNamespace); + client.IsClosed.Should().BeFalse(); + client.TransportType.Should().Be(ServiceBusTransportType.AmqpTcp); + client.Identifier.Should().NotBeNullOrWhiteSpace(); + } +} diff --git a/tests/Tests/ServiceBus/ServiceBusReceiverTests.cs b/tests/Tests/ServiceBus/ServiceBusReceiverTests.cs new file mode 100644 index 0000000..e25c483 --- /dev/null +++ b/tests/Tests/ServiceBus/ServiceBusReceiverTests.cs @@ -0,0 +1,266 @@ +using Azure.Messaging.ServiceBus; + +using Microsoft.Extensions.Time.Testing; + +using Spotflow.InMemory.Azure.ServiceBus; + +namespace Tests.ServiceBus; + + +[TestClass] +public class ServiceBusReceiverTests +{ + [TestMethod] + public async Task Constructor_For_Queue_Should_Succeed() + { + var ns = new InMemoryServiceBusProvider().AddNamespace(); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + + await using var receiver = new InMemoryServiceBusReceiver(client, "test-queue", options: new() { PrefetchCount = 42 }); + + receiver.ReceiveMode.Should().Be(ServiceBusReceiveMode.PeekLock); + receiver.FullyQualifiedNamespace.Should().Be(ns.FullyQualifiedNamespace); + receiver.EntityPath.Should().Be("test-queue"); + receiver.Identifier.Should().NotBeNullOrWhiteSpace(); + receiver.IsClosed.Should().BeFalse(); + receiver.PrefetchCount.Should().Be(42); + } + + + [TestMethod] + public async Task Constructor_For_Topic_Subscription_Should_Succeed() + { + var ns = new InMemoryServiceBusProvider().AddNamespace(); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + + await using var receiver = new InMemoryServiceBusReceiver(client, "test-topic", "test-subscription", options: new() { PrefetchCount = 42 }); + + receiver.ReceiveMode.Should().Be(ServiceBusReceiveMode.PeekLock); + receiver.FullyQualifiedNamespace.Should().Be(ns.FullyQualifiedNamespace); + receiver.EntityPath.Should().Be("test-topic/subscriptions/test-subscription"); + receiver.Identifier.Should().NotBeNullOrWhiteSpace(); + receiver.IsClosed.Should().BeFalse(); + receiver.PrefetchCount.Should().Be(42); + } + + [TestMethod] + public async Task Queue_Receiver_Should_Receive_Message_Sent_After_Receive_Operation_Started() + { + var provider = new InMemoryServiceBusProvider(); + + var queue = provider.AddNamespace().AddQueue("test-queue"); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + + await using var sender = client.CreateSender("test-queue"); + await using var receiver = client.CreateReceiver("test-queue"); + + var receivedMessageTask = receiver.ReceiveMessageAsync(); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + + var receivedMessage = await receivedMessageTask; + + receivedMessage.Body.ToString().Should().Be("Hello, world!"); + } + + [TestMethod] + public async Task Topic_Receivers_Should_Receive_Message_Sent_After_Receive_Operation_Started() + { + var provider = new InMemoryServiceBusProvider(); + + var topic = provider.AddNamespace().AddTopic("test-topic"); + + topic.AddSubscription("subscription-1"); + topic.AddSubscription("subscription-2"); + + await using var client = InMemoryServiceBusClient.FromNamespace(topic.Namespace); + + await using var sender = client.CreateSender("test-topic"); + await using var receiver1 = client.CreateReceiver("test-topic", "subscription-1"); + await using var receiver2 = client.CreateReceiver("test-topic", "subscription-2"); + + var receivedMessageTask1 = receiver1.ReceiveMessageAsync(); + var receivedMessageTask2 = receiver2.ReceiveMessageAsync(); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + + var receivedMessage1 = await receivedMessageTask1; + var receivedMessage2 = await receivedMessageTask2; + + receivedMessage1.Body.ToString().Should().Be("Hello, world!"); + receivedMessage2.Body.ToString().Should().Be("Hello, world!"); + + } + + [TestMethod] + public async Task Abandoned_Message_Should_Be_Recieved_Again() + { + var provider = new InMemoryServiceBusProvider(); + + var queue = provider.AddNamespace().AddQueue("test-queue"); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + + await using var sender = client.CreateSender("test-queue"); + await using var receiver = client.CreateReceiver("test-queue"); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + + var receivedMessage = await receiver.ReceiveMessageAsync(); + + var additionalReceivedMessagesBeforeAbandon = await receiver.ReceiveMessagesAsync(1, TimeSpan.FromMilliseconds(100)); + + additionalReceivedMessagesBeforeAbandon.Should().BeEmpty(); + + await receiver.AbandonMessageAsync(receivedMessage); + + var additionalReceivedMessagesAfterAbandon = await receiver.ReceiveMessagesAsync(1, TimeSpan.FromMinutes(1)); + + additionalReceivedMessagesAfterAbandon.Should().HaveCount(1); + additionalReceivedMessagesAfterAbandon[0].Body.ToString().Should().Be("Hello, world!"); + } + + [TestMethod] + public async Task Completed_Message_Should_Not_Be_Recieved_Again() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryServiceBusProvider(timeProvider); + + var queue = provider.AddNamespace().AddQueue("test-queue"); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + + await using var sender = client.CreateSender("test-queue"); + await using var receiver = client.CreateReceiver("test-queue"); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + + var message = await receiver.ReceiveMessageAsync(); + + await receiver.CompleteMessageAsync(message); + + timeProvider.Advance(TimeSpan.FromHours(1)); + + var messagesAfterComplete = await receiver.ReceiveMessagesAsync(1, TimeSpan.FromMilliseconds(100)); + + messagesAfterComplete.Should().BeEmpty(); + + } + + [TestMethod] + public async Task Expired_Message_Cannot_Be_Completed() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryServiceBusProvider(timeProvider); + + var queue = provider.AddNamespace().AddQueue("test-queue", new() { LockTime = TimeSpan.FromMinutes(2) }); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + + await using var sender = client.CreateSender("test-queue"); + await using var receiver = client.CreateReceiver("test-queue"); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + + var message = await receiver.ReceiveMessageAsync(); + + timeProvider.Advance(TimeSpan.FromMinutes(3)); + + var act = () => receiver.CompleteMessageAsync(message); + + await act.Should() + .ThrowAsync() + .Where(ex => ex.Reason == ServiceBusFailureReason.MessageLockLost); + } + + + [TestMethod] + public async Task Message_Count_On_Queue_Should_Be_Reported() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryServiceBusProvider(timeProvider); + + var queue = provider.AddNamespace().AddQueue("test-queue", new() { LockTime = TimeSpan.FromMinutes(2) }); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + + await using var sender = client.CreateSender("test-queue"); + await using var receiver = client.CreateReceiver("test-queue"); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + + queue.ActiveMessageCount.Should().Be(2); + queue.MessageCount.Should().Be(2); + + var message = await receiver.ReceiveMessageAsync(); + + queue.ActiveMessageCount.Should().Be(1); + queue.MessageCount.Should().Be(2); + + await receiver.CompleteMessageAsync(message); + + queue.ActiveMessageCount.Should().Be(1); + queue.MessageCount.Should().Be(1); + + _ = await receiver.ReceiveMessageAsync(); + + queue.ActiveMessageCount.Should().Be(0); + queue.MessageCount.Should().Be(1); + + timeProvider.Advance(TimeSpan.FromMinutes(3)); + + queue.ActiveMessageCount.Should().Be(1); + queue.MessageCount.Should().Be(1); + } + + [TestMethod] + public async Task Message_Count_On_Topic_Subscription_Should_Be_Reported() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryServiceBusProvider(timeProvider); + + var topic = provider.AddNamespace().AddTopic("test-topic"); + + var subscription = topic.AddSubscription("test-subscription", new() { LockTime = TimeSpan.FromMinutes(2) }); + + await using var client = InMemoryServiceBusClient.FromNamespace(topic.Namespace); + + await using var sender = client.CreateSender("test-topic"); + await using var receiver = client.CreateReceiver("test-topic", "test-subscription"); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!"))); + + subscription.ActiveMessageCount.Should().Be(2); + subscription.MessageCount.Should().Be(2); + + var message = await receiver.ReceiveMessageAsync(); + + subscription.ActiveMessageCount.Should().Be(1); + subscription.MessageCount.Should().Be(2); + + await receiver.CompleteMessageAsync(message); + + subscription.ActiveMessageCount.Should().Be(1); + subscription.MessageCount.Should().Be(1); + + _ = await receiver.ReceiveMessageAsync(); + + subscription.ActiveMessageCount.Should().Be(0); + subscription.MessageCount.Should().Be(1); + + timeProvider.Advance(TimeSpan.FromMinutes(3)); + + subscription.ActiveMessageCount.Should().Be(1); + subscription.MessageCount.Should().Be(1); + } + +} diff --git a/tests/Tests/ServiceBus/ServiceBusSenderTests.cs b/tests/Tests/ServiceBus/ServiceBusSenderTests.cs new file mode 100644 index 0000000..ab4787c --- /dev/null +++ b/tests/Tests/ServiceBus/ServiceBusSenderTests.cs @@ -0,0 +1,122 @@ +using Azure.Messaging.ServiceBus; + +using Spotflow.InMemory.Azure.ServiceBus; + +using Tests.Utils; + +namespace Tests.ServiceBus; + +[TestClass] +public class ServiceBusSenderTests +{ + [TestMethod] + public async Task Constructor_Should_Succeed() + { + var ns = new InMemoryServiceBusProvider().AddNamespace(); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + + await using var sender = new InMemoryServiceBusSender(client, "test-queue"); + + sender.FullyQualifiedNamespace.Should().Be(ns.FullyQualifiedNamespace); + sender.EntityPath.Should().Be("test-queue"); + sender.Identifier.Should().NotBeNullOrWhiteSpace(); + sender.IsClosed.Should().BeFalse(); + } + + [TestMethod] + public async Task Send_Batch_Should_Succeed() + { + var provider = new InMemoryServiceBusProvider(); + var ns = provider.AddNamespace(); + var queue = ns.AddQueue("test-queue"); + + await using var client = InMemoryServiceBusClient.FromNamespace(ns); + + await using var sender = client.CreateSender("test-queue"); + + using var batch = await sender.CreateMessageBatchAsync(); + + batch.TryAddMessage(new ServiceBusMessage(BinaryData.FromString("Message 1"))); + batch.TryAddMessage(new ServiceBusMessage(BinaryData.FromString("Message 2"))); + batch.TryAddMessage(new ServiceBusMessage(BinaryData.FromString("Message 3"))); + + await sender.SendMessagesAsync(batch); + + await using var receiver = client.CreateReceiver("test-queue"); + + var messages = await receiver.ReceiveMessagesAsync(3, TimeSpan.FromMilliseconds(100)); + + messages.Select(m => m.Body.ToString()).Should().BeEquivalentTo(["Message 1", "Message 2", "Message 3"]); + messages.Select(m => m.SequenceNumber).Should().BeEquivalentTo([0, 1, 2]); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Missing_Namespace_Should_Throw() + { + await using var sender = await ImplementationProvider.GetServiceBusSenderAsync(missingNamespace: true); + + var act = () => sender.SendMessageAsync(new ServiceBusMessage()); + + await act.Should() + .ThrowAsync() + .Where(ex => ex.Reason == ServiceBusFailureReason.ServiceCommunicationProblem) + .Where(ex => ex.IsTransient == true) + .Where(ex => ex.Message.Contains("ErrorCode: HostNotFound")); + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(true, DisplayName = "Topic")] + [DataRow(false, DisplayName = "Queue")] + public async Task Send_To_Missing_Entity_Should_Fail(bool useTopics) + { + await using var sender = await ImplementationProvider.GetServiceBusSenderAsync(missingEntity: true, useTopics: useTopics); + + var act = () => sender.SendMessageAsync(new ServiceBusMessage()); + + await act.Should() + .ThrowAsync() + .Where(ex => ex.Reason == ServiceBusFailureReason.MessagingEntityNotFound) + .Where(ex => ex.IsTransient == false); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Send_To_Session_Enabled_Queue_Without_Session_Id_Should_Fail() + { + await using var sender = await ImplementationProvider.GetServiceBusSenderAsync(withSessions: true); + + var message = new ServiceBusMessage(); + + var act = () => sender.SendMessageAsync(message); + + var expectedMessagePrefix = "" + + "The SessionId was not set on a message, " + + "and it cannot be sent to the entity. " + + "Entities that have session support enabled can " + + "only receive messages that have the SessionId set to a valid value."; + + await act.Should() + .ThrowAsync() + .Where(ex => ex.Message.StartsWith(expectedMessagePrefix)); + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Send_To_Session_Disabled_Queue_With_Session_Id_Should_Succeed() + { + await using var sender = await ImplementationProvider.GetServiceBusSenderAsync(); + + var message = new ServiceBusMessage { SessionId = "test" }; + + var act = () => sender.SendMessageAsync(message); + + await act.Should().NotThrowAsync(); + } + +} diff --git a/tests/Tests/ServiceBus/ServiceBusSessionsReceiverTests.cs b/tests/Tests/ServiceBus/ServiceBusSessionsReceiverTests.cs new file mode 100644 index 0000000..7768953 --- /dev/null +++ b/tests/Tests/ServiceBus/ServiceBusSessionsReceiverTests.cs @@ -0,0 +1,341 @@ +using Azure.Messaging.ServiceBus; + +using Microsoft.Extensions.Time.Testing; + +using Spotflow.InMemory.Azure.ServiceBus; + +namespace Tests.ServiceBus; + +[TestClass] +public class ServiceBusSessionsReceiverTests +{ + + [TestMethod] + public async Task Existing_Sessions_In_Queue_Should_Be_Directly_Received() + { + var provider = new InMemoryServiceBusProvider(); + + var queue = provider.AddNamespace().AddQueue("test-queue", new() { EnableSessions = true }); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + + await using var sender = client.CreateSender("test-queue"); + + var message1 = new ServiceBusMessage(BinaryData.FromString("Message 1")) { SessionId = "session-1" }; + var message2 = new ServiceBusMessage(BinaryData.FromString("Message 2")) { SessionId = "session-2" }; + + await sender.SendMessagesAsync([message1, message2]); + + await using var sessionReceiver1 = await client.AcceptSessionAsync("test-queue", "session-1"); + await using var sessionReceiver2 = await client.AcceptSessionAsync("test-queue", "session-2"); + + sessionReceiver1.SessionId.Should().Be("session-1"); + sessionReceiver1.EntityPath.Should().Be("test-queue"); + sessionReceiver1.Identifier.Should().NotBeNullOrWhiteSpace(); + sessionReceiver2.SessionId.Should().Be("session-2"); + sessionReceiver2.EntityPath.Should().Be("test-queue"); + sessionReceiver2.Identifier.Should().NotBeNullOrWhiteSpace(); + + var receivedMessage1 = await sessionReceiver1.ReceiveMessageAsync(); + var receivedMessage2 = await sessionReceiver2.ReceiveMessageAsync(); + + receivedMessage1.Body.ToString().Should().Be("Message 1"); + receivedMessage2.Body.ToString().Should().Be("Message 2"); + } + + [TestMethod] + public async Task Existing_Sessions_In_Topic_Should_Be_Directly_Received() + { + var provider = new InMemoryServiceBusProvider(); + + var topic = provider.AddNamespace().AddTopic("test-topic"); + + topic.AddSubscription("sub", new() { EnableSessions = true }); + + + await using var client = InMemoryServiceBusClient.FromNamespace(topic.Namespace); + + await using var sender = client.CreateSender("test-topic"); + + var message1 = new ServiceBusMessage(BinaryData.FromString("Message 1")) { SessionId = "session-1" }; + var message2 = new ServiceBusMessage(BinaryData.FromString("Message 2")) { SessionId = "session-2" }; + + await sender.SendMessagesAsync([message1, message2]); + + await using var sessionReceiver1 = await client.AcceptSessionAsync("test-topic", "sub", "session-1"); + await using var sessionReceiver2 = await client.AcceptSessionAsync("test-topic", "sub", "session-2"); + + sessionReceiver1.SessionId.Should().Be("session-1"); + sessionReceiver1.EntityPath.Should().Be("test-topic/subscriptions/sub"); + sessionReceiver1.Identifier.Should().NotBeNullOrWhiteSpace(); + sessionReceiver2.SessionId.Should().Be("session-2"); + sessionReceiver2.EntityPath.Should().Be("test-topic/subscriptions/sub"); + sessionReceiver2.Identifier.Should().NotBeNullOrWhiteSpace(); + + var receivedMessage1 = await sessionReceiver1.ReceiveMessageAsync(); + var receivedMessage2 = await sessionReceiver2.ReceiveMessageAsync(); + + receivedMessage1.Body.ToString().Should().Be("Message 1"); + receivedMessage2.Body.ToString().Should().Be("Message 2"); + } + + [TestMethod] + public async Task Empty_Session_Is_Not_Available() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryServiceBusProvider(timeProvider); + + var queue = provider.AddNamespace().AddQueue("test-queue", new() { EnableSessions = true }); + + var clientOptions = new ServiceBusClientOptions { RetryOptions = new() { MaxDelay = TimeSpan.FromMinutes(3) } }; + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace, clientOptions); + + await using var sender = client.CreateSender("test-queue"); + + await sender.SendMessageAsync(new(BinaryData.FromString("Test Message")) { SessionId = "session-1" }); + + await using (var sessionReceiver = await client.AcceptSessionAsync("test-queue", "session-1")) + { + var receivedMessage = await sessionReceiver.ReceiveMessageAsync(); + + receivedMessage.Body.ToString().Should().Be("Test Message"); + + await sessionReceiver.CompleteMessageAsync(receivedMessage); + } + + var withSessionIdAct = () => client.AcceptSessionAsync("test-queue", "session-1"); + + var withoutSessionIdAct = async () => + { + var task = client.AcceptNextSessionAsync("test-queue"); + + while (!task.IsCompleted) + { + timeProvider.Advance(TimeSpan.FromMinutes(1)); + await Task.Delay(100); + } + + await task; + }; + + await withSessionIdAct.Should() + .ThrowAsync() + .Where(e => e.Reason == ServiceBusFailureReason.GeneralError); + + await withoutSessionIdAct.Should() + .ThrowAsync() + .Where(e => e.Reason == ServiceBusFailureReason.ServiceTimeout); + } + + [TestMethod] + public async Task Session_Should_BeRelease_And_Reacquired() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryServiceBusProvider(timeProvider); + + var queue = provider.AddNamespace().AddQueue("test-queue", new() { EnableSessions = true }); + + var clientOptions = new ServiceBusClientOptions { RetryOptions = new() { MaxDelay = TimeSpan.FromMinutes(3) } }; + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace, clientOptions); + + await using var sender = client.CreateSender("test-queue"); + + var message1 = new ServiceBusMessage(BinaryData.FromString("Test Message 1")) { SessionId = "session-1" }; + var message2 = new ServiceBusMessage(BinaryData.FromString("Test Message 2")) { SessionId = "session-1" }; + + await sender.SendMessagesAsync([message1, message2]); + + await using (var sessionReceiver = await client.AcceptNextSessionAsync("test-queue")) + { + var receivedMessage = await sessionReceiver.ReceiveMessageAsync(); + + receivedMessage.Body.ToString().Should().Be("Test Message 1"); + + await sessionReceiver.CompleteMessageAsync(receivedMessage); + + var acceptAgain = async () => + { + var task = client.AcceptNextSessionAsync("test-queue"); + + while (!task.IsCompleted) + { + timeProvider.Advance(TimeSpan.FromMinutes(4)); + await Task.Delay(100); + } + + await task; + }; + + await acceptAgain.Should() + .ThrowAsync() + .Where(e => e.Reason == ServiceBusFailureReason.ServiceTimeout); + + } + + await using (var sessionReceiver = await client.AcceptNextSessionAsync("test-queue")) + { + var receivedMessage = await sessionReceiver.ReceiveMessageAsync(); + + receivedMessage.Body.ToString().Should().Be("Test Message 2"); + } + + } + + + [TestMethod] + public async Task Lost_Session_Lock_Should_Cause_All_Operations_To_Fail() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryServiceBusProvider(timeProvider); + + var queue = provider.AddNamespace().AddQueue("test-queue", new() { EnableSessions = true, LockTime = TimeSpan.FromMinutes(2) }); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + + await using var sender = client.CreateSender("test-queue"); + + var message = new ServiceBusMessage(BinaryData.FromString("Test Message")) { SessionId = "session-1" }; + + await sender.SendMessageAsync(message); + + await using var sessionReceiver = await client.AcceptNextSessionAsync("test-queue"); + + var receivedMessage = await sessionReceiver.ReceiveMessageAsync(); + + receivedMessage.Body.ToString().Should().Be("Test Message"); + + timeProvider.Advance(TimeSpan.FromMinutes(3)); + + var receiveAct = () => sessionReceiver.ReceiveMessageAsync(); + var completeAct = () => sessionReceiver.CompleteMessageAsync(receivedMessage); + var renewAct = () => sessionReceiver.RenewSessionLockAsync(); + var abandonAct = () => sessionReceiver.AbandonMessageAsync(receivedMessage); + + await receiveAct.Should() + .ThrowAsync() + .Where(e => e.Reason == ServiceBusFailureReason.SessionLockLost); + + await completeAct.Should() + .ThrowAsync() + .Where(e => e.Reason == ServiceBusFailureReason.SessionLockLost); + + await renewAct.Should() + .ThrowAsync() + .Where(e => e.Reason == ServiceBusFailureReason.SessionLockLost); + + await abandonAct.Should() + .ThrowAsync() + .Where(e => e.Reason == ServiceBusFailureReason.SessionLockLost); + + } + + [TestMethod] + public async Task Expired_Message_Cannot_Be_Completed() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryServiceBusProvider(timeProvider); + + var queue = provider.AddNamespace().AddQueue("test-queue", new() { EnableSessions = true, LockTime = TimeSpan.FromMinutes(2) }); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + + await using var sender = client.CreateSender("test-queue"); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!")) { SessionId = "session-1" }); + + await using var receiver = await client.AcceptNextSessionAsync("test-queue"); + + var message = await receiver.ReceiveMessageAsync(); + + timeProvider.Advance(TimeSpan.FromMinutes(1)); + + await receiver.RenewSessionLockAsync(); + + timeProvider.Advance(TimeSpan.FromMinutes(1.5)); + + var act = () => receiver.CompleteMessageAsync(message); + + await act.Should() + .ThrowAsync() + .Where(ex => ex.Reason == ServiceBusFailureReason.MessageLockLost); + } + + [TestMethod] + public async Task Session_State_Can_Be_Set_And_Get() + { + var provider = new InMemoryServiceBusProvider(); + + var queue = provider.AddNamespace().AddQueue("test-queue", new() { EnableSessions = true, LockTime = TimeSpan.FromMinutes(2) }); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace); + + await using var sender = client.CreateSender("test-queue"); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!")) { SessionId = "session-1" }); + + await using var receiver = await client.AcceptNextSessionAsync("test-queue"); + + await receiver.SetSessionStateAsync(BinaryData.FromString("Session State")); + + var sessionState = await receiver.GetSessionStateAsync(); + + sessionState.ToString().Should().Be("Session State"); + + } + + [TestMethod] + public async Task Session_Should_Become_Available_When_Message_Lock_Expires() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryServiceBusProvider(timeProvider); + + var queue = provider.AddNamespace().AddQueue("test-queue", new() { EnableSessions = true, LockTime = TimeSpan.FromMinutes(2) }); + + await using var client = InMemoryServiceBusClient.FromNamespace(queue.Namespace, new() { RetryOptions = new() { MaxDelay = TimeSpan.FromSeconds(16) } }); + + await using var sender = client.CreateSender("test-queue"); + + await sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromString("Hello, world!")) { SessionId = "session-1" }); + + await using var receiver1 = await client.AcceptNextSessionAsync("test-queue"); + + var message1 = await receiver1.ReceiveMessageAsync(); + + message1.Body.ToString().Should().Be("Hello, world!"); + + var acceptAgain = async () => + { + var task = client.AcceptNextSessionAsync("test-queue"); + + while (!task.IsCompleted) + { + timeProvider.Advance(TimeSpan.FromSeconds(18)); + await Task.Delay(100); + } + await task; + }; + + await acceptAgain.Should() + .ThrowAsync() + .Where(e => e.Reason == ServiceBusFailureReason.ServiceTimeout); + + await receiver1.DisposeAsync(); + + timeProvider.Advance(TimeSpan.FromMinutes(2)); + + await using var receiver2 = await client.AcceptNextSessionAsync("test-queue"); + + var message2 = await receiver2.ReceiveMessageAsync(); + + message2.Body.ToString().Should().Be("Hello, world!"); + + } + +} diff --git a/tests/Tests/Storage/Blobs/BlobClientTests.cs b/tests/Tests/Storage/Blobs/BlobClientTests.cs new file mode 100644 index 0000000..a112b1c --- /dev/null +++ b/tests/Tests/Storage/Blobs/BlobClientTests.cs @@ -0,0 +1,314 @@ +using System.Text; + +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Blobs.Specialized; + +using Tests.Utils; + +namespace Tests.Storage.Blobs; + +[TestClass] +public class BlobClientTests +{ + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void Exists_For_Existing_Blob_Should_Return_True(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + Upload(blobClient, "Hello, World!"); + + blobClient.Exists().Value.Should().BeTrue(); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void Exists_For_Missing_Blob_Should_Return_False(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + blobClient.Exists().Value.Should().BeFalse(); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void Upload_To_Non_Existing_Blob_With_ETag_Should_Fail(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + var options = new BlobUploadOptions + { + Conditions = new BlobRequestConditions + { + IfMatch = new ETag(Guid.NewGuid().ToString()) + } + }; + + var act = () => Upload(blobClient, "Hello, World!", options); + + act + .Should() + .Throw() + .Where(e => e.Status == 412) + .Where(e => e.ErrorCode == "ConditionNotMet"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void Upload_To_Existing_With_IfNoneMatch_All_Should_Fail(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + var options = new BlobUploadOptions + { + Conditions = new BlobRequestConditions + { + IfNoneMatch = ETag.All + } + }; + + var act = () => Upload(blobClient, "Hello, World!", options); + + act.Should().NotThrow(); + + act + .Should() + .Throw() + .Where(e => e.Status == 409) + .Where(e => e.ErrorCode == "BlobAlreadyExists"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void OpenWrite_And_Dispose_Should_Create_Blob(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + using (var stream = OpenWrite(blobClient, true)) + using (var streamWriter = new StreamWriter(stream)) + { + + blobClient.DownloadContent().Value.Content.ToString().Should().BeEmpty(); + + streamWriter.Write("test-data1\n"); + streamWriter.Write("test-data2\n"); + } + + blobClient.DownloadContent().Value.Content.ToString().Should().Be("test-data1\ntest-data2\n"); + + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void OpenWrite_Without_Overwrite_Option_Should_Be_Unsupported(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + var act = () => OpenWrite(blobClient, false); + + act.Should() + .Throw() + .WithMessage("BlockBlobClient.OpenWrite only supports overwriting"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void Download_Streaming_For_Non_Existing_Blob_Should_Fail(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + var act = () => blobClient.DownloadStreaming(); + + act + .Should() + .Throw() + .Where(e => e.Status == 404) + .Where(e => e.ErrorCode == "BlobNotFound"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void Delete_Existing_Blob_Should_Succeed(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + Upload(blobClient, "Hello, World!"); + + var ifExistsResponse = blobClient.DeleteIfExists(); + + ifExistsResponse.Value.Should().BeTrue(); + ifExistsResponse.GetRawResponse().Status.Should().Be(202); + + Upload(blobClient, "Hello, World!"); + + var response = blobClient.Delete(); + + response.Status.Should().Be(202); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void Delete_Missings_Blob_Should_Fail(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + var ifExistsResponse = blobClient.DeleteIfExists(); + + ifExistsResponse.Value.Should().BeFalse(); + ifExistsResponse.GetRawResponse().Should().BeNull(); + + var act = () => blobClient.Delete(); + + act + .Should() + .Throw() + .Where(e => e.Status == 404) + .Where(e => e.ErrorCode == "BlobNotFound"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(BlobClientType.Generic)] + [DataRow(BlobClientType.Block)] + public void GetProperties_For_Existing_Blob_Should_Succeed(BlobClientType clientType) + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = GetClient(containerClient, blobName, clientType); + + Upload(blobClient, "Hello, World!"); + + var properties = blobClient.GetProperties().Value; + + properties.BlobType.Should().Be(BlobType.Block); + } + + public enum BlobClientType + { + Generic, + Block + } + + private static BlobBaseClient GetClient(BlobContainerClient containerClient, string blobName, BlobClientType type) + { + return type switch + { + BlobClientType.Generic => containerClient.GetBlobClient(blobName), + BlobClientType.Block => containerClient.GetBlockBlobClient(blobName), + }; + } + + private static void Upload(BlobBaseClient blobClient, string content, BlobUploadOptions? options = null) + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + + if (blobClient is BlobClient genericClient) + { + genericClient.Upload(stream, options: options); + } + else if (blobClient is BlockBlobClient blockClient) + { + blockClient.Upload(stream, options: options); + } + else + { + throw new InvalidOperationException(); + } + } + + private static Stream OpenWrite(BlobBaseClient blobClient, bool overwrite) + { + return blobClient switch + { + BlobClient genericClient => genericClient.OpenWrite(overwrite), + BlockBlobClient blockClient => blockClient.OpenWrite(overwrite), + _ => throw new InvalidOperationException() + }; + } + + +} diff --git a/tests/Tests/Storage/Blobs/BlobClientTests_BlockBlobClient.cs b/tests/Tests/Storage/Blobs/BlobClientTests_BlockBlobClient.cs new file mode 100644 index 0000000..9a7353e --- /dev/null +++ b/tests/Tests/Storage/Blobs/BlobClientTests_BlockBlobClient.cs @@ -0,0 +1,484 @@ +using System.Text; + +using Azure; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Blobs.Specialized; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; +using Spotflow.InMemory.Azure.Storage.Resources; + +using Tests.Utils; + +namespace Tests.Storage.Blobs; + +[TestClass] +public class BlobClientTests_BlockBlobClient +{ + [TestMethod] + public void Constructor_With_Connection_String_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var connectionString = account.CreateConnectionString(); + + var client = new InMemoryBlockBlobClient(connectionString, "test-container", "test-blob", provider); + + AssertClientProperties(client, "test-container", "test-blob", account); + } + + [TestMethod] + public void Constructor_With_Uri_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var client = new InMemoryBlockBlobClient(account.CreateBlobSasUri("test-container", "test-blob"), provider); + + AssertClientProperties(client, "test-container", "test-blob", account); + } + + [TestMethod] + public void Constructor_With_Uri_Without_Container_Should_Fail() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var act = () => new InMemoryBlockBlobClient(account.BlobServiceUri, provider); + + act.Should() + .Throw() + .WithMessage("Blob container name must be specified when creating a blob client."); + } + + [TestMethod] + public void Constructor_With_Uri_Without_Blob_Should_Fail() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var act = () => new InMemoryBlockBlobClient(account.CreateBlobContainerSasUri("test"), provider); + + act.Should() + .Throw() + .WithMessage("Blob name must be specified when creating a blob client."); + } + + [TestMethod] + public void Construct_From_Account_Should_Succeed() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var client = InMemoryBlockBlobClient.FromAccount(account, "test-container", "test-blob"); + + AssertClientProperties(client, "test-container", "test-blob", account); + } + + private static void AssertClientProperties( + InMemoryBlockBlobClient client, + string expectedContainerName, + string expectedBlobName, + InMemoryStorageAccount account) + { + var expectedUri = new Uri(account.BlobServiceUri, $"{expectedContainerName}/{expectedBlobName}"); + + client.Uri.Should().Be(expectedUri); + client.AccountName.Should().Be(account.Name); + client.BlobContainerName.Should().Be(expectedContainerName); + client.Name.Should().Be(expectedBlobName); + client.CanGenerateSasUri.Should().BeFalse(); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void StageBlock_With_Invalid_Id_Should_Be_Rejected() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = "test-block-id"; + + var act = () => blobClient.StageBlock(blockId, BinaryData.FromString("test-data").ToStream()); + + act.Should() + .Throw() + .Where(e => e.Status == 400) + .Where(e => e.ErrorCode == "InvalidQueryParameterValue") + .Where(e => AssertHasInvalidBlockIdData(e, blockId)); + + } + + private static bool AssertHasInvalidBlockIdData(RequestFailedException ex, string actualBlockId) + { + ex.Data["QueryParameterName"].Should().Be("blockid"); + ex.Data["QueryParameterValue"].Should().Be(actualBlockId); + ex.Data["Reason"].Should().Be("Not a valid base64 string."); + + return true; + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Exists_For_Blob_With_Uncommited_Blocks_Only_Should_Be_False() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-block-id")); + + blobClient.StageBlock(blockId, BinaryData.FromString("test-data").ToStream()); + + blobClient.Exists().Value.Should().BeFalse(); + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void StageBlock_Without_Commit_Should_Not_Cause_Overwrite() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + using var content = new MemoryStream(Encoding.UTF8.GetBytes("test-data-1")); + + blobClient.Upload(content); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-block-id")); + + blobClient.StageBlock(blockId, BinaryData.FromString("test-data-2").ToStream()); + + blobClient.DownloadContent().Value.Content.ToString().Should().Be("test-data-1"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Stage_Block_And_Commit_Should_Create_Blob_With_Commited_Blocks() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-block-id")); + + blobClient + .StageBlock(blockId, BinaryData.FromString("test-data").ToStream()) + .GetRawResponse() + .Status + .Should() + .Be(201); + + blobClient.CommitBlockList([blockId]); + + var blockList = blobClient.GetBlockList().Value; + + blockList.CommittedBlocks.Should().ContainSingle(block => block.Name == blockId); + blockList.UncommittedBlocks.Should().BeEmpty(); + + blobClient.DownloadContent().Value.Content.ToString().Should().Be("test-data"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void CommitBlockList_With_Existing_Blocks_Should_Create_Blob_And_Clear_Uncommited_Blocks() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-block-id")); + + blobClient.StageBlock(blockId, BinaryData.FromString("test-data").ToStream()); + + blobClient.GetBlockList().Value.UncommittedBlocks.Should().ContainSingle(b => b.Name == blockId); + + blobClient.CommitBlockList([]); + + var blockList = blobClient.GetBlockList().Value; + + blockList.CommittedBlocks.Should().BeEmpty(); + blockList.UncommittedBlocks.Should().BeEmpty(); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void CommitBlockList_With_No_Blocks_Should_Create_Empty_Blob() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + blobClient.CommitBlockList([]); + + blobClient.Exists().Value.Should().BeTrue(); + + var blockList = blobClient.GetBlockList().Value; + + blockList.CommittedBlocks.Should().BeEmpty(); + blockList.UncommittedBlocks.Should().BeEmpty(); + + blobClient.DownloadContent().Value.Content.ToMemory().Length.Should().Be(0); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void CommitBlockList_Should_Set_Properties_And_Headers() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-block-id")); + + blobClient.StageBlock(blockId, BinaryData.FromString("test-data").ToStream()); + + blobClient.CommitBlockList( + [blockId], + new BlobHttpHeaders + { + ContentType = "test/test", + ContentEncoding = "gzip" + }, + new Dictionary { { "metadata1", "42" } } + ); + + + var blockList = blobClient.GetBlockList().Value; + + blockList.CommittedBlocks.Should().ContainSingle(block => block.Name == blockId); + blockList.UncommittedBlocks.Should().BeEmpty(); + + var properties = blobClient.GetProperties().Value; + + properties.Metadata.Should().Contain("metadata1", "42"); + properties.ContentType.Should().Be("test/test"); + properties.ContentEncoding.Should().Be("gzip"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void CommitBlockList_With_Blocks_To_Non_Existent_Blob_Should_Fail() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("1")); + + var act = () => blobClient.CommitBlockList([blockId]); + + act + .Should() + .Throw() + .Where(e => e.Status == 400) + .Where(e => e.ErrorCode == "InvalidBlockList"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void CommitBlockList_With_Missing_Block_To_Existing_Blob_Should_Fail() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId1 = Convert.ToBase64String(Encoding.UTF8.GetBytes("1")); + var blockId2 = Convert.ToBase64String(Encoding.UTF8.GetBytes("2")); + + using var content = new MemoryStream(Encoding.UTF8.GetBytes("Hello, World!")); + + blobClient.StageBlock(blockId1, content); + + var act = () => blobClient.CommitBlockList([blockId1, blockId2]); + + act + .Should() + .Throw() + .Where(e => e.Status == 400) + .Where(e => e.ErrorCode == "InvalidBlockList"); + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void CommitBlockList_To_Existing_Blob_With_IfNoneMatch_All_Should_Fail() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("1")); + + using var content = new MemoryStream(Encoding.UTF8.GetBytes("Hello, World!")); + + blobClient.StageBlock(blockId, content); + + var act = () => blobClient.CommitBlockList([blockId], options: new() { Conditions = new() { IfNoneMatch = ETag.All } }); + + act.Should().NotThrow(); + + act + .Should() + .Throw() + .Where(e => e.Status == 409) + .Where(e => e.ErrorCode == "BlobAlreadyExists"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void GetBlockList_For_Blob_With_Uncommited_Blocks_Only_Should_Return_Uncommited_Blocks() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-block-id")); + + blobClient.StageBlock(blockId, BinaryData.FromString("test-data").ToStream()); + + var blockList = blobClient.GetBlockList().Value; + + blockList.CommittedBlocks.Should().BeEmpty(); + blockList.UncommittedBlocks.Should().ContainSingle(b => b.Name == blockId); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void GetBlockList_For_Deleted_Blob_Should_Fail() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + using var content = new MemoryStream(Encoding.UTF8.GetBytes("Hello, World!")); + + blobClient.Upload(content); + + blobClient.Exists().Value.Should().BeTrue(); + + blobClient.Delete(); + + var act = () => blobClient.GetBlockList(); + + act + .Should() + .Throw() + .Where(e => e.Status == 404) + .Where(e => e.ErrorCode == "BlobNotFound"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void DownloadContent_For_Blob_With_Uncommited_Blocks_Only_Should_Fail() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-block-id")); + + blobClient.StageBlock(blockId, BinaryData.FromString("test-data").ToStream()); + + var act = () => blobClient.DownloadContent(); + + act + .Should() + .Throw() + .Where(e => e.Status == 404) + .Where(e => e.ErrorCode == "BlobNotFound"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void GetProperties_From_Blob_With_Uncommited_Blocks_Only_Should_Fail() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlockBlobClient(blobName); + + var blockId = Convert.ToBase64String(Encoding.UTF8.GetBytes("1")); + + using var content = new MemoryStream(Encoding.UTF8.GetBytes("Hello, World!")); + + blobClient.StageBlock(blockId, content); + + blobClient.Exists().Value.Should().BeFalse(); + + var act = () => blobClient.GetProperties(); + + act + .Should() + .Throw() + .Where(e => e.Status == 404) + .Where(e => e.ErrorCode == "BlobNotFound"); + } +} diff --git a/tests/Tests/Storage/Blobs/BlobClientTests_GenericBlobClient.cs b/tests/Tests/Storage/Blobs/BlobClientTests_GenericBlobClient.cs new file mode 100644 index 0000000..1c18d5e --- /dev/null +++ b/tests/Tests/Storage/Blobs/BlobClientTests_GenericBlobClient.cs @@ -0,0 +1,139 @@ +using Azure; +using Azure.Storage.Blobs.Specialized; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; +using Spotflow.InMemory.Azure.Storage.Resources; + +using Tests.Utils; + +namespace Tests.Storage.Blobs; + +[TestClass] +public class BlobClientTests_GenericBlobClient +{ + [TestMethod] + public void Constructor_With_Connection_String_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var connectionString = account.CreateConnectionString(); + + var client = new InMemoryBlobClient(connectionString, "test-container", "test-blob", provider); + + AssertClientProperties(client, "test-container", "test-blob", account); + } + + [TestMethod] + public void Constructor_With_Uri_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var client = new InMemoryBlobClient(account.CreateBlobSasUri("test-container", "test-blob"), provider); + + AssertClientProperties(client, "test-container", "test-blob", account); + } + + [TestMethod] + public void Constructor_With_Uri_Without_Container_Should_Fail() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var act = () => new InMemoryBlobClient(account.BlobServiceUri, provider); + + act.Should() + .Throw() + .WithMessage("Blob container name must be specified when creating a blob client."); + } + + [TestMethod] + public void Constructor_With_Uri_Without_Blob_Should_Fail() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var act = () => new InMemoryBlobClient(account.CreateBlobContainerSasUri("test"), provider); + + act.Should() + .Throw() + .WithMessage("Blob name must be specified when creating a blob client."); + } + + + [TestMethod] + public void Construct_From_Account_Should_Succeed() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var client = InMemoryBlobClient.FromAccount(account, "test-container", "test-blob"); + + AssertClientProperties(client, "test-container", "test-blob", account); + } + + private static void AssertClientProperties( + InMemoryBlobClient client, + string expectedContainerName, + string expectedBlobName, + InMemoryStorageAccount account) + { + var expectedUri = new Uri(account.BlobServiceUri, $"{expectedContainerName}/{expectedBlobName}"); + + client.Uri.Should().Be(expectedUri); + client.AccountName.Should().Be(account.Name); + client.BlobContainerName.Should().Be(expectedContainerName); + client.Name.Should().Be(expectedBlobName); + client.CanGenerateSasUri.Should().BeFalse(); + } + + [TestMethod] + public void GetParentBlobContainerClient_Should_Return_Working_Client() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var containerClient = InMemoryBlobContainerClient.FromAccount(account, "test-container"); + + containerClient.Create(); + + var blobClient = InMemoryBlobClient.FromAccount(account, "test-container", "test-blob"); + + blobClient.Upload(BinaryData.FromString("test-data")); + + var containerClientFromBlob = blobClient.GetParentBlobContainerClient(); + + var blobs = containerClientFromBlob.GetBlobs().ToList(); + + blobs.Should().ContainSingle(blob => blob.Name == "test-blob"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Upload_For_Existing_Blob_Without_Overwrite_Flag_Should_Fail() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlobClient(blobName); + + var data = BinaryData.FromString("test-data"); + + var act = () => blobClient.Upload(data, overwrite: false); + + act.Should().NotThrow(); + + act.Should() + .Throw() + .Where(e => e.Status == 409) + .Where(e => e.ErrorCode == "BlobAlreadyExists"); + } +} diff --git a/tests/Tests/Storage/Blobs/BlobContainerClientTests.cs b/tests/Tests/Storage/Blobs/BlobContainerClientTests.cs new file mode 100644 index 0000000..0b3eb7e --- /dev/null +++ b/tests/Tests/Storage/Blobs/BlobContainerClientTests.cs @@ -0,0 +1,299 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; +using Spotflow.InMemory.Azure.Storage.Resources; + +using Tests.Utils; + +namespace Tests.Storage.Blobs; + +[TestClass] +public class BlobContainerClientTests +{ + [TestMethod] + public void Constructor_With_Connection_String_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var connectionString = account.CreateConnectionString(); + + var client = new InMemoryBlobContainerClient(connectionString, "test", provider); + + AssertClientProperties(client, "test", account); + } + + [TestMethod] + public void Constructor_With_Uri_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var client = new InMemoryBlobContainerClient(account.CreateBlobContainerSasUri("test"), provider); + + AssertClientProperties(client, "test", account); + } + + [TestMethod] + public void Constructor_With_Uri_Without_Container_Should_Fail() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var act = () => new InMemoryBlobContainerClient(account.BlobServiceUri, provider); + + act.Should() + .Throw() + .WithMessage("Blob container name must be specified when creating a blob container client."); + } + + [TestMethod] + public void Construct_From_Account_Should_Succeed() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var client = InMemoryBlobContainerClient.FromAccount(account, "test"); + + AssertClientProperties(client, "test", account); + } + + private static void AssertClientProperties(InMemoryBlobContainerClient client, string expectedContainerName, InMemoryStorageAccount account) + { + var expectedUri = new Uri(account.BlobServiceUri, expectedContainerName); + + client.Uri.Should().Be(expectedUri); + client.AccountName.Should().Be(account.Name); + client.Name.Should().Be(expectedContainerName); + client.CanGenerateSasUri.Should().BeFalse(); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Create_From_Service_Client_With_Invalid_Name_Should_Fail() + { + var serviceClient = ImplementationProvider.GetBlobServiceClient(); + + var containerName = "abc--def"; + + var containerClient = serviceClient.GetBlobContainerClient(containerName); + + var act = () => containerClient.Create(); + + act.Should() + .Throw() + .Where(e => e.ErrorCode == "InvalidResourceName"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task GetBlobClient_Should_Return_Working_Client() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + var blobClient = containerClient.GetBlobClient(blobName); + + await blobClient.UploadAsync(BinaryData.FromString("test-data")); + + blobClient.DownloadContent().Value.Content.ToString().Should().Be("test-data"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Exists_For_Non_Existing_Container_Should_Be_False() + { + var serviceClient = ImplementationProvider.GetBlobServiceClient(); + + var containerName = Guid.NewGuid().ToString(); + + var containerClient = serviceClient.GetBlobContainerClient(containerName); + + containerClient.Exists().Value.Should().BeFalse(); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Exists_For_Existing_Container_Should_Be_True() + { + var serviceClient = ImplementationProvider.GetBlobServiceClient(); + + var containerName = nameof(Exists_For_Existing_Container_Should_Be_True) + .ToLowerInvariant() + .Replace("_", "-"); + + var containerClient = serviceClient.GetBlobContainerClient(containerName); + + containerClient.CreateIfNotExists(); + + containerClient.Exists().Value.Should().BeTrue(); + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void GetBlobs_Should_Return_Existing_Blobs() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobNamePrefix = Guid.NewGuid().ToString(); + + var count = ImplementationProvider.IsAzureConfigAvailable ? 10 : 100_000; + + for (var i = 0; i < count; i++) + { + var blobClient = containerClient.GetBlobClient($"{blobNamePrefix}_test-blob-{i:D10}"); + blobClient.Upload(BinaryData.FromString("test")); + } + + containerClient.GetBlobs(prefix: blobNamePrefix).Should().HaveCount(count); + } + + [TestMethod] + [DataRow(10_000, 2000, true)] + [DataRow(10_000, 2000, false)] + [DataRow(10_000, 128, true)] + [DataRow(10_000, 128, false)] + [DataRow(49_851, 128, true)] + [DataRow(49_851, 128, false)] + [DataRow(123, 2000, true)] + [DataRow(123, 2000, false)] + public async Task GetBlobs_Should_Return_Existing_Blobs_In_Pages(int numberOfBlobs, int pageSizeHint, bool async) + { + var account = new InMemoryStorageProvider().AddAccount(); + + var containerClient = InMemoryBlobContainerClient.FromAccount(account, "test"); + containerClient.Create(); + + for (var i = 0; i < numberOfBlobs; i++) + { + var blobClient = containerClient.GetBlobClient($"blob-{i:D10}"); + blobClient.Upload(BinaryData.FromString("test")); + } + + containerClient.GetBlobs().Should().HaveCount(numberOfBlobs); + + var expectedNumberOfPages = (int) Math.Ceiling((double) numberOfBlobs / pageSizeHint); + + var actualPageCount = await getPageCountAsync(containerClient, pageSizeHint, async); + + actualPageCount.Should().Be(expectedNumberOfPages); + + var firstPage = await getPageAsync(containerClient, 0, null, pageSizeHint, async); + + if (pageSizeHint >= numberOfBlobs) + { + firstPage.ContinuationToken.Should().BeNull(); + firstPage.Values.Should().HaveCount(numberOfBlobs); + return; + } + + firstPage.ContinuationToken.Should().Be("page-1"); + firstPage.Values.Should().HaveCount(pageSizeHint); + firstPage.Values[0].Name.Should().Be("blob-0000000000"); + firstPage.Values[pageSizeHint - 1].Name.Should().Be($"blob-{pageSizeHint - 1:D10}"); + + var secondPage = await getPageAsync(containerClient, 0, firstPage.ContinuationToken, pageSizeHint, async); + + secondPage.ContinuationToken.Should().Be("page-2"); + secondPage.Values.Should().HaveCount(pageSizeHint); + secondPage.Values[0].Name.Should().Be($"blob-{pageSizeHint:D10}"); + secondPage.Values[pageSizeHint - 1].Name.Should().Be($"blob-{(pageSizeHint * 2) - 1:D10}"); + + var lastPage = await getPageAsync(containerClient, expectedNumberOfPages - 1 - 2, secondPage.ContinuationToken, pageSizeHint, async); + + lastPage.ContinuationToken.Should().BeNull(); + lastPage.Values.Should().HaveCount(numberOfBlobs % pageSizeHint == 0 ? pageSizeHint : numberOfBlobs % pageSizeHint); + + + static async ValueTask getPageCountAsync(BlobContainerClient client, int pageSizeHint, bool async) + { + if (async) + { + var count = 0; + + await foreach (var page in client.GetBlobsAsync().AsPages(null, pageSizeHint)) + { + count++; + } + + return count; + } + else + { + return client.GetBlobs().AsPages(null, pageSizeHint).Count(); + } + } + + static async ValueTask> getPageAsync(BlobContainerClient client, int pageIndex, string? continuationToken, int pageSizeHint, bool async) + { + if (async) + { + var currentPageIndex = 0; + + await foreach (var page in client.GetBlobsAsync().AsPages(continuationToken, pageSizeHint)) + { + if (currentPageIndex == pageIndex) + { + return page; + } + + currentPageIndex++; + } + + throw new InvalidOperationException($"No page at index {pageIndex} not found."); + } + else + { + return client.GetBlobs().AsPages(continuationToken, pageSizeHint).ElementAt(pageIndex); + } + } + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Upload_Blob_Should_Succeed() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + containerClient.UploadBlob(blobName, BinaryData.FromString("test")); + + containerClient.GetBlobClient(blobName).DownloadContent().Value.Content.ToString().Should().Be("test"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Delete_Blob_Should_Succeed() + { + var containerClient = ImplementationProvider.GetBlobContainerClient(); + + containerClient.CreateIfNotExists(); + + var blobName = Guid.NewGuid().ToString(); + + containerClient.UploadBlob(blobName, BinaryData.FromString("test")); + + containerClient.GetBlobs(prefix: blobName).Should().HaveCount(1); + + containerClient.DeleteBlob(blobName); + + containerClient.GetBlobs(prefix: blobName).Should().BeEmpty(); + } +} diff --git a/tests/Tests/Storage/Blobs/BlobServiceClientTests.cs b/tests/Tests/Storage/Blobs/BlobServiceClientTests.cs new file mode 100644 index 0000000..358c5f4 --- /dev/null +++ b/tests/Tests/Storage/Blobs/BlobServiceClientTests.cs @@ -0,0 +1,52 @@ +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; +using Spotflow.InMemory.Azure.Storage.Resources; + +namespace Tests.Storage.Blobs; + +[TestClass] +public class BlobServiceClientTests +{ + [TestMethod] + public void Constructor_With_Connection_String_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var connectionString = account.CreateConnectionString(); + + var client = new InMemoryBlobServiceClient(connectionString, provider); + + AssertClientProperties(client, account); + } + + [TestMethod] + public void Constructor_With_Sas_Uri_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var client = new InMemoryBlobServiceClient(account.BlobServiceUri, provider); + + AssertClientProperties(client, account); + } + + [TestMethod] + public void Construct_From_Account_Should_Succeed() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var client = InMemoryBlobServiceClient.FromAccount(account); + + AssertClientProperties(client, account); + } + + private static void AssertClientProperties(InMemoryBlobServiceClient client, InMemoryStorageAccount account) + { + client.AccountName.Should().Be(account.Name); + client.Uri.Should().Be(account.BlobServiceUri); + client.CanGenerateAccountSasUri.Should().BeFalse(); + } +} diff --git a/tests/Tests/Storage/Blobs/FaultInjectionTests.cs b/tests/Tests/Storage/Blobs/FaultInjectionTests.cs new file mode 100644 index 0000000..c67da6b --- /dev/null +++ b/tests/Tests/Storage/Blobs/FaultInjectionTests.cs @@ -0,0 +1,84 @@ +using System.Text.RegularExpressions; + +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Sas; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; + +using Tests.Utils; + +namespace Tests.Storage.Blobs; + +[TestClass] +public class FaultInjectionTests +{ + [TestMethod] + public void Service_Is_Busy_Should_Throw_Exception() + { + var provider = new InMemoryStorageProvider(); + + provider.AddHook(hook => hook.ForBlobService().Before(ctx => ctx.Faults().ServiceIsBusy())); + + var account = provider.AddAccount("test-account"); + + var containerClient = InMemoryBlobContainerClient.FromAccount(account, "test-container"); + + var act = () => containerClient.Create(); + + act.Should() + .Throw() + .WithMessage("Blob service in account 'test-account' is busy.") + .Where(ex => ex.Status == 503) + .Where(ex => ex.ErrorCode == "ServerBusy"); + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public async Task Authentication_Failed_Should_Throw_Exception() + { + Action act; + + if (!ImplementationProvider.IsAzureConfigAvailable) + { + var provider = new InMemoryStorageProvider(); + + provider.AddHook(hook => hook.ForBlobService().Before(ctx => ctx.Faults().AuthenticationFailedSignatureDidNotMatch())); + + var account = provider.AddAccount("test-account"); + + var containerClient = InMemoryBlobContainerClient.FromAccount(account, "test-container"); + + act = () => containerClient.Create(); + } + else + { + var connectionString = await ImplementationProvider.GetStorageConnectionString(); + + var serviceClient = new BlobServiceClient(connectionString); + var containerClient = serviceClient.GetBlobContainerClient(Guid.NewGuid().ToString()); + var blobClient = containerClient.GetBlobClient(Guid.NewGuid().ToString()); + + var sasUri = blobClient.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.UtcNow.AddHours(1)); + + var invalidSasUri = new Uri(Regex.Replace(sasUri.ToString(), "sig=[^&]([^&]+)", "sig=x$1")); // Change one letter of the signature. + + act = () => new BlobClient(invalidSasUri).DownloadContent(); + } + + act.Should() + .Throw() + .WithMessage("Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature.*") + .Where(ex => ex.Status == 403) + .Where(ex => ex.ErrorCode == "AuthenticationFailed") + .Which.Data["AuthenticationErrorDetail"].Should().BeOfType() + .Which.Should().StartWith("Signature did not match."); + } + + + + + +} diff --git a/tests/Tests/Storage/Blobs/FluentAssertionsTests.cs b/tests/Tests/Storage/Blobs/FluentAssertionsTests.cs new file mode 100644 index 0000000..45e3f11 --- /dev/null +++ b/tests/Tests/Storage/Blobs/FluentAssertionsTests.cs @@ -0,0 +1,38 @@ +using Azure.Storage.Blobs.Specialized; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; +using Spotflow.InMemory.Azure.Storage.FluentAssertions; + +namespace Tests.Storage.Blobs; + +[TestClass] +public class FluentAssertionsTests +{ + [TestMethod] + public async Task ExistAsync_Should_Wait_For_Blob_To_Be_Created() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var blob = InMemoryBlobClient.FromAccount(account, "test-container", "test-blob"); + + blob.GetParentBlobContainerClient().Create(); + + var existTask = blob.Should().ExistAsync(); + + existTask.IsCompleted.Should().BeFalse(); + + await Task.Delay(100); + + existTask.IsCompleted.Should().BeFalse(); + + await Task.Delay(100); + + blob.Upload(BinaryData.FromString("test-data")); + + await existTask; + + existTask.IsCompletedSuccessfully.Should().BeTrue(); + + } +} diff --git a/tests/Tests/Storage/Blobs/HooksTests.cs b/tests/Tests/Storage/Blobs/HooksTests.cs new file mode 100644 index 0000000..e615680 --- /dev/null +++ b/tests/Tests/Storage/Blobs/HooksTests.cs @@ -0,0 +1,207 @@ +using Azure; +using Azure.Storage.Blobs.Models; + +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks; +using Spotflow.InMemory.Azure.Storage.Blobs.Hooks.Contexts; + +namespace Tests.Storage.Blobs; + +[TestClass] +public class HooksTests +{ + [TestMethod] + public async Task Blob_Download_Hooks_Should_Execute() + { + const string accountName = "test-account"; + const string containerName = "test-container"; + const string blobName = "test-blob"; + var provider = new InMemoryStorageProvider(); + + BlobDownloadBeforeHookContext? capturedBeforeContext = null; + BlobDownloadAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForBlobService().ForBlobOperations().BeforeDownload(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(builder => builder.ForBlobService().ForBlobOperations().AfterDownload(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + var account = provider.AddAccount(accountName); + var containerClient = InMemoryBlobContainerClient.FromAccount(account, containerName); + await containerClient.CreateIfNotExistsAsync(); + var client = InMemoryBlobClient.FromAccount(account, containerName, blobName); + + var blobData = new BinaryData("test"); + await client.UploadAsync(blobData); + + await client.DownloadContentAsync(new BlobRequestConditions() { IfMatch = ETag.All }, default); + + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.StorageAccountName.Should().BeEquivalentTo(accountName); + capturedBeforeContext?.ContainerName.Should().BeEquivalentTo(containerName); + capturedBeforeContext?.BlobName.Should().BeEquivalentTo(blobName); + capturedBeforeContext?.Operation.Should().Be(BlobOperations.Download); + capturedBeforeContext?.Options?.Conditions.IfMatch.Should().Be(ETag.All); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.StorageAccountName.Should().BeEquivalentTo(accountName); + capturedAfterContext?.ContainerName.Should().BeEquivalentTo(containerName); + capturedAfterContext?.BlobName.Should().BeEquivalentTo(blobName); + capturedAfterContext?.Operation.Should().Be(BlobOperations.Download); + capturedAfterContext?.BlobDownloadDetails.Should().NotBeNull(); + capturedAfterContext?.Content.ToString().Should().Be(blobData.ToString()); + } + + [TestMethod] + public async Task Hooks_With_Different_Scope_Should_Not_Execute() + { + const string accountName = "test-account"; + const string containerName = "test-container"; + const string blobName = "test-blob"; + var provider = new InMemoryStorageProvider(); + + HookFunc failingBeforeHook = _ => throw new InvalidOperationException("This hook should not execute."); + HookFunc failingAfterHook = _ => throw new InvalidOperationException("This hook should not execute."); + + provider.AddHook(hook => hook.ForBlobService("different-acc").ForBlobOperations().BeforeDownload(failingBeforeHook)); + provider.AddHook(hook => hook.ForBlobService("different-acc").ForBlobOperations().BeforeDownload(failingBeforeHook)); + provider.AddHook(hook => hook.ForBlobService().ForBlobOperations(blobName: "different-blob").BeforeDownload(failingBeforeHook)); + provider.AddHook(hook => hook.ForBlobService().ForBlobOperations(containerName: "different-container").BeforeDownload(failingBeforeHook)); + + provider.AddHook(hook => hook.ForBlobService("different-acc").ForBlobOperations().AfterDownload(failingAfterHook)); + provider.AddHook(hook => hook.ForBlobService("different-acc").ForBlobOperations().AfterDownload(failingAfterHook)); + provider.AddHook(hook => hook.ForBlobService().ForBlobOperations(blobName: "different-blob").AfterDownload(failingAfterHook)); + provider.AddHook(hook => hook.ForBlobService().ForBlobOperations(containerName: "different-container").AfterDownload(failingAfterHook)); + + var account = provider.AddAccount(accountName); + var containerClient = InMemoryBlobContainerClient.FromAccount(account, containerName); + await containerClient.CreateIfNotExistsAsync(); + var client = InMemoryBlobClient.FromAccount(account, containerName, blobName); + + var blobData = new BinaryData("test"); + + await client.UploadAsync(blobData); + await client.DownloadContentAsync(default); + } + + [TestMethod] + public async Task Parent_Hook_Should_Execute() + { + const string accountName = "test-account"; + const string containerName = "test-container"; + const string blobName = "test-blob"; + var provider = new InMemoryStorageProvider(); + + BlobServiceBeforeHookContext? capturedBeforeContext = null; + BlobServiceAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForBlobService().Before(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(builder => builder.ForBlobService().After(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + var account = provider.AddAccount(accountName); + var containerClient = InMemoryBlobContainerClient.FromAccount(account, containerName); + await containerClient.CreateIfNotExistsAsync(); + var client = InMemoryBlobClient.FromAccount(account, containerName, blobName); + + var blobData = new BinaryData("test"); + + await client.UploadAsync(blobData); + await client.DownloadContentAsync(default); + + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.StorageAccountName.Should().Be(accountName); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.StorageAccountName.Should().Be(accountName); + + } + + [TestMethod] + public async Task Targeted_Hooks_Should_Execute() + { + const string accountName = "test-account"; + const string containerName = "test-container"; + const string blobName = "test-blob"; + var provider = new InMemoryStorageProvider(); + + BlobBeforeHookContext? capturedBeforeContext = null; + BlobAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForBlobService().ForBlobOperations().Before(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + }, BlobOperations.Upload)); + + provider.AddHook(builder => builder.ForBlobService().ForBlobOperations().After(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + }, BlobOperations.Upload)); + + var account = provider.AddAccount(accountName); + var containerClient = InMemoryBlobContainerClient.FromAccount(account, containerName); + await containerClient.CreateIfNotExistsAsync(); + var client = InMemoryBlobClient.FromAccount(account, containerName, blobName); + + var blobData = new BinaryData("test"); + + await client.UploadAsync(blobData); + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.StorageAccountName.Should().Be(accountName); + capturedBeforeContext?.ContainerName.Should().Be(containerName); + capturedBeforeContext?.BlobName.Should().Be(blobName); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.StorageAccountName.Should().Be(accountName); + capturedAfterContext?.ContainerName.Should().Be(containerName); + capturedAfterContext?.BlobName.Should().Be(blobName); + + } + + [TestMethod] + public async Task Hooks_With_Different_Target_Should_Not_Execute() + { + const string accountName = "test-account"; + const string containerName = "test-container"; + const string blobName = "test-blob"; + var provider = new InMemoryStorageProvider(); + + HookFunc failingBeforeHook = _ => throw new InvalidOperationException("This hook should not execute."); + HookFunc failingAfterHook = _ => throw new InvalidOperationException("This hook should not execute."); + + provider.AddHook(hook => hook.ForBlobService().Before(failingBeforeHook, containerOperations: ContainerOperations.None, blobOperations: BlobOperations.Download)); + provider.AddHook(hook => hook.ForBlobService().After(failingAfterHook, containerOperations: ContainerOperations.None, blobOperations: BlobOperations.Download)); + + var account = provider.AddAccount(accountName); + var containerClient = InMemoryBlobContainerClient.FromAccount(account, containerName); + await containerClient.CreateIfNotExistsAsync(); + + var client = InMemoryBlobClient.FromAccount(account, containerName, blobName); + + var blobData = new BinaryData("test"); + + await client.UploadAsync(blobData); + } +} diff --git a/tests/Tests/Storage/DelayInjectionTests.cs b/tests/Tests/Storage/DelayInjectionTests.cs new file mode 100644 index 0000000..05041eb --- /dev/null +++ b/tests/Tests/Storage/DelayInjectionTests.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Time.Testing; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; + +namespace Tests.Storage; + +[TestClass] +public class DelayInjectionTests +{ + [TestMethod] + public async Task Operation_Should_Be_Delayed() + { + var timeProvider = new FakeTimeProvider(); + + var provider = new InMemoryStorageProvider(timeProvider: timeProvider); + + provider.AddHook(hook => hook.Before(ctx => ctx.DelayAsync(TimeSpan.FromMilliseconds(100)))); + + var account = provider.AddAccount("test-account"); + + var containerClient = InMemoryBlobContainerClient.FromAccount(account, "test-container"); + + var task = Task.Run(() => containerClient.Create()); + + while (task.Status != TaskStatus.Running) + { + await Task.Delay(10); + } + + await Task.Delay(1000); + + task.Status.Should().Be(TaskStatus.Running); + + timeProvider.Advance(TimeSpan.FromSeconds(32)); + + var response = await task; + + response.Value.LastModified.Should().Be(timeProvider.GetUtcNow()); + + } +} diff --git a/tests/Tests/Storage/FaultInjectionTests.cs b/tests/Tests/Storage/FaultInjectionTests.cs new file mode 100644 index 0000000..5d1bc94 --- /dev/null +++ b/tests/Tests/Storage/FaultInjectionTests.cs @@ -0,0 +1,81 @@ +using Azure; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; +using Spotflow.InMemory.Azure.Storage.Tables; + +namespace Tests.Storage; + + +[TestClass] +public class FaultInjectionTests +{ + [TestMethod] + public void Service_Is_Busy_With_Manual_Fault_Disable_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var hook = provider.AddHook(hook => hook.Before(ctx => ctx.Faults().ServiceIsBusy())); + + var account = provider.AddAccount("test-account"); + + var containerClient = InMemoryBlobContainerClient.FromAccount(account, "test-container"); + var tableClient = InMemoryTableClient.FromAccount(account, "test-table"); + + var actBlob = () => containerClient.Create(); + var actTable = () => tableClient.Create(); + + actBlob.Should().Throw().WithMessage("Blob service in account 'test-account' is busy."); + actTable.Should().Throw().WithMessage("Table service in account 'test-account' is busy."); + + hook.Disable(); + + actBlob.Should().NotThrow(); + actTable.Should().NotThrow(); + + + } + + [TestMethod] + public void Service_Is_Busy_For_Specific_Account_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account1 = provider.AddAccount("test-account-1"); + var account2 = provider.AddAccount("test-account-2"); + + var hook = provider.AddHook(hook => hook.Before(ctx => ctx.Faults().ServiceIsBusy(), storageAccountName: account1.Name)); + + var actBlob1 = () => InMemoryBlobContainerClient.FromAccount(account1, "test-container").Create(); + var actTable1 = () => InMemoryTableClient.FromAccount(account1, "test-table").Create(); + + var actBlob2 = () => InMemoryBlobContainerClient.FromAccount(account2, "test-container").Create(); + var actTable2 = () => InMemoryTableClient.FromAccount(account2, "test-table").Create(); + + actBlob1.Should().Throw(); + actTable1.Should().Throw(); + + actBlob2.Should().NotThrow(); + actTable2.Should().NotThrow(); + } + + [TestMethod] + public void Service_Is_Busy_With_Automatic_Fault_Disable_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var hook = provider.AddHook(hook => hook.Before(ctx => ctx.Faults().ServiceIsBusy())); + + hook.DisableAfter(invocationCount: 1); + + var account = provider.AddAccount("test-account"); + + var containerClient = InMemoryBlobContainerClient.FromAccount(account, "test-container"); + + var act = () => containerClient.Create(); + + act.Should().Throw().WithMessage("Blob service in account 'test-account' is busy."); + + act.Should().NotThrow(); + } +} diff --git a/tests/Tests/Storage/StorageAccountTests.cs b/tests/Tests/Storage/StorageAccountTests.cs new file mode 100644 index 0000000..a4970da --- /dev/null +++ b/tests/Tests/Storage/StorageAccountTests.cs @@ -0,0 +1,19 @@ +using Spotflow.InMemory.Azure.Storage; + +namespace Tests.Storage; + +[TestClass] +public class StorageAccountTests +{ + [TestMethod] + public void Connection_String_Should_Be_Properly_Formatted() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var connectionString = account.CreateConnectionString(); + + connectionString + .Should() + .Be($"AccountName={account.Name};AccountKey={account.PrimaryAccessKey};DefaultEndpointsProtocol=https;TableEndpoint={account.TableServiceUri};BlobEndpoint={account.BlobServiceUri}"); + } +} diff --git a/tests/Tests/Storage/StorageProviderTests.cs b/tests/Tests/Storage/StorageProviderTests.cs new file mode 100644 index 0000000..2ec84ab --- /dev/null +++ b/tests/Tests/Storage/StorageProviderTests.cs @@ -0,0 +1,21 @@ +using Spotflow.InMemory.Azure.Storage; + +namespace Tests.Storage; + +[TestClass] +public class StorageProviderTests + +{ + [TestMethod] + public void Get_Non_Existent_Account_Should_Throw() + { + var provider = new InMemoryStorageProvider(); + + Action act = () => provider.GetAccount("non-existent"); + + act + .Should() + .Throw() + .WithMessage("Storage account 'non-existent' not found."); + } +} diff --git a/tests/Tests/Storage/Tables/FaultInjectionTests.cs b/tests/Tests/Storage/Tables/FaultInjectionTests.cs new file mode 100644 index 0000000..9451da4 --- /dev/null +++ b/tests/Tests/Storage/Tables/FaultInjectionTests.cs @@ -0,0 +1,30 @@ +using Azure; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Tables; + +namespace Tests.Storage.Tables; + +[TestClass] +public class FaultInjectionTests +{ + [TestMethod] + public void Service_Is_Busy_Should_Throw_Exception() + { + var provider = new InMemoryStorageProvider(); + + provider.AddHook(hook => hook.ForTableService().Before(ctx => ctx.Faults().ServiceIsBusy())); + + var account = provider.AddAccount("test-account"); + + var tableClient = InMemoryTableClient.FromAccount(account, "test-container"); + + var act = () => tableClient.Create(); + + act.Should() + .Throw() + .WithMessage("Table service in account 'test-account' is busy.") + .Where(ex => ex.Status == 503) + .Where(ex => ex.ErrorCode == "ServerBusy"); + } +} diff --git a/tests/Tests/Storage/Tables/HooksTests.cs b/tests/Tests/Storage/Tables/HooksTests.cs new file mode 100644 index 0000000..f045d98 --- /dev/null +++ b/tests/Tests/Storage/Tables/HooksTests.cs @@ -0,0 +1,219 @@ +using Azure; +using Azure.Data.Tables; + +using Spotflow.InMemory.Azure.Hooks; +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Tables; +using Spotflow.InMemory.Azure.Storage.Tables.Hooks; +using Spotflow.InMemory.Azure.Storage.Tables.Hooks.Contexts; + +namespace Tests.Storage.Tables; + + +[TestClass] +public class HooksTests +{ + [TestMethod] + public async Task Table_Create_Hooks_Should_Execute() + { + const string accountName = "test-account"; + const string tableName = "test-table"; + var provider = new InMemoryStorageProvider(); + + TableCreateBeforeHookContext? capturedBeforeContext = null; + TableCreateAfterHookContext? capturedAfterContext = null; + + provider.AddHook(builder => builder.ForTableService().ForTableOperations().BeforeCreate(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(builder => builder.ForTableService().ForTableOperations().AfterCreate(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + var account = provider.AddAccount(accountName); + var tableClient = InMemoryTableClient.FromAccount(account, tableName); + + await tableClient.CreateAsync(); + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.StorageAccountName.Should().Be(accountName); + capturedBeforeContext?.TableName.Should().BeEquivalentTo(tableName); + capturedBeforeContext?.Operation.Should().Be(TableOperations.Create); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.StorageAccountName.Should().Be(accountName); + capturedAfterContext?.TableName.Should().Be(tableName); + capturedAfterContext?.Operation.Should().Be(TableOperations.Create); + } + + [TestMethod] + public async Task Hooks_With_Different_Scope_Should_Not_Execute() + { + const string accountName = "test-account"; + const string tableName = "test-table"; + var provider = new InMemoryStorageProvider(); + + HookFunc failingBeforeHook = _ => throw new InvalidOperationException("This hook should not execute."); + HookFunc failingAfterHook = _ => throw new InvalidOperationException("This hook should not execute."); + + provider.AddHook(hook => hook.ForTableService("different-acc").ForTableOperations().BeforeCreate(failingBeforeHook)); + provider.AddHook(hook => hook.ForTableService("different-acc").ForTableOperations().BeforeCreate(failingBeforeHook)); + provider.AddHook(hook => hook.ForTableService().ForTableOperations(tableName: "different-table").BeforeCreate(failingBeforeHook)); + + provider.AddHook(hook => hook.ForTableService("different-acc").ForTableOperations().AfterCreate(failingAfterHook)); + provider.AddHook(hook => hook.ForTableService().ForTableOperations(tableName: "different-table").AfterCreate(failingAfterHook)); + + var account = provider.AddAccount(accountName); + var tableClient = InMemoryTableClient.FromAccount(account, tableName); + await tableClient.CreateAsync(); + } + + [TestMethod] + public async Task Parent_Hook_Should_Execute() + { + const string accountName = "test-account"; + const string tableName = "test-table"; + var provider = new InMemoryStorageProvider(); + + TableBeforeHookContext? capturedBeforeContext = null; + TableAfterHookContext? capturedAfterContext = null; + + provider.AddHook(hook => hook.ForTableService().ForTableOperations().Before(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + })); + + provider.AddHook(hook => hook.ForTableService().ForTableOperations().After(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + })); + + var account = provider.AddAccount(accountName); + var tableClient = InMemoryTableClient.FromAccount(account, tableName); + + await tableClient.CreateAsync(); + + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.StorageAccountName.Should().BeEquivalentTo(accountName); + capturedBeforeContext?.TableName.Should().Be(tableName); + capturedBeforeContext?.Operation.Should().Be(TableOperations.Create); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.StorageAccountName.Should().Be(accountName); + capturedAfterContext?.TableName.Should().Be(tableName); + capturedAfterContext?.Operation.Should().Be(TableOperations.Create); + } + + [TestMethod] + public async Task Targeted_Hooks__Should_Execute() + { + const string accountName = "test-account"; + const string tableName = "test-table"; + var provider = new InMemoryStorageProvider(); + + TableBeforeHookContext? capturedBeforeContext = null; + TableAfterHookContext? capturedAfterContext = null; + + provider.AddHook(hook => hook.ForTableService().ForTableOperations().Before(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + }, TableOperations.Create)); + + provider.AddHook(hook => hook.ForTableService().ForTableOperations().After(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + }, TableOperations.Create)); + + var account = provider.AddAccount(accountName); + var tableClient = InMemoryTableClient.FromAccount(account, tableName); + + await tableClient.CreateAsync(); + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.StorageAccountName.Should().Be(accountName); + capturedBeforeContext?.TableName.Should().Be(tableName); + capturedBeforeContext?.Operation.Should().Be(TableOperations.Create); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.StorageAccountName.Should().Be(accountName); + capturedAfterContext?.TableName.Should().Be(tableName); + capturedAfterContext?.Operation.Should().Be(TableOperations.Create); + } + + [TestMethod] + public async Task Entity_Hooks_Should_Execute() + { + const string accountName = "test-account"; + const string tableName = "test-table"; + var provider = new InMemoryStorageProvider(); + + EntityBeforeHookContext? capturedBeforeContext = null; + EntityAfterHookContext? capturedAfterContext = null; + + provider.AddHook(hook => hook.ForTableService().ForEntityOperations().Before(ctx => + { + capturedBeforeContext = ctx; + return Task.CompletedTask; + }, EntityOperations.Add)); + + provider.AddHook(hook => hook.ForTableService().ForEntityOperations().After(ctx => + { + capturedAfterContext = ctx; + return Task.CompletedTask; + }, EntityOperations.Add)); + + var account = provider.AddAccount(accountName); + var tableClient = InMemoryTableClient.FromAccount(account, tableName); + await tableClient.CreateAsync(); + + await tableClient.AddEntityAsync(new TestEntity() { PartitionKey = "pk", RowKey = "rk" }); + + capturedBeforeContext.Should().NotBeNull(); + capturedBeforeContext?.StorageAccountName.Should().Be(accountName); + capturedBeforeContext?.TableName.Should().Be(tableName); + capturedBeforeContext?.Operation.Should().Be(EntityOperations.Add); + + capturedAfterContext.Should().NotBeNull(); + capturedAfterContext?.StorageAccountName.Should().Be(accountName); + capturedAfterContext?.TableName.Should().Be(tableName); + capturedAfterContext?.Operation.Should().Be(EntityOperations.Add); + } + + [TestMethod] + public async Task Hooks_With_Different_Target_Should_Not_Execute() + { + const string accountName = "test-account"; + const string tableName = "test-table"; + var provider = new InMemoryStorageProvider(); + + HookFunc failingBeforeHook = _ => throw new InvalidOperationException("This hook should not execute."); + HookFunc failingAfterHook = _ => throw new InvalidOperationException("This hook should not execute."); + + provider.AddHook(hook => hook.ForTableService().ForEntityOperations().Before(failingBeforeHook, EntityOperations.Upsert)); + provider.AddHook(hook => hook.ForTableService().ForEntityOperations().After(failingAfterHook, EntityOperations.Upsert)); + + var account = provider.AddAccount(accountName); + var tableClient = InMemoryTableClient.FromAccount(account, tableName); + await tableClient.CreateAsync(); + + await tableClient.AddEntityAsync(new TestEntity() { PartitionKey = "pk", RowKey = "rk" }); + } + + private class TestEntity : ITableEntity + { + public required string PartitionKey { get; set; } + public required string RowKey { get; set; } + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } + } +} diff --git a/tests/Tests/Storage/Tables/TableClientTests.cs b/tests/Tests/Storage/Tables/TableClientTests.cs new file mode 100644 index 0000000..b5ab8ff --- /dev/null +++ b/tests/Tests/Storage/Tables/TableClientTests.cs @@ -0,0 +1,489 @@ +using Azure; +using Azure.Data.Tables; + +using Microsoft.Extensions.Time.Testing; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Resources; +using Spotflow.InMemory.Azure.Storage.Tables; + +using Tests.Utils; + +namespace Tests.Storage.Tables; + +[TestClass] +public class TableClientTests +{ + private class CustomEntity : ITableEntity + { + public required string PartitionKey { get; set; } + public required string RowKey { get; set; } + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } + public DateTimeOffset? CustomProperty2 { get; set; } + public long? CustomProperty3 { get; set; } + public int CustomProperty1 { get; set; } + } + + [TestMethod] + public void Constructor_With_Connection_String_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var connectionString = account.CreateConnectionString(); + + var client = new InMemoryTableClient(connectionString, "test", provider); + + AssertClientProperties(client, "test", account); + } + + [TestMethod] + public void Constructor_With_Uri_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var client = new InMemoryTableClient(account.CreateTableSasUri("test"), provider); + + AssertClientProperties(client, "test", account); + } + + [TestMethod] + public void Constructor_With_Uri_Without_Table_Should_Fail() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var act = () => new InMemoryTableClient(account.TableServiceUri, provider); + + act.Should() + .Throw() + .WithMessage("Table name must be specified when creating a table client."); + } + + [TestMethod] + public void Construct_From_Account_Should_Succeed() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var client = InMemoryTableClient.FromAccount(account, "test"); + + AssertClientProperties(client, "test", account); + } + + private static void AssertClientProperties(InMemoryTableClient client, string expectedTableName, InMemoryStorageAccount account) + { + var expectedUri = new Uri(account.TableServiceUri, expectedTableName); + + client.Uri.Should().Be(expectedUri); + client.AccountName.Should().Be(account.Name); + client.Name.Should().Be(expectedTableName); + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void GetEntity_For_Non_Existing_Entity_Should_Fail() + { + var tableClient = ImplementationProvider.GetTableClient(); + + var partitionKey = Guid.NewGuid().ToString(); + + tableClient.CreateIfNotExists(); + + var act = () => tableClient.GetEntity(partitionKey, "rk"); + + var exception = act.Should().Throw().Which; + + exception.Status.Should().Be(404); + exception.ErrorCode.Should().Be("ResourceNotFound"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void AddEntity_Should_Create_Entity() + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var entity = new TableEntity(partitionKey, "rk") { ["TestProperty"] = 42 }; + + tableClient.AddEntity(entity); + + var fetchedEntity = tableClient.GetEntity(partitionKey, "rk").Value; + + fetchedEntity.GetInt32("TestProperty").Should().Be(42); + } + + [TestMethod] + public void AddEntity_Should_Create_Entity_And_Set_Timestamp() + { + var timeProvider = new FakeTimeProvider(); + + var account = new InMemoryStorageProvider(timeProvider: timeProvider).AddAccount(); + + var tableClient = InMemoryTableClient.FromAccount(account, "TestTable"); + + tableClient.Create(); + + var entity = new TableEntity("pk", "rk") { ["TestProperty"] = 42 }; + + tableClient.AddEntity(entity); + + var fetchedEntity = tableClient.GetEntity("pk", "rk").Value; + + fetchedEntity.GetInt32("TestProperty").Should().Be(42); + fetchedEntity.ETag.ToString().Should().NotBeNullOrWhiteSpace(); + fetchedEntity.Timestamp.Should().Be(timeProvider.GetUtcNow()); + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void AddEntity_For_Existing_Entity_Should_Fail() + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var entity = new TableEntity(partitionKey, "rk"); + + tableClient.AddEntity(entity); + + var act = () => tableClient.AddEntity(entity); + + var exception = act.Should().Throw().Which; + + exception.Status.Should().Be(409); + exception.ErrorCode.Should().Be("EntityAlreadyExists"); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Delete_Existing_Entity_Should_Succeed() + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var entity = new TableEntity(partitionKey, "rk"); + + tableClient.AddEntity(entity); + + tableClient.Query(e => e.PartitionKey == partitionKey).Should().ContainSingle(e => e.RowKey == "rk"); + + tableClient.DeleteEntity(partitionKey, "rk"); + + tableClient.Query(e => e.PartitionKey == partitionKey).Should().BeEmpty(); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Delete_Missing_Entity_Without_ETag_Should_Succeed() + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + tableClient.DeleteEntity(partitionKey, "rk"); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Delete_Missing_Entity_With_ETag_Should_Not_Fail() + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var eTag = new ETag("W/\"datetime'2024-06-19T14%3A29%3A09.5839429Z'\""); + + var response = tableClient.DeleteEntity(partitionKey, "rk", ifMatch: eTag); + + response.Status.Should().Be(404); + + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Upsert_Existing_Entity_With_Merge_Should_Succeed() + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var entity1 = new TableEntity(partitionKey, "rk") { ["TestProperty1"] = 11, ["TestProperty2"] = 12 }; + var entity2 = new TableEntity(partitionKey, "rk") { ["TestProperty1"] = 21, ["TestProperty3"] = 23 }; + + tableClient.UpsertEntity(entity1, TableUpdateMode.Merge); + tableClient.UpsertEntity(entity2, TableUpdateMode.Merge); + + var fetchedEntity = tableClient.GetEntity(partitionKey, "rk").Value; + + fetchedEntity.GetInt32("TestProperty1").Should().Be(21); + fetchedEntity.GetInt32("TestProperty2").Should().Be(12); + fetchedEntity.GetInt32("TestProperty3").Should().Be(23); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Upsert_Existing_Entity_With_Replace_ShouldSucceed() + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var entity1 = new TableEntity(partitionKey, "rk") { ["TestProperty1"] = 11, ["TestProperty2"] = 12 }; + var entity2 = new TableEntity(partitionKey, "rk") { ["TestProperty1"] = 21, ["TestProperty3"] = 23 }; + + tableClient.UpsertEntity(entity1, TableUpdateMode.Replace); + tableClient.UpsertEntity(entity2, TableUpdateMode.Replace); + + var fetchedEntity = tableClient.GetEntity(partitionKey, "rk").Value; + + fetchedEntity.GetInt32("TestProperty1").Should().Be(21); + fetchedEntity.GetInt32("TestProperty2").Should().BeNull(); + fetchedEntity.GetInt32("TestProperty3").Should().Be(23); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(TableUpdateMode.Replace, DisplayName = "Replace")] + [DataRow(TableUpdateMode.Merge, DisplayName = "Merge")] + public void Upsert_New_Entity_Should_Succeed(TableUpdateMode updateMode) + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var entity = new TableEntity(partitionKey, "rk") { ["TestProperty"] = 42 }; + + tableClient.UpsertEntity(entity, updateMode); + + var fetchedEntity = tableClient.GetEntity(partitionKey, "rk").Value; + + fetchedEntity.ETag.ToString().Should().NotBeNullOrWhiteSpace(); + fetchedEntity.GetInt32("TestProperty").Should().Be(42); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(TableUpdateMode.Replace, DisplayName = "Replace")] + [DataRow(TableUpdateMode.Merge, DisplayName = "Merge")] + public void UpsertEntity_Of_Custom_Type_Should_Succeed(TableUpdateMode updateMode) + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var now = DateTimeOffset.UtcNow; + + var partitionKey = Guid.NewGuid().ToString(); + + var entity = new CustomEntity + { + PartitionKey = partitionKey, + RowKey = "rk", + CustomProperty1 = 42, + CustomProperty2 = now, + CustomProperty3 = 4242 + }; + + tableClient.UpsertEntity(entity, updateMode); + + var fetchedEntity = tableClient.GetEntity(partitionKey, "rk").Value; + + fetchedEntity.ETag.ToString().Should().NotBeNullOrWhiteSpace(); + fetchedEntity.CustomProperty1.Should().Be(42); + fetchedEntity.CustomProperty2.Should().Be(now); + fetchedEntity.CustomProperty3.Should().Be(4242); + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(TableUpdateMode.Replace, DisplayName = "Replace")] + [DataRow(TableUpdateMode.Merge, DisplayName = "Merge")] + public void UpsertEntity_Should_Change_ETag(TableUpdateMode updateMode) + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var entity1 = new TableEntity(partitionKey, "rk") { ["TestProperty"] = 42 }; + + tableClient.AddEntity(entity1); + + var entity2 = tableClient.GetEntity(partitionKey, "rk").Value; + + entity2["TestProperty"] = 43; + + tableClient.UpsertEntity(entity2, updateMode); + + var entity3 = tableClient.GetEntity(partitionKey, "rk").Value; + + entity3.GetInt32("TestProperty").Should().Be(43); + entity3.ETag.Should().NotBe(entity2.ETag); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(TableUpdateMode.Replace, DisplayName = "Replace")] + [DataRow(TableUpdateMode.Merge, DisplayName = "Merge")] + public void UpsertEntity_For_Existing_Entity_Without_ETag_Should_Succeeed(TableUpdateMode updateMode) + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var entity1 = new TableEntity(partitionKey, "rk"); + var entity2 = new TableEntity(partitionKey, "rk"); + + tableClient.UpsertEntity(entity1, updateMode); + tableClient.UpsertEntity(entity2, updateMode); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(TableUpdateMode.Replace, DisplayName = "Replace")] + [DataRow(TableUpdateMode.Merge, DisplayName = "Merge")] + public void UpsertEntity_For_Existing_Entity_With_Different_ETag_Should_Succeed(TableUpdateMode updateMode) + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var eTag = new ETag("W/\"datetime'2024-06-19T14%3A29%3A09.5839429Z'\""); + + var entity1 = new TableEntity(partitionKey, "rk"); + var entity2 = new TableEntity(partitionKey, "rk") { ETag = eTag, ["test"] = 42 }; + + var act1 = () => tableClient.UpsertEntity(entity1, updateMode); + + act1.Should().NotThrow(); + + var act2 = () => tableClient.UpsertEntity(entity2, updateMode); // ETag should be ignored by the service. + + act2.Should().NotThrow(); + + tableClient.GetEntity(partitionKey, "rk").Value.GetInt32("test").Should().Be(42); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(TableUpdateMode.Replace, DisplayName = "Replace")] + [DataRow(TableUpdateMode.Merge, DisplayName = "Merge")] + public void UpsertEntity_For_Missing_Entity_With_ETag_Should_Succeed(TableUpdateMode updateMode) + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var eTag = new ETag("W/\"datetime'2024-06-19T14%3A29%3A09.5839429Z'\""); + + var entity = new TableEntity(partitionKey, "rk") { ETag = eTag }; + + var act = () => tableClient.UpsertEntity(entity, updateMode); // ETag should be ignored by the service. + + act.Should().NotThrow(); + + tableClient.GetEntity(partitionKey, "rk").Value.Should().NotBeNull(); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(TableUpdateMode.Replace, DisplayName = "Replace")] + [DataRow(TableUpdateMode.Merge, DisplayName = "Merge")] + public void UpdateEntity_For_Existing_Entity_Should_Succeed(TableUpdateMode updateMode) + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var entity = new TableEntity(partitionKey, "rk") { ["property1"] = 41 }; + + var addResponse = tableClient.AddEntity(entity); + + var eTagBeforeUpdate = addResponse.Headers.ETag; + + eTagBeforeUpdate.Should().NotBeNull(); + + entity["property1"] = 42; + + var updateResponse = tableClient.UpdateEntity(entity, eTagBeforeUpdate!.Value, updateMode); + + var eTagAfterUpdate = updateResponse.Headers.ETag; + + eTagAfterUpdate.Should().NotBeNull(); + eTagAfterUpdate.Should().NotBe(eTagBeforeUpdate); + + var fetchedEntity = tableClient.GetEntity(partitionKey, "rk").Value; + + fetchedEntity.GetInt32("property1").Should().Be(42); + + } + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow(TableUpdateMode.Replace, DisplayName = "Replace")] + [DataRow(TableUpdateMode.Merge, DisplayName = "Merge")] + public void UpdateEntity_For_Missing_Entity_Should_Fail(TableUpdateMode updateMode) + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKey = Guid.NewGuid().ToString(); + + var eTag = new ETag("W/\"datetime'2024-06-19T14%3A29%3A09.5839429Z'\""); + + var entity = new TableEntity(partitionKey, "rk") { ETag = eTag }; + + var act = () => tableClient.UpdateEntity(entity, entity.ETag, updateMode); + + var exception = act.Should() + .Throw() + .Where(e => e.Status == 404) + .Where(e => e.ErrorCode == "ResourceNotFound"); + } +} diff --git a/tests/Tests/Storage/Tables/TableClientTests_Query.cs b/tests/Tests/Storage/Tables/TableClientTests_Query.cs new file mode 100644 index 0000000..5fb815d --- /dev/null +++ b/tests/Tests/Storage/Tables/TableClientTests_Query.cs @@ -0,0 +1,187 @@ +using Azure.Data.Tables; + +using Microsoft.Extensions.Logging.Abstractions; + +using Spotflow.InMemory.Azure.Storage.Tables.Internals; + +using Tests.Utils; + +namespace Tests.Storage.Tables; + +[TestClass] +public class TableClientTests_Query +{ + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + public void Query_Entities_With_Linq_Should_Succeed() + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKeyPrefix = Guid.NewGuid().ToString(); + + tableClient.AddEntity(new TableEntity($"{partitionKeyPrefix}_A", "rk1")); + tableClient.AddEntity(new TableEntity($"{partitionKeyPrefix}_A", "rk2")); + tableClient.AddEntity(new TableEntity($"{partitionKeyPrefix}_B", "rk1")); + + var entities = tableClient.Query(e => e.PartitionKey == $"{partitionKeyPrefix}_A"); + + entities.Should().HaveCount(2).And.OnlyContain(e => e.PartitionKey == $"{partitionKeyPrefix}_A"); + } + + + [TestMethod] + [TestCategory(TestCategory.AzureInfra)] + [DataRow("PartitionKey eq '{prefix}_A'", 2, DisplayName = "Range query for single condition on standard property.")] + [DataRow("PartitionKey eq '{prefix}_A' and RowKey eq 'rk1'", 1, DisplayName = "Simple point query with some results.")] + [DataRow("PartitionKey eq '{prefix}_C' and RowKey eq 'rk1'", 0, DisplayName = "Simple point query without results.")] + [DataRow("PartitionKey eq '{prefix}_A' and (RowKey eq 'rk1' or RowKey eq 'rk2')", 2, DisplayName = "OR point query with all results.")] + [DataRow("PartitionKey eq '{prefix}_A' and (RowKey eq 'rk1' or RowKey eq 'rk4')", 1, DisplayName = "OR point query with some results.")] + [DataRow("PartitionKey eq '{prefix}_A' and (RowKey eq 'rk3' or RowKey eq 'rk4')", 0, DisplayName = "OR point query without results.")] + [DataRow("PartitionKey eq '{prefix}_A' and customProperty1 eq 'test'", 1, DisplayName = "Range query on custom string property.")] + [DataRow("PartitionKey eq '{prefix}_A' and customProperty2 eq 42", 1, DisplayName = "Range query on custom integer property.")] + [DataRow("PartitionKey eq '{prefix}_B' and customProperty3 eq 42.42d", 1, DisplayName = "Range query on custom double property.")] + [DataRow("PartitionKey eq '{prefix}_A' and RowKey ge 'rk1' and RowKey lt 'rk3'", 2, DisplayName = "Range query with RowKey range condition.")] + public void Query_Entities_With_String_Should_Succeed(string query, int numberOfResults) + { + var tableClient = ImplementationProvider.GetTableClient(); + + tableClient.CreateIfNotExists(); + + var partitionKeyPrefix = Guid.NewGuid().ToString(); + + tableClient.AddEntity(new TableEntity($"{partitionKeyPrefix}_A", "rk1") { ["customProperty1"] = "test" }); + tableClient.AddEntity(new TableEntity($"{partitionKeyPrefix}_A", "rk2") { ["customProperty2"] = 42 }); + tableClient.AddEntity(new TableEntity($"{partitionKeyPrefix}_B", "rk1") { ["customProperty3"] = 42.42d }); + + var prefixedQuery = query.Replace("{prefix}", partitionKeyPrefix); + + var entities = tableClient.Query(prefixedQuery); + + entities.Should().HaveCount(numberOfResults); + } + + [TestMethod] + public void Query_Matcher_Operators_For_Strings_Should_Be_Correct() + { + var entity = InMemoryTableEntity.CreateNew(new TableEntity("pk", "rk") { ["customProperty"] = "2024-04-12" }, TimeProvider.System); + + static TextQueryFilterMatcher matcher(string query) => new(query, NullLoggerFactory.Instance); + + var lessThan_success = matcher("customProperty lt '2024-04-13'"); + var lessThan_fail = matcher("customProperty lt '2024-04-12'"); + var lessThan_undefined = matcher("customProperty lt 1"); + var lessThanOrEqual_success = matcher("customProperty le '2024-04-12'"); + var lessThanOrEqual_fail = matcher("customProperty le '2024-04-11'"); + var lessThanOrEqual_undefined = matcher("customProperty le 1"); + var greaterThan_success = matcher("customProperty gt '2024-04-11'"); + var greaterThan_fail = matcher("customProperty gt '2024-04-12'"); + var greaterThan_undefined = matcher("customProperty gt 20250412"); + var greaterThanOrEqual_success = matcher("customProperty ge '2024-04-12'"); + var greaterThanOrEqual_fail = matcher("customProperty ge '2024-04-13'"); + var greaterThanOrEqual_undefined = matcher("customProperty ge 20250412"); + var equal_success = matcher("customProperty eq '2024-04-12'"); + var equal_fail = matcher("customProperty eq '2024-04-11'"); + var equal_undefined = matcher("customProperty eq 20240412"); + + lessThan_success.IsMatch(entity).Should().BeTrue(); + lessThan_fail.IsMatch(entity).Should().BeFalse(); + lessThan_undefined.IsMatch(entity).Should().BeFalse(); + lessThanOrEqual_success.IsMatch(entity).Should().BeTrue(); + lessThanOrEqual_fail.IsMatch(entity).Should().BeFalse(); + lessThanOrEqual_undefined.IsMatch(entity).Should().BeFalse(); + greaterThan_success.IsMatch(entity).Should().BeTrue(); + greaterThan_fail.IsMatch(entity).Should().BeFalse(); + greaterThan_undefined.IsMatch(entity).Should().BeFalse(); + greaterThanOrEqual_success.IsMatch(entity).Should().BeTrue(); + greaterThanOrEqual_fail.IsMatch(entity).Should().BeFalse(); + greaterThanOrEqual_undefined.IsMatch(entity).Should().BeFalse(); + equal_success.IsMatch(entity).Should().BeTrue(); + equal_fail.IsMatch(entity).Should().BeFalse(); + equal_undefined.IsMatch(entity).Should().BeFalse(); + } + + + [TestMethod] + public void Query_Matcher_Operators_For_Integers_Should_Be_Correct() + { + var entity = InMemoryTableEntity.CreateNew(new TableEntity("pk", "rk") { ["customProperty"] = 42 }, TimeProvider.System); + + static TextQueryFilterMatcher matcher(string query) => new(query, NullLoggerFactory.Instance); + + var lessThan_success = matcher("customProperty lt 43"); + var lessThan_fail = matcher("customProperty lt 42"); + var lessThan_undefined = matcher("customProperty lt '43'"); + var lessThanOrEqual_success = matcher("customProperty le 42"); + var lessThanOrEqual_fail = matcher("customProperty le 41"); + var lessThanOrEqual_undefined = matcher("customProperty le '42'"); + var greaterThan_success = matcher("customProperty gt 41"); + var greaterThan_fail = matcher("customProperty gt 42"); + var greaterThan_undefined = matcher("customProperty gt '41'"); + var greaterThanOrEqual_success = matcher("customProperty ge 42"); + var greaterThanOrEqual_fail = matcher("customProperty ge 43"); + var greaterThanOrEqual_undefined = matcher("customProperty ge '42'"); + var equal_success = matcher("customProperty eq 42"); + var equal_fail = matcher("customProperty eq 43"); + var equal_undefined = matcher("customProperty eq '42'"); + + lessThan_success.IsMatch(entity).Should().BeTrue(); + lessThan_fail.IsMatch(entity).Should().BeFalse(); + lessThan_undefined.IsMatch(entity).Should().BeFalse(); + lessThanOrEqual_success.IsMatch(entity).Should().BeTrue(); + lessThanOrEqual_fail.IsMatch(entity).Should().BeFalse(); + lessThanOrEqual_undefined.IsMatch(entity).Should().BeFalse(); + greaterThan_success.IsMatch(entity).Should().BeTrue(); + greaterThan_fail.IsMatch(entity).Should().BeFalse(); + greaterThan_undefined.IsMatch(entity).Should().BeFalse(); + greaterThanOrEqual_success.IsMatch(entity).Should().BeTrue(); + greaterThanOrEqual_fail.IsMatch(entity).Should().BeFalse(); + greaterThanOrEqual_undefined.IsMatch(entity).Should().BeFalse(); + equal_success.IsMatch(entity).Should().BeTrue(); + equal_fail.IsMatch(entity).Should().BeFalse(); + equal_undefined.IsMatch(entity).Should().BeFalse(); + } + + + [TestMethod] + public void Query_Matcher_Operators_For_Doubles_Should_Be_Correct() + { + var entity = InMemoryTableEntity.CreateNew(new TableEntity("pk", "rk") { ["customProperty"] = 3.141 }, TimeProvider.System); + + static TextQueryFilterMatcher matcher(string query) => new(query, NullLoggerFactory.Instance); + + var lessThan_success = matcher("customProperty lt 3.15"); + var lessThan_fail = matcher("customProperty lt 3.14"); + var lessThan_undefined = matcher("customProperty lt '3.13'"); + var lessThanOrEqual_success = matcher("customProperty le 3.141"); + var lessThanOrEqual_fail = matcher("customProperty le 3.13"); + var lessThanOrEqual_undefined = matcher("customProperty le '3.14'"); + var greaterThan_success = matcher("customProperty gt 3.13"); + var greaterThan_fail = matcher("customProperty gt 3.142"); + var greaterThan_undefined = matcher("customProperty gt '3.13'"); + var greaterThanOrEqual_success = matcher("customProperty ge 3.14"); + var greaterThanOrEqual_fail = matcher("customProperty ge 3.15"); + var greaterThanOrEqual_undefined = matcher("customProperty ge '3.14'"); + var equal_success = matcher("customProperty eq 3.141"); + var equal_fail = matcher("customProperty eq 3.15"); + var equal_undefined = matcher("customProperty eq '3.141'"); + + lessThan_success.IsMatch(entity).Should().BeTrue(); + lessThan_fail.IsMatch(entity).Should().BeFalse(); + lessThan_undefined.IsMatch(entity).Should().BeFalse(); + lessThanOrEqual_success.IsMatch(entity).Should().BeTrue(); + lessThanOrEqual_fail.IsMatch(entity).Should().BeFalse(); + lessThanOrEqual_undefined.IsMatch(entity).Should().BeFalse(); + greaterThan_success.IsMatch(entity).Should().BeTrue(); + greaterThan_fail.IsMatch(entity).Should().BeFalse(); + greaterThan_undefined.IsMatch(entity).Should().BeFalse(); + greaterThanOrEqual_success.IsMatch(entity).Should().BeTrue(); + greaterThanOrEqual_fail.IsMatch(entity).Should().BeFalse(); + greaterThanOrEqual_undefined.IsMatch(entity).Should().BeFalse(); + equal_success.IsMatch(entity).Should().BeTrue(); + equal_fail.IsMatch(entity).Should().BeFalse(); + equal_undefined.IsMatch(entity).Should().BeFalse(); + } +} diff --git a/tests/Tests/Storage/Tables/TableServiceClientTests.cs b/tests/Tests/Storage/Tables/TableServiceClientTests.cs new file mode 100644 index 0000000..153cbd8 --- /dev/null +++ b/tests/Tests/Storage/Tables/TableServiceClientTests.cs @@ -0,0 +1,51 @@ +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Resources; +using Spotflow.InMemory.Azure.Storage.Tables; + +namespace Tests.Storage.Tables; + +[TestClass] +public class TableServiceClientTests +{ + [TestMethod] + public void Constructor_With_Connection_String_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var connectionString = account.CreateConnectionString(); + + var client = new InMemoryTableServiceClient(connectionString, provider); + + AssertClientProperties(client, account); + } + + [TestMethod] + public void Constructor_With_Uri_Should_Succeed() + { + var provider = new InMemoryStorageProvider(); + + var account = provider.AddAccount(); + + var client = new InMemoryTableServiceClient(account.TableServiceUri, provider); + + AssertClientProperties(client, account); + } + + [TestMethod] + public void Construct_From_Account_Should_Succeed() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var client = InMemoryTableServiceClient.FromAccount(account); + + AssertClientProperties(client, account); + } + + private static void AssertClientProperties(InMemoryTableServiceClient client, InMemoryStorageAccount account) + { + client.Uri.Should().Be(account.TableServiceUri); + client.AccountName.Should().Be(account.Name); + } +} diff --git a/tests/Tests/Storage/Tables/TransactionTests.cs b/tests/Tests/Storage/Tables/TransactionTests.cs new file mode 100644 index 0000000..ad252f7 --- /dev/null +++ b/tests/Tests/Storage/Tables/TransactionTests.cs @@ -0,0 +1,37 @@ +using Azure.Data.Tables; + +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Tables; + +namespace Tests.Storage.Tables; + +[TestClass] +public class TransactionTests +{ + [TestMethod] + public void Transaction_With_Mixed_Actions_Should_Succeed() + { + var account = new InMemoryStorageProvider().AddAccount(); + + var tableClient = InMemoryTableClient.FromAccount(account, "TestTable"); + + tableClient.Create(); + + tableClient.AddEntity(new TableEntity("pk", "rk-1")); + tableClient.AddEntity(new TableEntity("pk", "rk-2")); + tableClient.AddEntity(new TableEntity("pk", "rk-3")); + + + var transaction = new TableTransactionAction[] + { + new(TableTransactionActionType.Add, new TableEntity("pk", "rk-4")), + new(TableTransactionActionType.Delete, new TableEntity("pk", "rk-1")), + new(TableTransactionActionType.UpsertReplace, new TableEntity("pk", "rk-5")), + new(TableTransactionActionType.UpsertMerge, new TableEntity("pk", "rk-6")), + new(TableTransactionActionType.UpdateReplace, new TableEntity("pk", "rk-2")) , + new(TableTransactionActionType.UpdateMerge, new TableEntity("pk", "rk-3")), + }; + + tableClient.SubmitTransaction(transaction); + } +} diff --git a/tests/Tests/Tests.csproj b/tests/Tests/Tests.csproj new file mode 100644 index 0000000..a7eb660 --- /dev/null +++ b/tests/Tests/Tests.csproj @@ -0,0 +1,34 @@ + + + + true + false + true + + + + + + + + + + + + + + + + + + + + + 32 + 4 + true + Microsoft.VisualStudio.TestTools.UnitTesting.ExecutionScope.MethodLevel + true + + + diff --git a/tests/Tests/Utils/AzureResourceProvider.cs b/tests/Tests/Utils/AzureResourceProvider.cs new file mode 100644 index 0000000..1e66b88 --- /dev/null +++ b/tests/Tests/Utils/AzureResourceProvider.cs @@ -0,0 +1,164 @@ +using Azure; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.KeyVault; +using Azure.ResourceManager.KeyVault.Models; +using Azure.ResourceManager.Resources; +using Azure.ResourceManager.ServiceBus; +using Azure.ResourceManager.Storage; +using Azure.ResourceManager.Storage.Models; + +namespace Tests.Utils; + +internal class AzureResourceProvider +{ + private readonly Lazy> _resourceGroup; + private readonly Lazy> _serviceBusResources; + private readonly Lazy> _storageAccountResource; + private readonly Lazy> _keyVaultResource; + + public AzureResourceProvider(AzureTestConfig.Values config) + { + Config = config; + + _resourceGroup = new(PrepareResourceGroupAsync); + _serviceBusResources = new(PrepareServiceBusResourcesAsync); + _storageAccountResource = new(PrepareStorageAccountResourceAsync); + _keyVaultResource = new(PrepareKeyVaultResourceAsync); + } + + public AzureTestConfig.Values Config { get; } + + public async Task InitializeAsync() + { + var tasks = new Task[] + { + _resourceGroup.Value, + _storageAccountResource.Value, + _serviceBusResources.Value, + _keyVaultResource.Value + }; + + await Task.WhenAll(tasks); + } + + public Task GetStorageAccountAsync() => _storageAccountResource.Value; + public Task GetServiceBusResources() => _serviceBusResources.Value; + + private async Task PrepareResourceGroupAsync() + { + var armClient = new ArmClient(Config.TokenCredential); + var subscription = armClient.GetSubscriptionResource(ResourceIdentifier.Parse($"/subscriptions/{Config.SubscriptionId}")); + var resourceGroup = await subscription.GetResourceGroupAsync(Config.ResourceGroupName); + return resourceGroup.Value; + } + + private async Task PrepareStorageAccountResourceAsync() + { + var resourceGroup = await _resourceGroup.Value; + + var sku = new StorageSku(StorageSkuName.StandardLrs); + + var storageData = new StorageAccountCreateOrUpdateContent(sku, StorageKind.StorageV2, resourceGroup.Data.Location) + { + AccessTier = StorageAccountAccessTier.Hot, + EnableHttpsTrafficOnly = true, + AllowBlobPublicAccess = false, + IsHnsEnabled = false, + AllowSharedKeyAccess = true, + }; + + var storageAccount = await resourceGroup + .GetStorageAccounts() + .CreateOrUpdateAsync(WaitUntil.Completed, Config.StorageAccountName, storageData); + + var managementPolicyRule = new ManagementPolicyRule( + "cleanup", + ManagementPolicyRuleType.Lifecycle, + new(new() { BaseBlob = new() { Delete = new() { DaysAfterCreationGreaterThan = 14 } } }) { Filters = new(["blockBlob"]) }); + + var managementPolicyData = new StorageAccountManagementPolicyData + { + Rules = [managementPolicyRule] + }; + + await storageAccount.Value + .GetStorageAccountManagementPolicy() + .CreateOrUpdateAsync(WaitUntil.Completed, managementPolicyData); + + return storageAccount.Value; + } + + private async Task PrepareServiceBusResourcesAsync() + { + var resourceGroup = await _resourceGroup.Value; + + var serviceBusNamespace = await resourceGroup + .GetServiceBusNamespaces() + .CreateOrUpdateAsync(WaitUntil.Completed, Config.ServiceBusNamespaceName, data: new(resourceGroup.Data.Location)); + + var serviceBusNamespaceFqn = serviceBusNamespace.Value.Data.ServiceBusEndpoint; + + var queues = serviceBusNamespace.Value.GetServiceBusQueues(); + var topics = serviceBusNamespace.Value.GetServiceBusTopics(); + + var ttl = TimeSpan.FromMinutes(30); + + var queueWithSessionsTask = queues.CreateOrUpdateAsync(WaitUntil.Completed, "test-queue-with-sessions", data: new() { RequiresSession = true, DefaultMessageTimeToLive = ttl }); + var queueWithoutSessionsTask = queues.CreateOrUpdateAsync(WaitUntil.Completed, "test-queue-without-sessions", data: new() { RequiresSession = false, DefaultMessageTimeToLive = ttl }); + + var topicTask = topics.CreateOrUpdateAsync(WaitUntil.Completed, "test-topic", data: new() { }); + + var topic = await topicTask; + + var subscriptions = topic.Value.GetServiceBusSubscriptions(); + + var subWithSessionsTask = subscriptions.CreateOrUpdateAsync(WaitUntil.Completed, "test-subscription-with-sessions", data: new() { RequiresSession = true, DefaultMessageTimeToLive = ttl }); + var subWithoutSessionsTask = subscriptions.CreateOrUpdateAsync(WaitUntil.Completed, "test-subscription-without-sessions", data: new() { RequiresSession = false, DefaultMessageTimeToLive = ttl }); + + return new() + { + FullyQualifiedNamespaceName = serviceBusNamespaceFqn, + QueueWithSessions = (await queueWithSessionsTask).Value, + QueueWithoutSessions = (await queueWithoutSessionsTask).Value, + SubscriptionWithSessions = (await subWithSessionsTask).Value, + SubscriptionWithoutSessions = (await subWithoutSessionsTask).Value + }; + } + + private async Task PrepareKeyVaultResourceAsync() + { + var resourceGroup = await _resourceGroup.Value; + + var content = new KeyVaultCreateOrUpdateContent( + resourceGroup.Data.Location, + new(Guid.Parse(Config.TenantId), new KeyVaultSku(KeyVaultSkuFamily.A, KeyVaultSkuName.Standard)) { EnableRbacAuthorization = true }); + + var keyVault = await resourceGroup + .GetKeyVaults() + .CreateOrUpdateAsync(WaitUntil.Completed, Config.KeyVaultName, content); + + return keyVault.Value; + } + + + public class ServiceBusResources + { + public required string FullyQualifiedNamespaceName { get; init; } + public required ServiceBusQueueResource QueueWithSessions { get; init; } + public required ServiceBusQueueResource QueueWithoutSessions { get; init; } + public required ServiceBusSubscriptionResource SubscriptionWithSessions { get; init; } + public required ServiceBusSubscriptionResource SubscriptionWithoutSessions { get; init; } + + public ArmResource GetEntity(bool withSessions, bool useTopics) + { + return (withSessions, useTopics) switch + { + (true, true) => SubscriptionWithSessions, + (true, false) => QueueWithSessions, + (false, true) => SubscriptionWithoutSessions, + (false, false) => QueueWithoutSessions, + }; + } + } +} diff --git a/tests/Tests/Utils/AzureTestConfig.cs b/tests/Tests/Utils/AzureTestConfig.cs new file mode 100644 index 0000000..1df8892 --- /dev/null +++ b/tests/Tests/Utils/AzureTestConfig.cs @@ -0,0 +1,74 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Core; +using Azure.Identity; + +namespace Tests.Utils; + +internal static class AzureTestConfig +{ + public class Values + { + public required string TenantId { get; init; } + public required string SubscriptionId { get; init; } + public required string ResourceGroupName { get; init; } + public required string StorageAccountName { get; init; } + public required Uri BlobServiceUri { get; init; } + public required Uri TableServiceUri { get; init; } + public required string ServiceBusNamespaceName { get; init; } + public required string KeyVaultName { get; init; } + public required Uri KeyVaultUri { get; init; } + public required TokenCredential TokenCredential { get; init; } + } + + private static readonly Values? _values; + + static AzureTestConfig() + { + if (!UseAzure()) + { + return; + } + + var tenantId = GetRequiredString("AZURE_TENANT_ID"); + + var storageAccountName = GetRequiredString("AZURE_STORAGE_ACCOUNT_NAME"); + var keyVaultName = GetRequiredString("AZURE_KEY_VAULT_NAME"); + + _values = new() + { + TenantId = tenantId, + SubscriptionId = GetRequiredString("AZURE_SUBSCRIPTION_ID"), + ResourceGroupName = GetRequiredString("AZURE_RESOURCE_GROUP_NAME"), + StorageAccountName = storageAccountName, + BlobServiceUri = new($"https://{storageAccountName}.blob.core.windows.net/"), + TableServiceUri = new($"https://{storageAccountName}.table.core.windows.net/"), + ServiceBusNamespaceName = GetRequiredString("AZURE_SERVICE_BUS_NAMESPACE_NAME"), + KeyVaultName = keyVaultName, + KeyVaultUri = new($"https://{keyVaultName}.vault.azure.net/"), + TokenCredential = new AzureCliCredential(options: new() { TenantId = tenantId }) + }; + } + + public static bool IsAvailable([NotNullWhen(true)] out Values? result) => (result = _values) is not null; + + private static bool UseAzure() + { + var flag = Environment.GetEnvironmentVariable("SPOTFLOW_USE_AZURE") ?? "false"; + return flag.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + private static string GetRequiredString(string environmentVariableName) + { + var value = Environment.GetEnvironmentVariable(environmentVariableName); + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"Environment variable '{environmentVariableName}' is must be set when using Azure."); + } + + return value; + } +} + + diff --git a/tests/Tests/Utils/ImplementationProvider.cs b/tests/Tests/Utils/ImplementationProvider.cs new file mode 100644 index 0000000..94ed6c5 --- /dev/null +++ b/tests/Tests/Utils/ImplementationProvider.cs @@ -0,0 +1,182 @@ +using System.Diagnostics.CodeAnalysis; + +using Azure.Data.Tables; +using Azure.Messaging.ServiceBus; +using Azure.Security.KeyVault.Secrets; +using Azure.Storage.Blobs; + +using Spotflow.InMemory.Azure.Auth; +using Spotflow.InMemory.Azure.KeyVault; +using Spotflow.InMemory.Azure.KeyVault.Secrets; +using Spotflow.InMemory.Azure.ServiceBus; +using Spotflow.InMemory.Azure.Storage; +using Spotflow.InMemory.Azure.Storage.Blobs; +using Spotflow.InMemory.Azure.Storage.Tables; + +namespace Tests.Utils; + +internal static class ImplementationProvider +{ + public static bool IsAzureConfigAvailable => AzureTestConfig.IsAvailable(out _); + + private static readonly AzureResourceProvider? _resourceProvider; + + static ImplementationProvider() + { + if (AzureTestConfig.IsAvailable(out var config)) + { + _resourceProvider = new(config); + } + } + + public static BlobContainerClient GetBlobContainerClient(string? containerName = null) + { + containerName ??= "test"; + + if (TryWithAzure(out var config)) + { + var serviceClient = new BlobServiceClient(config.BlobServiceUri, config.TokenCredential); + return serviceClient.GetBlobContainerClient(containerName); + } + else + { + var account = new InMemoryStorageProvider().AddAccount(); + return InMemoryBlobContainerClient.FromAccount(account, containerName); + } + } + + public static BlobServiceClient GetBlobServiceClient() + { + if (TryWithAzure(out var config)) + { + return new BlobServiceClient(config.BlobServiceUri, config.TokenCredential); + } + else + { + var account = new InMemoryStorageProvider().AddAccount(); + return InMemoryBlobServiceClient.FromAccount(account); + } + } + + public static TableClient GetTableClient(string? tableName = null) + { + tableName ??= "test"; + + if (TryWithAzure(out var config)) + { + var serviceClient = new TableServiceClient(config.TableServiceUri, config.TokenCredential); + return serviceClient.GetTableClient(tableName); + } + else + { + var account = new InMemoryStorageProvider().AddAccount(); + return InMemoryTableClient.FromAccount(account, tableName); + } + } + + public static async Task GetStorageConnectionString() + { + if (!TryWithAzure(out _, out var provider)) + { + throw new InvalidOperationException("Azure configuration is not available."); + } + + var storageAccount = await provider.GetStorageAccountAsync(); + + await foreach (var key in storageAccount.GetKeysAsync()) + { + return $"AccountName={storageAccount.Data.Name};AccountKey={key.Value}"; + } + + throw new InvalidOperationException("No storage account keys found."); + } + + + public static async Task GetServiceBusSenderAsync(bool withSessions = false, bool missingEntity = false, bool useTopics = false, bool missingNamespace = false) + { + if (TryWithAzure(out var config, out var provider)) + { + if (missingNamespace) + { + var serviceClient = new ServiceBusClient($"{Guid.NewGuid()}.servicebus.windows.net", config.TokenCredential); + return serviceClient.CreateSender("test-queue"); + } + else + { + var serviceBusResources = await provider.GetServiceBusResources(); + + var serviceClient = new ServiceBusClient(serviceBusResources.FullyQualifiedNamespaceName, config.TokenCredential); + + var entityName = missingEntity ? Guid.NewGuid().ToString() : serviceBusResources.GetEntity(withSessions, useTopics).Id.Name; + + return serviceClient.CreateSender(entityName); + } + } + else + { + if (missingNamespace) + { + var inMemoryProvider = new InMemoryServiceBusProvider(); + var serviceClient = new InMemoryServiceBusClient("non-existing.servicebus.in-memory.example.com", NoOpTokenCredential.Instance, inMemoryProvider); + return serviceClient.CreateSender("test-entity"); + } + else + { + var ns = new InMemoryServiceBusProvider().AddNamespace(); + + var entityName = Guid.NewGuid().ToString(); + + if (!missingEntity) + { + if (useTopics) + { + ns.AddTopic(entityName).AddSubscription("test-subscription", options: new() { EnableSessions = withSessions }); + } + else + { + ns.AddQueue(entityName, options: new() { EnableSessions = withSessions }); + } + } + + var client = new InMemoryServiceBusClient(ns.FullyQualifiedNamespace, NoOpTokenCredential.Instance, ns.Provider); + + return client.CreateSender(entityName); + } + } + } + + + + public static SecretClient GetSecretClient() + { + if (TryWithAzure(out var config)) + { + return new SecretClient(config.KeyVaultUri, config.TokenCredential); + } + else + { + var vault = new InMemoryKeyVaultProvider().AddVault(); + return InMemorySecretClient.FromVault(vault); + } + } + private static bool TryWithAzure([NotNullWhen(true)] out AzureTestConfig.Values? config) => TryWithAzure(out config, out _); + + private static bool TryWithAzure([NotNullWhen(true)] out AzureTestConfig.Values? config, [NotNullWhen(true)] out AzureResourceProvider? provider) + { + if (_resourceProvider is null) + { + config = null; + provider = null; + return false; + } + + _resourceProvider.InitializeAsync().Wait(); + + config = _resourceProvider.Config; + provider = _resourceProvider; + return true; + } + + + +} diff --git a/tests/Tests/Utils/TestCategory.cs b/tests/Tests/Utils/TestCategory.cs new file mode 100644 index 0000000..2d91848 --- /dev/null +++ b/tests/Tests/Utils/TestCategory.cs @@ -0,0 +1,6 @@ +namespace Tests.Utils; + +public static class TestCategory +{ + public const string AzureInfra = "AzureInfra"; +}