diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..cd967fc3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.github/workflows/dockerize_host.yml b/.github/workflows/dockerize_host.yml new file mode 100644 index 00000000..cb3c2c21 --- /dev/null +++ b/.github/workflows/dockerize_host.yml @@ -0,0 +1,42 @@ +# This builds a .net 8 app using src/Imageflow.Server.Host/Dockerfile +# using nowsprinting/check-version-format-action@v3 to determine the version +# and uploads it to docker hub + +# This is the docker image name +name: Build Docker Image for Imageflow.Server.Host +on: + push: + branches: [ main ] + release: + types: [ published ] +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Docker Build + uses: docker/setup-buildx-action@v1 + + - name: Get version + uses: nowsprinting/check-version-format-action@v3 + id: version + with: + prefix: 'v' + + - name: Login + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + if: steps.version.outputs.is_valid == 'true' && github.event_name == 'release' + uses: docker/build-push-action@v5 + with: + context: . + file: ./src/Imageflow.Server.Host/Dockerfile + push: true + tags: your-dockerhub-username/imageflow-server-host:${{ steps.get_version.outputs.version }} \ No newline at end of file diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml index 55881fc1..ff6e7237 100644 --- a/.github/workflows/dotnet-core.yml +++ b/.github/workflows/dotnet-core.yml @@ -2,31 +2,24 @@ name: Build on: push: - branches: [ main ] pull_request: - branches: [ main ] release: types: [ published ] jobs: - build: + test: strategy: fail-fast: false matrix: + os: [ubuntu-20.04, macos-11.0, windows-latest] include: - - name: ubuntu-20.04 - os: ubuntu-20.04 - uploader: true - cache_dependencies: true - - - name: osx_11.0-x86_64 - os: macos-11.0 - cache_dependencies: true - - - name: win-x86_64 - os: windows-latest - cache_dependencies: false - + - os: windows-latest + pack: true + docs: true + dotnet: all + coverage: true + - os: ubuntu-latest + id: ubuntu-latest runs-on: ${{matrix.os}} steps: - uses: actions/checkout@v3 @@ -35,18 +28,34 @@ jobs: with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-breaker3-${{ hashFiles('**/packages.lock.json') }} - if: matrix.cache_dependencies + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Check .NET versions available to see if we can skip installs + shell: bash + run: | + echo "DOTNET_VERSION_LIST<> $GITHUB_ENV + dotnet --list-sdks >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Setup .NET 6, 7, 8 + uses: actions/setup-dotnet@v3 + if: contains(env.DOTNET_VERSION_LIST, '6.0.') == 'false' || contains(env.DOTNET_VERSION_LIST, '7.0.') == 'false' || contains(env.DOTNET_VERSION_LIST, '8.0.') == 'false' + with: + dotnet-version: "6\n7\n8\n" + + - name: Setup .NET 4.8.1 if on windows + uses: actions/setup-dotnet@v3 + if: matrix.os == 'windows-latest' && contains(env.DOTNET_VERSION_LIST, '4.8.1') == 'false' + with: + dotnet-version: '4.8.1' - uses: nowsprinting/check-version-format-action@v3 id: version with: prefix: 'v' - - name: Upload planned for Nuget.org? - run: echo "This runner will upload to Nuget.org if tests pass" - if: matrix.uploader && github.event_name == 'release' && steps.version.outputs.is_valid == 'true' - - - name: Set the release version (if applicable) + - name: Set the release version (if applicable) - The source code depends on these run: | echo "TAGGED_VERSION=${{ steps.version.outputs.full_without_prefix }}" >> $GITHUB_ENV echo "ARTIFACT_VERSION=${{ steps.version.outputs.full_without_prefix }}" >> $GITHUB_ENV @@ -61,28 +70,8 @@ jobs: echo Set ARTIFACT_VERSION to commit-${{ github.sha }} shell: bash if: steps.version.outputs.is_valid == 'false' || github.event_name != 'release' - - - name: Set the Imageflow.Server.Host zip file name - run: | - echo "HOST_ZIP_FILE=Imageflow.Server.Host-${{matrix.os}}-${{ env.ARTIFACT_VERSION }}.zip" >> $GITHUB_ENV - echo Set HOST_ZIP_FILE to ${{ env.HOST_ZIP_FILE }} - shell: bash - - - name: Check .NET versions available to see if we can skip install - shell: bash - run: | - echo "DOTNET_VERSION_LIST<> $GITHUB_ENV - dotnet --list-sdks >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - name: Setup .NET 6.0.x and 7.0.x - uses: actions/setup-dotnet@v2 - if: contains(env.DOTNET_VERSION_LIST, '6.0') == 'false' || contains(env.DOTNET_VERSION_LIST, '7.0.') == 'false' - with: - dotnet-version: | - 6 - 7 - + - name: Clear & clean on release or cache miss run: | dotnet clean --configuration Release @@ -92,22 +81,70 @@ jobs: - name: Restore packages run: dotnet restore --force-evaluate - - name: Build + - name: Build all if on windows run: dotnet build --maxcpucount:1 -c Release - - - name: Test - run: dotnet test -c Release --blame --no-build + if: matrix.os == 'windows-latest' + + - name: Build for .NET 8, 6 + run: dotnet build --maxcpucount:1 -c Release --framework net6.0 net8.0 + if: matrix.os != 'windows-latest' + + - name: Test .NET 6, 8 without Coverage + if: matrix.os != 'windows-latest' + run: | + dotnet test -c Release --framework net6.0 + dotnet test -c Release --framework net8.0 - uses: actions/upload-artifact@v3 if: failure() with: name: TestResults-${{matrix.os}}-${{ env.ARTIFACT_VERSION }} path: TestResults/ + + - name: Test .NET 8, 6, and 4.8.1 with Coverage + if: matrix.os == 'windows-latest' + run: | + dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=./TestResults/coverage.net48.opencover.xml --framework net48 + dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=./TestResults/coverage.net6.opencover.xml --framework net6.0 + dotnet test -c Release /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=./TestResults/coverage.net8.opencover.xml --framework net8.0 - - name: Publish Host App - run: dotnet publish -c Release -o host/publish/ src/Imageflow.Server.Host/Imageflow.Server.Host.csproj + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + if: matrix.os == 'windows-latest' + with: + files: ./TestResults/coverage.**.opencover.xml + token: ${{ secrets.CODECOV_TOKEN }} # replace with your Codecov token + fail_ci_if_error: false + + - uses: actions/upload-artifact@v3 + if: failure() + with: + name: TestResults-${{matrix.os}}-${{ env.ARTIFACT_VERSION }} + path: TestResults/ + + - name: Pack + run: dotnet pack -c Release --include-source + if: matrix.pack - # Zip the contents of folder host/publish/ into host/Imageflow.Server.Host-${{matrix.os}}-${{ env.ARTIFACT_VERSION }}.zip + - name: Upload artifacts + uses: actions/upload-artifact@v4 + id: nuget-artifact-upload-step + if: npack + with: + name: NuGetPackages + path: NuGetPackages/Release/*.nupkg + + ############################ We build the host for every OS, but only upload it for release ############################ + - name: Set the Imageflow.Server.Host zip file name + run: | + echo "HOST_ZIP_FILE=Imageflow.Server.Host-${{matrix.os}}-${{ env.ARTIFACT_VERSION }}.zip" >> $GITHUB_ENV + echo Set HOST_ZIP_FILE to ${{ env.HOST_ZIP_FILE }} + shell: bash + + - name: Create Host App Folder + run: dotnet publish -c Release -o host/publish/ src/Imageflow.Server.Host/Imageflow.Server.Host.csproj --framework net8.0 + + # Zip the contents of folder host/publish/ into host/Imageflow.Server.Host-${{matrix.os}}-${{ env.ARTIFACT_VERSION }}.zip - name: Zip Server.Host uses: thedoctor0/zip-release@0.7.1 with: @@ -115,16 +152,16 @@ jobs: directory: 'host/publish/' filename: '../${{env.HOST_ZIP_FILE}}' path: '.' - - # Upload the publish folder for src/Imageflow.Server.Host to the release artifacts - - name: Upload Imageflow.Server.Host to artifacts - uses: actions/upload-artifact@v3 + + - name: Upload Imageflow.Server.Host to job + uses: actions/upload-artifact@v4 + id: host-upload-step if: success() with: name: ${{env.HOST_ZIP_FILE}} path: host/${{env.HOST_ZIP_FILE}} - - # If this is a release, upload it to the github release page using the git + + # If this is a release, upload it to the github release page using the git - name: Upload Imageflow.Server.Host to release uses: Shopify/upload-to-release@v1.0.1 if: steps.version.outputs.is_valid == 'true' && github.event_name == 'release' @@ -133,12 +170,89 @@ jobs: path: host/${{env.HOST_ZIP_FILE}} repo-token: ${{ secrets.GITHUB_TOKEN }} content-type: application/zip + + - name: Install DocFX + run: dotnet tool install --global docfx + if: matrix.docs + - name: Generate Documentation + run: docfx src/DocFx/docfx.json --output docs + if: matrix.docs + - name: Upload documentation to job + id: docs-upload-step + uses: actions/upload-artifact@v4 + if: success() && matrix.docs + with: + name: Documentation + path: docs/ - - name: Pack - run: dotnet pack -c Release --include-source - + outputs: + host_artifact-id: ${{ steps.host-upload-step.outputs.artifact_id }} + nuget_artifact-id: ${{ steps.nuget-artifact-upload-step.outputs.artifact_id }} + docs_artifact-id: ${{ steps.docs-upload-step.outputs.artifact_id }} + + + publish: + needs: test + if: github.event_name == 'release' + + runs-on: ubuntu-latest + steps: + - uses: nowsprinting/check-version-format-action@v3 + id: version + with: + prefix: 'v' + + # Download nuget artifacts from the test job into a folder called NuGetPackages + - name: Download artifacts + uses: actions/download-artifact@v4 + if: steps.version.outputs.is_valid == 'true' && github.event_name == 'release' + with: + name: NuGetPackages + path: NuGetPackages + - name: Publish NuGet packages to Nuget.org - if: steps.version.outputs.is_valid == 'true' && github.event_name == 'release' && matrix.uploader + if: steps.version.outputs.is_valid == 'true' && github.event_name == 'release' run: | - dotnet nuget push bin/Release/*.nupkg --skip-duplicate --api-key ${{ secrets.NUGET_API_KEY }} --source nuget.org + dotnet nuget push NuGetPackages/*.nupkg --skip-duplicate --api-key ${{ secrets.NUGET_API_KEY }} --source nuget.org + + - name: Download Documentation + uses: actions/download-artifact@v4 + if: steps.version.outputs.is_valid == 'true' && github.event_name == 'release' + with: + name: Documentation + path: docs + + - name: Deploy Documentation to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs + + dockerize_host: + needs: test + if: github.event_name == 'release' + + runs-on: ubuntu-latest + steps: + - uses: nowsprinting/check-version-format-action@v3 + id: version + with: + prefix: 'v' + # download artifact using the host artifact id from the test job, the ubuntu-latest matrix version + - name: Download Imageflow.Server.Host + uses: actions/download-artifact@v4 + if: steps.version.outputs.is_valid == 'true' && github.event_name == 'release' + with: + name: ${{ steps.test.outputs.host_artifact-id }} + path: host + + # Create a docker image for the host and push, using the host artifact id from the test job, the ubuntu-latest matrix version + + + + ## Create a docker image for the host and push, using the host artifact id from the test job, the ubuntu-latest matrix version + + + + diff --git a/.gitignore b/.gitignore index bbad74e4..aedcc692 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ bld/ [Oo]bj/ [Ll]og/ host/ +NugetPackages/ +test-publish/ # DocFX build results _site/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..f244c54a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/tests/Imageflow.Server.Tests/bin/Debug/net7.0/Imageflow.Server.Tests.dll", + "args": [], + "cwd": "${workspaceFolder}/tests/Imageflow.Server.Tests", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..302f0234 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "cSpell.words": [ + "Imazen", + "Subfolders" + ], + "cSpell.ignoreWords": [ + "sthree" + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..b07271ba --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Imageflow.Server.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/Imageflow.Server.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/Imageflow.Server.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000..7398b6f0 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,36 @@ + +#### Breaking changes in v0.9 rewrite + +This version is a complete rewrite of the product to enable tons of key scenarios. + +# Rewrite, PreRewriteAuth and PostRewriteAuth have changed. +UrlEventArgs.Query was Dictionary and is now IDictionary +UrlEventArgs.Context is no longer available. Use UrlEventArgs.Request.OriginalRequest to access +some of the same properties. + +Alternate idea: .Context is (object) instead. It could be a HttpContext, or not. + +I'm currently bogged down in a massive Imageflow Server refactor that will +enable a lot of new scenarios (and be usable from .NET 4.8 as well as .NET +Standard 2, .NET 6-8, but it also heavily architected. It creates new nuget +packages Imazen.Routing and Imazen.Abstractions, with the intent to support +serverless/one-shot/AOT mode as well as long-running server processes that +can apply a lot more heuristics and optimizations. It introduces a ton of +new interfaces and establishes a mini-framework for blobs with optimized +routing, resource dependency discovery for smart cache keys, etags, smart +buffering, async cache writes, memory caching, blob caching, Pipelines +support, cache invalidation, search, audit trails, and purging, better +non-image blob handling, intuive route->provider mapping, and can support +future real-time resilience features like circuit breaker, multi-cache +choice, and possibly offloading jobs to other servers in the cluster or +serverless functions. It will autoconfigure s3 and azure containers for +maximum throughout and automatic eviction of expired unused entries. + +This also lays the foundation for AI salience detection and storage of +metadata like this anchor value, so that it could automatically happen if a +salience backend/service and cache service are registered. + +It's a lot, and I could use some code review when I wrap up the first +preview. I think it would be usable directly in your packages that are +alternatives to ImageResizer. I could probably do this side quest for +anchor support once I have a preview shipped and reviewed. diff --git a/Directory.Build.props b/Directory.Build.props index 43d30d80..02a5d54a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ - Copyright © 2023 Imazen LLC + Copyright © 2024 Imazen LLC https://github.com/imazen/imageflow-dotnet-server $(GITHUB_SERVER_URL)/$(GITHUB_REPOSITORY) $(GITHUB_SHA) @@ -36,13 +36,9 @@ true - - true - - @@ -50,5 +46,4 @@ NU1603 true - \ No newline at end of file diff --git a/Imageflow.Server.sln b/Imageflow.Server.sln index 004fe79c..5f95a957 100644 --- a/Imageflow.Server.sln +++ b/Imageflow.Server.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29503.13 @@ -12,6 +12,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution src\NugetPackages.targets = src\NugetPackages.targets Directory.Build.props = Directory.Build.props CONFIGURATION.md = CONFIGURATION.md + src\NugetPackageDefaults.targets = src\NugetPackageDefaults.targets + src\ProjectDefaults.targets = src\ProjectDefaults.targets + CHANGES.md = CHANGES.md + tests\TestDefaults.targets = tests\TestDefaults.targets + src\design_scratch.md = src\design_scratch.md + .github\workflows\dockerize_host.yml = .github\workflows\dockerize_host.yml EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Imazen.Common", "src\Imazen.Common\Imazen.Common.csproj", "{49BDDBEE-3385-4B1D-B7C1-76A893F25C00}" @@ -38,7 +44,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imageflow.Server.ExampleDoc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imageflow.Server.ExampleDockerDiskCache", "examples\Imageflow.Server.ExampleDockerDiskCache\Imageflow.Server.ExampleDockerDiskCache.csproj", "{2AD8CC34-5302-4E17-B6D0-F5E47FA49E22}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imazen.Common.Tests", "tests\Imazen.Common.Tests\Imazen.Common.Tests.csproj", "{8ADC01E1-1965-41EF-BA97-2E5DD2302293}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImazenShared.Tests", "tests\ImazenShared.Tests\ImazenShared.Tests.csproj", "{8ADC01E1-1965-41EF-BA97-2E5DD2302293}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imazen.HybridCache", "src\Imazen.HybridCache\Imazen.HybridCache.csproj", "{02128BFA-C4AC-4CC8-8417-D84C4B699803}" EndProject @@ -54,6 +60,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imageflow.Server.Host", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imageflow.Server.Configuration.Tests", "tests\Imageflow.Server.Configuration.Tests\Imageflow.Server.Configuration.Tests.csproj", "{200CD4DA-C49B-413B-8FCF-8DA73A2803E0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imazen.Routing", "src\Imazen.Routing\Imazen.Routing.csproj", "{5EF0F9EA-D6DE-4E34-9141-4C44DA08A726}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Imazen.Abstractions", "src\Imazen.Abstractions\Imazen.Abstractions.csproj", "{A04B9BE0-4931-4305-B9AB-B79737130F20}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -140,6 +150,14 @@ Global {200CD4DA-C49B-413B-8FCF-8DA73A2803E0}.Debug|Any CPU.Build.0 = Debug|Any CPU {200CD4DA-C49B-413B-8FCF-8DA73A2803E0}.Release|Any CPU.ActiveCfg = Release|Any CPU {200CD4DA-C49B-413B-8FCF-8DA73A2803E0}.Release|Any CPU.Build.0 = Release|Any CPU + {5EF0F9EA-D6DE-4E34-9141-4C44DA08A726}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5EF0F9EA-D6DE-4E34-9141-4C44DA08A726}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5EF0F9EA-D6DE-4E34-9141-4C44DA08A726}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5EF0F9EA-D6DE-4E34-9141-4C44DA08A726}.Release|Any CPU.Build.0 = Release|Any CPU + {A04B9BE0-4931-4305-B9AB-B79737130F20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A04B9BE0-4931-4305-B9AB-B79737130F20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A04B9BE0-4931-4305-B9AB-B79737130F20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A04B9BE0-4931-4305-B9AB-B79737130F20}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Imageflow.Server.sln.DotSettings b/Imageflow.Server.sln.DotSettings index b59d1d64..be0001c3 100644 --- a/Imageflow.Server.sln.DotSettings +++ b/Imageflow.Server.sln.DotSettings @@ -15,6 +15,7 @@ True True True + True True True True diff --git a/README.md b/README.md index acc051cd..4718900a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ [![.NET 6/7](https://github.com/imazen/imageflow-dotnet-server/workflows/Build/badge.svg)](https://github.com/imazen/imageflow-dotnet-server/actions/workflows/dotnet-core.yml) -#### Imageflow.NET Server is image processing and optimizing middleware for ASP.NET 6 & 7. +#### Imageflow.NET Server is image processing and optimizing middleware for ASP.NET 6, 7, & 8. -** Try [the new configuration system](https://github.com/imazen/imageflow-dotnet-server/blob/main/CONFIGURATION.md) to avoid a build step & C#! ** +** Try [the new configuration system](https://github.com/imazen/imageflow-dotnet-server/blob/main/CONFIGURATION.md) to avoid a build step & C#! ** [Check out breaking changes in 0.9](https://github.com/imazen/imageflow-dotnet-server/blob/main/CHANGES.md) If you don't need an HTTP server, [try Imageflow.NET](https://github.com/imazen/imageflow-dotnet). If you don't want to use .NET, try [Imageflow](https://imageflow.io), which has a server, command-line tool, and library with language bindings for Go, C, Rust, Node, Ruby and more. Imageflow is specifically designed for web servers and focuses on security, quality, and performance. @@ -41,6 +41,11 @@ All operations are designed to be fast enough for on-demand use. * Production-ready for trusted image files. +### Custom cache layers + +* For example, you can cache source images pulled from remote HTTP servers to Amazon S3. +* You can also layer multiple caches on top of each other, such as a disk cache and a blob cache. So if the result image isn't found in the low-latency disk cache, you can check the blob cache before generating it. + ### License We offer commercial licenses at https://imageresizing.net/pricing, or you can use @@ -370,4 +375,12 @@ namespace Imageflow.Server.Example The following platforms / solutions have a direct integration -1. **Oqtane Blazor platform** ([website](https://oqtane.org/) / [docs](https://docs.oqtane.org/) / [git](https://github.com/oqtane/oqtane.framework)): Use the **ToSic.ImageFlow.Oqtane** ([git](https://github.com/2sic/oqtane-imageflow) / [nuget](https://www.nuget.org/packages/ToSic.Imageflow.Oqtane/)) middleware. The package will automatically appear in Oqtane as an installable extension. \ No newline at end of file +1. **Oqtane Blazor platform** ([website](https://oqtane.org/) / [docs](https://docs.oqtane.org/) / [git](https://github.com/oqtane/oqtane.framework)): Use the **ToSic.ImageFlow.Oqtane** ([git](https://github.com/2sic/oqtane-imageflow) / [nuget](https://www.nuget.org/packages/ToSic.Imageflow.Oqtane/)) middleware. The package will automatically appear in Oqtane as an installable extension. + + + +## Code coverage + +dotnet tool install -g dotnet-reportgenerator-globaltool + +dotnet test --collect:"XPlat Code Coverage" \ No newline at end of file diff --git a/examples/Imageflow.Server.Example/CustomBlobService.cs b/examples/Imageflow.Server.Example/CustomBlobService.cs index d1aece4b..b47eac68 100644 --- a/examples/Imageflow.Server.Example/CustomBlobService.cs +++ b/examples/Imageflow.Server.Example/CustomBlobService.cs @@ -6,8 +6,9 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; -using Imageflow.Server.Storage.AzureBlob; -using Imazen.Common.Storage; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.Resulting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -19,7 +20,7 @@ public static class CustomBlobServiceExtensions public static IServiceCollection AddImageflowCustomBlobService(this IServiceCollection services, CustomBlobServiceOptions options) { - services.AddSingleton((container) => + services.AddSingleton((container) => { var logger = container.GetRequiredService>(); return new CustomBlobService(options, logger); @@ -31,7 +32,7 @@ public static IServiceCollection AddImageflowCustomBlobService(this IServiceColl public class CustomBlobServiceOptions { - + public string Name { get; set; } = "CustomBlobService"; public BlobClientOptions BlobClientOptions { get; set; } = new BlobClientOptions(); /// @@ -62,7 +63,7 @@ public string Prefix } - public class CustomBlobService : IBlobProvider + public class CustomBlobService : IBlobWrapperProvider { private readonly BlobServiceClient client; @@ -73,6 +74,7 @@ public CustomBlobService(CustomBlobServiceOptions options, ILogger options.Name; public IEnumerable GetPrefixes() { return Enumerable.Repeat(options.Prefix, 1); @@ -83,7 +85,7 @@ public bool SupportsPath(string virtualPath) options.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - public async Task Fetch(string virtualPath) + public async Task> Fetch(string virtualPath) { if (!SupportsPath(virtualPath)) { @@ -109,42 +111,38 @@ public async Task Fetch(string virtualPath) try { var blobClient = client.GetBlobContainerClient(container).GetBlobClient(blobKey); - + var latencyZone = new LatencyTrackingZone($"azure::blob/{container}", 100); var s = await blobClient.DownloadAsync(); - return new CustomAzureBlob(s); + return CodeResult.Ok(new BlobWrapper(latencyZone,CustomAzureBlobHelpers.CreateAzureBlob(s))); } catch (RequestFailedException e) { if (e.Status == 404) { - throw new BlobMissingException($"Azure blob \"{blobKey}\" not found.", e); + return CodeResult.Err(HttpStatus.NotFound.WithMessage( + $"Azure blob \"{blobKey}\" not found.")); } throw; } } + } - internal class CustomAzureBlob :IBlobData, IDisposable + internal static class CustomAzureBlobHelpers { - private readonly Response response; - - internal CustomAzureBlob(Response r) + public static IConsumableBlob CreateAzureBlob(Response response) { - response = r; - } - - public bool? Exists => true; - public DateTime? LastModifiedDateUtc => response.Value.Details.LastModified.UtcDateTime; - public Stream OpenRead() - { - return response.Value.Content; - } - - public void Dispose() - { - response?.Value?.Dispose(); + var a = new BlobAttributes() + { + LastModifiedDateUtc = response.Value.Details.LastModified.UtcDateTime, + ContentType = response.Value.ContentType, + Etag = response.Value.Details.ETag.ToString(), + + }; + var stream = response.Value.Content; + return new ConsumableStreamBlob(a, stream); } } } \ No newline at end of file diff --git a/examples/Imageflow.Server.Example/Imageflow.Server.Example.csproj b/examples/Imageflow.Server.Example/Imageflow.Server.Example.csproj index 86f8d96f..379d7ef1 100644 --- a/examples/Imageflow.Server.Example/Imageflow.Server.Example.csproj +++ b/examples/Imageflow.Server.Example/Imageflow.Server.Example.csproj @@ -7,7 +7,7 @@ - + diff --git a/examples/Imageflow.Server.Example/Startup.cs b/examples/Imageflow.Server.Example/Startup.cs index 583098e9..a3893f94 100644 --- a/examples/Imageflow.Server.Example/Startup.cs +++ b/examples/Imageflow.Server.Example/Startup.cs @@ -98,7 +98,6 @@ public void ConfigureServices(IServiceCollection services) // How long after a file is created before it can be deleted MinAgeToDelete = TimeSpan.FromSeconds(10), // How much RAM to use for the write queue before switching to synchronous writes - WriteQueueMemoryMb = 100, // The maximum size of the cache (1GB) CacheSizeMb = 1000, }); diff --git a/examples/Imageflow.Server.Example/packages.lock.json b/examples/Imageflow.Server.Example/packages.lock.json index 10e1c38b..716358df 100644 --- a/examples/Imageflow.Server.Example/packages.lock.json +++ b/examples/Imageflow.Server.Example/packages.lock.json @@ -4,11 +4,11 @@ "net6.0": { "AWSSDK.Extensions.NETCore.Setup": { "type": "Direct", - "requested": "[3.7.2, )", - "resolved": "3.7.2", - "contentHash": "iFjbEnVB0f6Hr8L3EfdelHG7zxVQrOmeP9UIrX3IODR1eTsrqVrmq0mazdIr0GZK1YG2/DZiVt6tNyV1bayndw==", + "requested": "[3.7.300, )", + "resolved": "3.7.300", + "contentHash": "zMxAHFYSAWHsVV9Cn96nE+V40agRCjT0etF10f0d/nFMMb1z7lecVwNadq9JYyqlDj+jsVRH9ydk4Al4v/1+jg==", "dependencies": { - "AWSSDK.Core": "3.7.6", + "AWSSDK.Core": "3.7.300", "Microsoft.Extensions.Configuration.Abstractions": "2.0.0", "Microsoft.Extensions.DependencyInjection.Abstractions": "2.0.0", "Microsoft.Extensions.Logging.Abstractions": "2.0.0" @@ -27,24 +27,24 @@ }, "AWSSDK.Core": { "type": "Transitive", - "resolved": "3.7.103.11", - "contentHash": "twTMAAOA4ztiVtfrq2ZiRUmSnt2CW3DZwGvlbcftM6Dbsc2VGWE/hWv1Rl6GfKpEbrJxkW+FmG2o27XRTg8lqA==" + "resolved": "3.7.302.12", + "contentHash": "OaFHc2FD3vYldtdaH0Sqob3NoTXFBBMKxmq/VV3of5l+hHZmcaAjpJhh1Et0BxggeHqimVOTaoQxEO0PXQHeuw==" }, "AWSSDK.S3": { "type": "Transitive", - "resolved": "3.7.101.49", - "contentHash": "GN0pFLWqDe53UDbMEtZ4RkU64+mtJS4OsADemJ0TTaUlLhH5xJCkj5nhTa7eFG3tiIKbtdeMDUPhX47F3+UimQ==", + "resolved": "3.7.305.28", + "contentHash": "kjnmtEIddQPmS8XxwGwu1Gdc0vO+3bbohPSH59s8OdXnZlSMtsldJM+jwS5LTz4DUusFwMk3E8h0DY0TlwkVzg==", "dependencies": { - "AWSSDK.Core": "[3.7.103.11, 4.0.0)" + "AWSSDK.Core": "[3.7.302.12, 4.0.0)" } }, "Azure.Core": { "type": "Transitive", - "resolved": "1.25.0", - "contentHash": "X8Dd4sAggS84KScWIjEbFAdt2U1KDolQopTPoHVubG2y3CM54f9l6asVrP5Uy384NWXjsspPYaJgz5xHc+KvTA==", + "resolved": "1.37.0", + "contentHash": "ZSWV/ftBM/c/+Eo2hlzeEWntjqNG+8TWXX/DAKjBSWIf7nEvFILQoJL7JZx5HjypDvdNUMj5J2ji8ZpFFSghSg==", "dependencies": { "Microsoft.Bcl.AsyncInterfaces": "1.1.1", - "System.Diagnostics.DiagnosticSource": "4.6.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", "System.Memory.Data": "1.0.2", "System.Numerics.Vectors": "4.5.0", "System.Text.Encodings.Web": "4.7.2", @@ -52,34 +52,53 @@ "System.Threading.Tasks.Extensions": "4.5.4" } }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.10.4", + "contentHash": "hSvisZy9sld0Gik1X94od3+rRXCx+AKgi+iLH6fFdlnRZRePn7RtrqUGSsORiH2h8H2sc4NLTrnuUte1WL+QuQ==", + "dependencies": { + "Azure.Core": "1.36.0", + "Microsoft.Identity.Client": "4.56.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.56.0", + "System.Memory": "4.5.4", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, "Azure.Storage.Blobs": { "type": "Transitive", - "resolved": "12.14.1", - "contentHash": "DvRBWUDMB2LjdRbsBNtz/LiVIYk56hqzSooxx4uq4rCdLj2M+7Vvoa1r+W35Dz6ZXL6p+SNcgEae3oZ+CkPfow==", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", "dependencies": { - "Azure.Storage.Common": "12.13.0", + "Azure.Storage.Common": "12.18.1", "System.Text.Json": "4.7.2" } }, "Azure.Storage.Common": { "type": "Transitive", - "resolved": "12.13.0", - "contentHash": "jDv8xJWeZY2Er9zA6QO25BiGolxg87rItt9CwAp7L/V9EPJeaz8oJydaNL9Wj0+3ncceoMgdiyEv66OF8YUwWQ==", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", "dependencies": { - "Azure.Core": "1.25.0", + "Azure.Core": "1.36.0", "System.IO.Hashing": "6.0.0" } }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", "dependencies": { "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", - "Imageflow.Net": "0.10.2" + "Imageflow.Net": "0.12.0" } }, "Imageflow.NativeRuntime.osx-x86_64": { @@ -104,23 +123,39 @@ }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", "dependencies": { - "Microsoft.CSharp": "4.7.0", - "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", - "Newtonsoft.Json": "[13.0.3, 14.0.0)" + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" } }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==" + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.CSharp": { + "Microsoft.Extensions.Azure": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + "resolved": "1.7.2", + "contentHash": "EJJQKICExhTERoqY/m/A2dkfit7TqOJt2Mnhreg06D2BxioGnoOYV72s5a9NfIN8K4fABl04wRkdJ4+jgSPheQ==", + "dependencies": { + "Azure.Core": "1.37.0", + "Azure.Identity": "1.10.4", + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -130,6 +165,14 @@ "Microsoft.Extensions.Primitives": "2.2.0" } }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" + } + }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", "resolved": "5.0.0", @@ -203,15 +246,38 @@ "resolved": "5.0.0", "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "rr4zbidvHy9r4NvOAs5hdd964Ao2A0pAeFBJKR95u1CJAVzbd1p6tPTXUZ+5ld0cfThiVSGvz6UHwY6JjraTpA==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.22.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "H12YAzEGK55vZ+QpxUzozhW8ZZtgPDuWvgA0JbdIR9UhMUplj29JhIgE2imuH8W2Nw9D8JKygR1uxRFtpSNcrg==", + "dependencies": { + "Microsoft.Identity.Client": "4.56.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.22.0", + "contentHash": "iI+9V+2ciCrbheeLjpmjcqCnhy+r6yCoEcid3nkoFWerHgjVuT6CPM4HODUTtUPe1uwks4wcnAujJ8u+IKogHQ==" + }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", - "resolved": "1.2.2", - "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" }, - "Newtonsoft.Json": { + "Microsoft.NETCore.Platforms": { "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" }, "Polly": { "type": "Transitive", @@ -226,16 +292,62 @@ "Polly": "7.1.0" } }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "4.6.0", - "contentHash": "mbBgoR0rRfl2uimsZ2avZY8g7Xnh1Mza0rJZLPcxqiMWlkGukjmRkuMJ/er+AhQuiRIh80CR/Hpeztr80seV5g==" + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } }, "System.IO.Hashing": { "type": "Transitive", "resolved": "6.0.0", "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==" + }, "System.Memory.Data": { "type": "Transitive", "resolved": "1.0.2", @@ -250,15 +362,46 @@ "resolved": "4.5.0", "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "iTUgB/WtrZ1sWZs84F2hwyQhiRH6QNjQv2DkwrH+WP6RoFga2Q1m3f9/Q7FG8cck8AdHitQkmkXSY8qylcDmuA==" + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } }, "System.Text.Json": { "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } }, "System.Threading.Tasks.Extensions": { "type": "Transitive", @@ -268,8 +411,10 @@ "imageflow.server": { "type": "Project", "dependencies": { - "Imageflow.AllPlatforms": "[0.10.2, )", - "Imazen.Common": "[0.1.0--notset, )" + "Imageflow.AllPlatforms": "[0.12.0, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.IO.RecyclableMemoryStream": "[3.0.0, )" } }, "imageflow.server.hybridcache": { @@ -283,8 +428,9 @@ "imageflow.server.storage.azureblob": { "type": "Project", "dependencies": { - "Azure.Storage.Blobs": "[12.14.1, )", - "Imazen.Common": "[0.1.0--notset, )" + "Azure.Storage.Blobs": "[12.19.1, )", + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Azure": "[1.7.2, )" } }, "imageflow.server.storage.remotereader": { @@ -297,31 +443,52 @@ "imageflow.server.storage.s3": { "type": "Project", "dependencies": { - "AWSSDK.S3": "[3.7.101.49, )", - "Imazen.Common": "[0.1.0--notset, )" + "AWSSDK.S3": "[3.7.305.28, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imazen.hybridcache": { "type": "Project", "dependencies": { - "Imazen.Common": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" } } }, "net7.0": { "AWSSDK.Extensions.NETCore.Setup": { "type": "Direct", - "requested": "[3.7.2, )", - "resolved": "3.7.2", - "contentHash": "iFjbEnVB0f6Hr8L3EfdelHG7zxVQrOmeP9UIrX3IODR1eTsrqVrmq0mazdIr0GZK1YG2/DZiVt6tNyV1bayndw==", + "requested": "[3.7.300, )", + "resolved": "3.7.300", + "contentHash": "zMxAHFYSAWHsVV9Cn96nE+V40agRCjT0etF10f0d/nFMMb1z7lecVwNadq9JYyqlDj+jsVRH9ydk4Al4v/1+jg==", "dependencies": { - "AWSSDK.Core": "3.7.6", + "AWSSDK.Core": "3.7.300", "Microsoft.Extensions.Configuration.Abstractions": "2.0.0", "Microsoft.Extensions.DependencyInjection.Abstractions": "2.0.0", "Microsoft.Extensions.Logging.Abstractions": "2.0.0" @@ -340,24 +507,24 @@ }, "AWSSDK.Core": { "type": "Transitive", - "resolved": "3.7.103.11", - "contentHash": "twTMAAOA4ztiVtfrq2ZiRUmSnt2CW3DZwGvlbcftM6Dbsc2VGWE/hWv1Rl6GfKpEbrJxkW+FmG2o27XRTg8lqA==" + "resolved": "3.7.302.12", + "contentHash": "OaFHc2FD3vYldtdaH0Sqob3NoTXFBBMKxmq/VV3of5l+hHZmcaAjpJhh1Et0BxggeHqimVOTaoQxEO0PXQHeuw==" }, "AWSSDK.S3": { "type": "Transitive", - "resolved": "3.7.101.49", - "contentHash": "GN0pFLWqDe53UDbMEtZ4RkU64+mtJS4OsADemJ0TTaUlLhH5xJCkj5nhTa7eFG3tiIKbtdeMDUPhX47F3+UimQ==", + "resolved": "3.7.305.28", + "contentHash": "kjnmtEIddQPmS8XxwGwu1Gdc0vO+3bbohPSH59s8OdXnZlSMtsldJM+jwS5LTz4DUusFwMk3E8h0DY0TlwkVzg==", "dependencies": { - "AWSSDK.Core": "[3.7.103.11, 4.0.0)" + "AWSSDK.Core": "[3.7.302.12, 4.0.0)" } }, "Azure.Core": { "type": "Transitive", - "resolved": "1.25.0", - "contentHash": "X8Dd4sAggS84KScWIjEbFAdt2U1KDolQopTPoHVubG2y3CM54f9l6asVrP5Uy384NWXjsspPYaJgz5xHc+KvTA==", + "resolved": "1.37.0", + "contentHash": "ZSWV/ftBM/c/+Eo2hlzeEWntjqNG+8TWXX/DAKjBSWIf7nEvFILQoJL7JZx5HjypDvdNUMj5J2ji8ZpFFSghSg==", "dependencies": { "Microsoft.Bcl.AsyncInterfaces": "1.1.1", - "System.Diagnostics.DiagnosticSource": "4.6.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", "System.Memory.Data": "1.0.2", "System.Numerics.Vectors": "4.5.0", "System.Text.Encodings.Web": "4.7.2", @@ -365,34 +532,53 @@ "System.Threading.Tasks.Extensions": "4.5.4" } }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.10.4", + "contentHash": "hSvisZy9sld0Gik1X94od3+rRXCx+AKgi+iLH6fFdlnRZRePn7RtrqUGSsORiH2h8H2sc4NLTrnuUte1WL+QuQ==", + "dependencies": { + "Azure.Core": "1.36.0", + "Microsoft.Identity.Client": "4.56.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.56.0", + "System.Memory": "4.5.4", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, "Azure.Storage.Blobs": { "type": "Transitive", - "resolved": "12.14.1", - "contentHash": "DvRBWUDMB2LjdRbsBNtz/LiVIYk56hqzSooxx4uq4rCdLj2M+7Vvoa1r+W35Dz6ZXL6p+SNcgEae3oZ+CkPfow==", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", "dependencies": { - "Azure.Storage.Common": "12.13.0", + "Azure.Storage.Common": "12.18.1", "System.Text.Json": "4.7.2" } }, "Azure.Storage.Common": { "type": "Transitive", - "resolved": "12.13.0", - "contentHash": "jDv8xJWeZY2Er9zA6QO25BiGolxg87rItt9CwAp7L/V9EPJeaz8oJydaNL9Wj0+3ncceoMgdiyEv66OF8YUwWQ==", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", "dependencies": { - "Azure.Core": "1.25.0", + "Azure.Core": "1.36.0", "System.IO.Hashing": "6.0.0" } }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", "dependencies": { "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", - "Imageflow.Net": "0.10.2" + "Imageflow.Net": "0.12.0" } }, "Imageflow.NativeRuntime.osx-x86_64": { @@ -417,23 +603,39 @@ }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", "dependencies": { - "Microsoft.CSharp": "4.7.0", - "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", - "Newtonsoft.Json": "[13.0.3, 14.0.0)" + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" } }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==" + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.CSharp": { + "Microsoft.Extensions.Azure": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + "resolved": "1.7.2", + "contentHash": "EJJQKICExhTERoqY/m/A2dkfit7TqOJt2Mnhreg06D2BxioGnoOYV72s5a9NfIN8K4fABl04wRkdJ4+jgSPheQ==", + "dependencies": { + "Azure.Core": "1.37.0", + "Azure.Identity": "1.10.4", + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -443,6 +645,14 @@ "Microsoft.Extensions.Primitives": "2.2.0" } }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" + } + }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", "resolved": "5.0.0", @@ -516,15 +726,38 @@ "resolved": "5.0.0", "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "rr4zbidvHy9r4NvOAs5hdd964Ao2A0pAeFBJKR95u1CJAVzbd1p6tPTXUZ+5ld0cfThiVSGvz6UHwY6JjraTpA==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.22.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "H12YAzEGK55vZ+QpxUzozhW8ZZtgPDuWvgA0JbdIR9UhMUplj29JhIgE2imuH8W2Nw9D8JKygR1uxRFtpSNcrg==", + "dependencies": { + "Microsoft.Identity.Client": "4.56.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.22.0", + "contentHash": "iI+9V+2ciCrbheeLjpmjcqCnhy+r6yCoEcid3nkoFWerHgjVuT6CPM4HODUTtUPe1uwks4wcnAujJ8u+IKogHQ==" + }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", - "resolved": "1.2.2", - "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" }, - "Newtonsoft.Json": { + "Microsoft.NETCore.Platforms": { "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" }, "Polly": { "type": "Transitive", @@ -539,16 +772,62 @@ "Polly": "7.1.0" } }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "4.6.0", - "contentHash": "mbBgoR0rRfl2uimsZ2avZY8g7Xnh1Mza0rJZLPcxqiMWlkGukjmRkuMJ/er+AhQuiRIh80CR/Hpeztr80seV5g==" + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } }, "System.IO.Hashing": { "type": "Transitive", "resolved": "6.0.0", "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==" + }, "System.Memory.Data": { "type": "Transitive", "resolved": "1.0.2", @@ -563,15 +842,46 @@ "resolved": "4.5.0", "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "iTUgB/WtrZ1sWZs84F2hwyQhiRH6QNjQv2DkwrH+WP6RoFga2Q1m3f9/Q7FG8cck8AdHitQkmkXSY8qylcDmuA==" + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } }, "System.Text.Json": { "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } }, "System.Threading.Tasks.Extensions": { "type": "Transitive", @@ -581,8 +891,10 @@ "imageflow.server": { "type": "Project", "dependencies": { - "Imageflow.AllPlatforms": "[0.10.2, )", - "Imazen.Common": "[0.1.0--notset, )" + "Imageflow.AllPlatforms": "[0.12.0, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.IO.RecyclableMemoryStream": "[3.0.0, )" } }, "imageflow.server.hybridcache": { @@ -596,8 +908,9 @@ "imageflow.server.storage.azureblob": { "type": "Project", "dependencies": { - "Azure.Storage.Blobs": "[12.14.1, )", - "Imazen.Common": "[0.1.0--notset, )" + "Azure.Storage.Blobs": "[12.19.1, )", + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Azure": "[1.7.2, )" } }, "imageflow.server.storage.remotereader": { @@ -610,20 +923,41 @@ "imageflow.server.storage.s3": { "type": "Project", "dependencies": { - "AWSSDK.S3": "[3.7.101.49, )", - "Imazen.Common": "[0.1.0--notset, )" + "AWSSDK.S3": "[3.7.305.28, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imazen.hybridcache": { "type": "Project", "dependencies": { - "Imazen.Common": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" } } } diff --git a/examples/Imageflow.Server.ExampleDocker/Imageflow.Server.ExampleDocker.csproj b/examples/Imageflow.Server.ExampleDocker/Imageflow.Server.ExampleDocker.csproj index 174d73e2..47c7520d 100644 --- a/examples/Imageflow.Server.ExampleDocker/Imageflow.Server.ExampleDocker.csproj +++ b/examples/Imageflow.Server.ExampleDocker/Imageflow.Server.ExampleDocker.csproj @@ -5,7 +5,7 @@ - + diff --git a/examples/Imageflow.Server.ExampleDocker/packages.lock.json b/examples/Imageflow.Server.ExampleDocker/packages.lock.json index 6cf6e1bb..8c117391 100644 --- a/examples/Imageflow.Server.ExampleDocker/packages.lock.json +++ b/examples/Imageflow.Server.ExampleDocker/packages.lock.json @@ -4,50 +4,50 @@ "net7.0": { "Imageflow.Server": { "type": "Direct", - "requested": "[0.7.9, )", - "resolved": "0.7.9", - "contentHash": "MnX/7G07zQkBTbt3m4UZsXoMRMhNceNF0l58koPLlPf/iXpSaGsSvvhvLIFnULLUPy9dK6TiiTw6FKZKrh4/HQ==", + "requested": "[0.8.3, )", + "resolved": "0.8.3", + "contentHash": "gLoyMT37OXtjZIuVvgYAAhgyh5q4Ak/YFaCyoOiaQUa8a7qJd9LKzcEmHvJyrYNT/e0vgh3/n+WjmlJuA7Gtig==", "dependencies": { - "Imageflow.AllPlatforms": "0.10.0", - "Imazen.Common": "0.7.9" + "Imageflow.AllPlatforms": "0.10.2", + "Imazen.Common": "0.8.3" } }, "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "0.10.0", - "contentHash": "krGchS0IO5w6GI2u8jfqOeoeknqwXck3i2wxCt30/kdoX0awZZu1ZKTC3PLjqOPDHxs5vCW1Q03cyS4OoWa0mg==", + "resolved": "0.10.2", + "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", "dependencies": { - "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview5", - "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview5", - "Imageflow.NativeRuntime.win-x86": "2.0.0-preview5", - "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview5", - "Imageflow.Net": "0.10.0" + "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", + "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", + "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", + "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", + "Imageflow.Net": "0.10.2" } }, "Imageflow.NativeRuntime.osx-x86_64": { "type": "Transitive", - "resolved": "2.0.0-preview5", - "contentHash": "lZtndj9zVgs2Nvgna9NsH1jBldOnPnlQ7wU8is8a5maiWKBTwvrnMp/nYtmFwAmekQi/gM8fILMIQE9CQX5TPQ==" + "resolved": "2.0.0-preview8", + "contentHash": "3wEglrMqVzlnAVBTdK6qcRySdo/4ajBP5ASRuK3yHfBqPp3ld4ke6guxuSZbgDTObIxai7KTLlsIvQZhusymUA==" }, "Imageflow.NativeRuntime.ubuntu-x86_64": { "type": "Transitive", - "resolved": "2.0.0-preview5", - "contentHash": "+c9vYSjZdRYv9zTicR51P71WwsEUjI6rmT0kAr9lrO4NAg5ESBGxcimAddRpVlEdcrxoh/HxeC8CRbXNPZFmew==" + "resolved": "2.0.0-preview8", + "contentHash": "H8K5kZqcM3IliDRZD3H8BN6TbeLgcW+6FsDZ3EvlqBvu41s+Lv9vxE+c3m1cUQhsYBs76udUhgJFNR1D6x3U5g==" }, "Imageflow.NativeRuntime.win-x86": { "type": "Transitive", - "resolved": "2.0.0-preview5", - "contentHash": "pS7MpUXo3bWsDmJX+joZRp24nAdrRk2XwAVlnp/KirU0WLwzkj4uRxxZwFHAf+sy0YI1QlM3oG5LkS31vOrIow==" + "resolved": "2.0.0-preview8", + "contentHash": "WunIva5NZ2iMPKCyz8ZTkN7SRaW3szBijMg5YK7jaSFZHw8Xiky/GFfghc0XgWTuILxwO4YbY86e8QvW8CBigQ==" }, "Imageflow.NativeRuntime.win-x86_64": { "type": "Transitive", - "resolved": "2.0.0-preview5", - "contentHash": "RhTqBEALaUcmdpZArikxTa+rk1axUpgwiotubLY6OhsF97omyV2bYr7/PEql/KQ2oMIo0c6weEoTCAgOKwA+mA==" + "resolved": "2.0.0-preview8", + "contentHash": "1rY6C9Hjj7U9toa7FlnveiSBKccZlvCaHwdxPRQS0vDpAZZCJrTA/H7VYdreifpnIDInYcf0i/3oEKzEnj884w==" }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.0", - "contentHash": "kUHuZQvzUx6tWziR8H3rATgq90Jcnbfp/kv29k7D+cbULF/cbOPb/w4k21bhkuJEazaHgPND2xC8+egZSKCCug==", + "resolved": "0.10.2", + "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", "dependencies": { "Microsoft.CSharp": "4.7.0", "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", @@ -56,8 +56,8 @@ }, "Imazen.Common": { "type": "Transitive", - "resolved": "0.7.9", - "contentHash": "+MI55+jISlvXCvWmlD+xXkkPBL1rrvS+Z7qM/NOcuE96QDWfIrjP8wDqdMn5IUT3d/5jvjnlNF3gfPs5wYAZrQ==", + "resolved": "0.8.3", + "contentHash": "fV0lMNq/NFUfQQchwt8lE2vwwXZz/0t6Pacwpup/esZtU1idWnQE1OCnJeC0u7iIwSy1uLNWR9UPoTiSYstIdg==", "dependencies": { "Microsoft.Extensions.Hosting.Abstractions": "2.2.0" } diff --git a/examples/Imageflow.Server.ExampleDockerDiskCache/Imageflow.Server.ExampleDockerDiskCache.csproj b/examples/Imageflow.Server.ExampleDockerDiskCache/Imageflow.Server.ExampleDockerDiskCache.csproj index e8ff8584..a499be86 100644 --- a/examples/Imageflow.Server.ExampleDockerDiskCache/Imageflow.Server.ExampleDockerDiskCache.csproj +++ b/examples/Imageflow.Server.ExampleDockerDiskCache/Imageflow.Server.ExampleDockerDiskCache.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/examples/Imageflow.Server.ExampleDockerDiskCache/packages.lock.json b/examples/Imageflow.Server.ExampleDockerDiskCache/packages.lock.json index 709255bf..8b887208 100644 --- a/examples/Imageflow.Server.ExampleDockerDiskCache/packages.lock.json +++ b/examples/Imageflow.Server.ExampleDockerDiskCache/packages.lock.json @@ -4,61 +4,61 @@ "net7.0": { "Imageflow.Server": { "type": "Direct", - "requested": "[0.7.9, )", - "resolved": "0.7.9", - "contentHash": "MnX/7G07zQkBTbt3m4UZsXoMRMhNceNF0l58koPLlPf/iXpSaGsSvvhvLIFnULLUPy9dK6TiiTw6FKZKrh4/HQ==", + "requested": "[0.8.3, )", + "resolved": "0.8.3", + "contentHash": "gLoyMT37OXtjZIuVvgYAAhgyh5q4Ak/YFaCyoOiaQUa8a7qJd9LKzcEmHvJyrYNT/e0vgh3/n+WjmlJuA7Gtig==", "dependencies": { - "Imageflow.AllPlatforms": "0.10.0", - "Imazen.Common": "0.7.9" + "Imageflow.AllPlatforms": "0.10.2", + "Imazen.Common": "0.8.3" } }, "Imageflow.Server.HybridCache": { "type": "Direct", - "requested": "[0.7.9, )", - "resolved": "0.7.9", - "contentHash": "fopBXxN3+KwWkzudbYCKK6k4wp9rSFMDyvBbRcxrINmJ0SsiXHMTm84JYw/QjD+beWiGuY1HjYy0CMmvCOV80w==", + "requested": "[0.8.3, )", + "resolved": "0.8.3", + "contentHash": "lZQWlCcI/dyBSGta/Gunj63jT3Qj48fAeMh40T+sItCs1e/JYscbDPANydRqvUQD0GTW+gwVUuQh85YFQL3m+w==", "dependencies": { - "Imazen.Common": "0.7.9", - "Imazen.HybridCache": "0.7.9", + "Imazen.Common": "0.8.3", + "Imazen.HybridCache": "0.8.3", "Microsoft.Extensions.Hosting.Abstractions": "2.2.0" } }, "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "0.10.0", - "contentHash": "krGchS0IO5w6GI2u8jfqOeoeknqwXck3i2wxCt30/kdoX0awZZu1ZKTC3PLjqOPDHxs5vCW1Q03cyS4OoWa0mg==", + "resolved": "0.10.2", + "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", "dependencies": { - "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview5", - "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview5", - "Imageflow.NativeRuntime.win-x86": "2.0.0-preview5", - "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview5", - "Imageflow.Net": "0.10.0" + "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", + "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", + "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", + "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", + "Imageflow.Net": "0.10.2" } }, "Imageflow.NativeRuntime.osx-x86_64": { "type": "Transitive", - "resolved": "2.0.0-preview5", - "contentHash": "lZtndj9zVgs2Nvgna9NsH1jBldOnPnlQ7wU8is8a5maiWKBTwvrnMp/nYtmFwAmekQi/gM8fILMIQE9CQX5TPQ==" + "resolved": "2.0.0-preview8", + "contentHash": "3wEglrMqVzlnAVBTdK6qcRySdo/4ajBP5ASRuK3yHfBqPp3ld4ke6guxuSZbgDTObIxai7KTLlsIvQZhusymUA==" }, "Imageflow.NativeRuntime.ubuntu-x86_64": { "type": "Transitive", - "resolved": "2.0.0-preview5", - "contentHash": "+c9vYSjZdRYv9zTicR51P71WwsEUjI6rmT0kAr9lrO4NAg5ESBGxcimAddRpVlEdcrxoh/HxeC8CRbXNPZFmew==" + "resolved": "2.0.0-preview8", + "contentHash": "H8K5kZqcM3IliDRZD3H8BN6TbeLgcW+6FsDZ3EvlqBvu41s+Lv9vxE+c3m1cUQhsYBs76udUhgJFNR1D6x3U5g==" }, "Imageflow.NativeRuntime.win-x86": { "type": "Transitive", - "resolved": "2.0.0-preview5", - "contentHash": "pS7MpUXo3bWsDmJX+joZRp24nAdrRk2XwAVlnp/KirU0WLwzkj4uRxxZwFHAf+sy0YI1QlM3oG5LkS31vOrIow==" + "resolved": "2.0.0-preview8", + "contentHash": "WunIva5NZ2iMPKCyz8ZTkN7SRaW3szBijMg5YK7jaSFZHw8Xiky/GFfghc0XgWTuILxwO4YbY86e8QvW8CBigQ==" }, "Imageflow.NativeRuntime.win-x86_64": { "type": "Transitive", - "resolved": "2.0.0-preview5", - "contentHash": "RhTqBEALaUcmdpZArikxTa+rk1axUpgwiotubLY6OhsF97omyV2bYr7/PEql/KQ2oMIo0c6weEoTCAgOKwA+mA==" + "resolved": "2.0.0-preview8", + "contentHash": "1rY6C9Hjj7U9toa7FlnveiSBKccZlvCaHwdxPRQS0vDpAZZCJrTA/H7VYdreifpnIDInYcf0i/3oEKzEnj884w==" }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.0", - "contentHash": "kUHuZQvzUx6tWziR8H3rATgq90Jcnbfp/kv29k7D+cbULF/cbOPb/w4k21bhkuJEazaHgPND2xC8+egZSKCCug==", + "resolved": "0.10.2", + "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", "dependencies": { "Microsoft.CSharp": "4.7.0", "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", @@ -67,18 +67,18 @@ }, "Imazen.Common": { "type": "Transitive", - "resolved": "0.7.9", - "contentHash": "+MI55+jISlvXCvWmlD+xXkkPBL1rrvS+Z7qM/NOcuE96QDWfIrjP8wDqdMn5IUT3d/5jvjnlNF3gfPs5wYAZrQ==", + "resolved": "0.8.3", + "contentHash": "fV0lMNq/NFUfQQchwt8lE2vwwXZz/0t6Pacwpup/esZtU1idWnQE1OCnJeC0u7iIwSy1uLNWR9UPoTiSYstIdg==", "dependencies": { "Microsoft.Extensions.Hosting.Abstractions": "2.2.0" } }, "Imazen.HybridCache": { "type": "Transitive", - "resolved": "0.7.9", - "contentHash": "p6w3NWzYAt5TAt/+Ci7+Kimwm2Af2iC9Vz49Ye4ivho2GCxSyIGKoxh/6J6wKjyPM9c50OBDsSLv7OrrugkNhQ==", + "resolved": "0.8.3", + "contentHash": "9MJEgXZjxTxSWxjmM7UH2iMxy2+V4zhj7yt9BQ5nc+aJ/Fwokrrgt+/+kk9SCGXk+gvGQUwV9MDty/Y5nc6mJQ==", "dependencies": { - "Imazen.Common": "0.7.9" + "Imazen.Common": "0.8.3" } }, "Microsoft.CSharp": { diff --git a/examples/Imageflow.Server.ExampleMinimal/Program.cs b/examples/Imageflow.Server.ExampleMinimal/Program.cs index 02d15bb2..bbdff382 100644 --- a/examples/Imageflow.Server.ExampleMinimal/Program.cs +++ b/examples/Imageflow.Server.ExampleMinimal/Program.cs @@ -1,11 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; namespace Imageflow.Server.ExampleMinimal { diff --git a/examples/Imageflow.Server.ExampleMinimal/Startup.cs b/examples/Imageflow.Server.ExampleMinimal/Startup.cs index 6efe0647..08094792 100644 --- a/examples/Imageflow.Server.ExampleMinimal/Startup.cs +++ b/examples/Imageflow.Server.ExampleMinimal/Startup.cs @@ -1,8 +1,10 @@ +using Imazen.Abstractions.Logging; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Imageflow.Server.ExampleMinimal { @@ -10,6 +12,11 @@ public class Startup { public void ConfigureServices(IServiceCollection services) { + services.AddImageflowReLogStoreAndReLoggerFactoryIfMissing(); + services.AddLogging(builder => builder.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Trace; + })); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { diff --git a/examples/Imageflow.Server.ExampleMinimal/packages.lock.json b/examples/Imageflow.Server.ExampleMinimal/packages.lock.json index 90698253..92373117 100644 --- a/examples/Imageflow.Server.ExampleMinimal/packages.lock.json +++ b/examples/Imageflow.Server.ExampleMinimal/packages.lock.json @@ -2,16 +2,21 @@ "version": 1, "dependencies": { "net7.0": { + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", "dependencies": { "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", - "Imageflow.Net": "0.10.2" + "Imageflow.Net": "0.12.0" } }, "Imageflow.NativeRuntime.osx-x86_64": { @@ -36,19 +41,13 @@ }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", "dependencies": { - "Microsoft.CSharp": "4.7.0", - "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", - "Newtonsoft.Json": "[13.0.3, 14.0.0)" + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" } }, - "Microsoft.CSharp": { - "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" - }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", "resolved": "2.2.0", @@ -97,13 +96,21 @@ }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", - "resolved": "1.2.2", - "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" }, - "Newtonsoft.Json": { + "System.Collections.Immutable": { "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" }, "System.Memory": { "type": "Transitive", @@ -112,21 +119,59 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } }, "imageflow.server": { "type": "Project", "dependencies": { - "Imageflow.AllPlatforms": "[0.10.2, )", - "Imazen.Common": "[0.1.0--notset, )" + "Imageflow.AllPlatforms": "[0.12.0, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.IO.RecyclableMemoryStream": "[3.0.0, )" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" + } } } } diff --git a/examples/ZRIO/.idea/.idea.ZRIO.dir/.idea/projectSettingsUpdater.xml b/examples/ZRIO/.idea/.idea.ZRIO.dir/.idea/projectSettingsUpdater.xml new file mode 100644 index 00000000..4bb9f4d2 --- /dev/null +++ b/examples/ZRIO/.idea/.idea.ZRIO.dir/.idea/projectSettingsUpdater.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/src/Imageflow.Server.Configuration/Executor.cs b/src/Imageflow.Server.Configuration/Executor.cs index 3f944546..9a2fc613 100644 --- a/src/Imageflow.Server.Configuration/Executor.cs +++ b/src/Imageflow.Server.Configuration/Executor.cs @@ -107,19 +107,23 @@ public ImageflowMiddlewareOptions GetImageflowMiddlewareOptions(){ if (config.RouteDefaults?.ApplyDefaultCommands != null){ // parse as querystring using ASP.NET. var querystring = '?' + config.RouteDefaults.ApplyDefaultCommands.TrimStart('?'); - var parsed = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(querystring); - foreach(var command in parsed){ - var key = command.Key; - var value = command.Value; - if (string.IsNullOrWhiteSpace(key)){ - throw new ArgumentNullException($"route_defaults.apply_default_commands.key is missing. Defined in file '{sourcePath}'"); - } - if (string.IsNullOrWhiteSpace(value)){ - throw new ArgumentNullException($"route_defaults.apply_default_commands.value is missing. Defined in file '{sourcePath}' for key route_defaults.apply_default_commands.key '{key}'"); + var parsed = Imazen.Routing.Helpers.PathHelpers.ParseQuery(querystring); + if (parsed != null) + foreach (var (key, value) in parsed) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentNullException( + $"route_defaults.apply_default_commands.key is missing. Defined in file '{sourcePath}'"); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentNullException( + $"route_defaults.apply_default_commands.value is missing. Defined in file '{sourcePath}' for key route_defaults.apply_default_commands.key '{key}'"); + } + options.AddCommandDefault(key, value!); } - options.AddCommandDefault(key, value); - } - } if (config.RouteDefaults?.ApplyDefaultCommandsToQuerylessUrls ?? false){ options.SetApplyDefaultCommandsToQuerylessUrls(true); @@ -238,8 +242,10 @@ public HybridCacheOptions GetHybridCacheOptions(){ } var writeQueueRamMb = config.DiskCache.WriteQueueRamMb ?? 0; if (writeQueueRamMb > 0){ - options.WriteQueueMemoryMb = writeQueueRamMb; + //TODO: warn ignored + } + var EvictionSweepSizeMb = config.DiskCache.EvictionSweepSizeMb ?? 0; if (EvictionSweepSizeMb > 0){ options.EvictionSweepSizeMb = EvictionSweepSizeMb; diff --git a/src/Imageflow.Server.Configuration/Future/ParseStructures.cs b/src/Imageflow.Server.Configuration/Future/ParseStructures.cs index 8c436219..a3bb741f 100644 --- a/src/Imageflow.Server.Configuration/Future/ParseStructures.cs +++ b/src/Imageflow.Server.Configuration/Future/ParseStructures.cs @@ -1,13 +1,6 @@ namespace Imageflow.Server.Configuration.Future.ParseStructures; using Tomlyn.Model; -using System.Runtime.CompilerServices; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Mvc; -using Tomlyn.Syntax; -using System.Text.RegularExpressions; -using System.Runtime.InteropServices; -using Imageflow.Fluent; using Imageflow.Server.Configuration.Parsing; #nullable enable diff --git a/src/Imageflow.Server.Configuration/Imageflow.Server.Configuration.csproj b/src/Imageflow.Server.Configuration/Imageflow.Server.Configuration.csproj index 8bc4449e..9efd9594 100644 --- a/src/Imageflow.Server.Configuration/Imageflow.Server.Configuration.csproj +++ b/src/Imageflow.Server.Configuration/Imageflow.Server.Configuration.csproj @@ -1,27 +1,27 @@ + + - net7.0 - 11 - enable - enable - true + net8.0 + true + true + true + true + true - - - runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - + + diff --git a/src/Imageflow.Server.Configuration/InternalsVisibleTo.cs b/src/Imageflow.Server.Configuration/InternalsVisibleTo.cs new file mode 100644 index 00000000..96519ce7 --- /dev/null +++ b/src/Imageflow.Server.Configuration/InternalsVisibleTo.cs @@ -0,0 +1,6 @@ + +// internals visible to Imageflow.Server.Configuration.Tests + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Imageflow.Server.Configuration.Tests")] \ No newline at end of file diff --git a/src/Imageflow.Server.Configuration/MinimalParseStructures.cs b/src/Imageflow.Server.Configuration/MinimalParseStructures.cs index 8117720b..37e579ef 100644 --- a/src/Imageflow.Server.Configuration/MinimalParseStructures.cs +++ b/src/Imageflow.Server.Configuration/MinimalParseStructures.cs @@ -1,12 +1,6 @@ namespace Imageflow.Server.Configuration.Parsing; using Tomlyn.Model; -using System.Runtime.CompilerServices; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Mvc; -using Tomlyn.Syntax; -using System.Text.RegularExpressions; -using System.Runtime.InteropServices; using Imageflow.Fluent; #nullable enable diff --git a/src/Imageflow.Server.Configuration/SimpleConfigRedactor.cs b/src/Imageflow.Server.Configuration/SimpleConfigRedactor.cs index 33bd4bde..76304bb9 100644 --- a/src/Imageflow.Server.Configuration/SimpleConfigRedactor.cs +++ b/src/Imageflow.Server.Configuration/SimpleConfigRedactor.cs @@ -1,4 +1,5 @@ //diagnostics.allow_with_password and //license.key + using Tomlyn.Model; namespace Imageflow.Server.Configuration.Parsing; diff --git a/src/Imageflow.Server.Configuration/StringInterpolation.cs b/src/Imageflow.Server.Configuration/StringInterpolation.cs index 873b857c..a4cefbe4 100644 --- a/src/Imageflow.Server.Configuration/StringInterpolation.cs +++ b/src/Imageflow.Server.Configuration/StringInterpolation.cs @@ -1,4 +1,3 @@ - using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; diff --git a/src/Imageflow.Server.Configuration/TomlParser.cs b/src/Imageflow.Server.Configuration/TomlParser.cs index 1d44c8e8..9ea0f532 100644 --- a/src/Imageflow.Server.Configuration/TomlParser.cs +++ b/src/Imageflow.Server.Configuration/TomlParser.cs @@ -4,25 +4,9 @@ namespace Imageflow.Server.Configuration; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; -using System.Reflection; -using System.Reflection.Metadata.Ecma335; -using System.Text; -using System.Text.RegularExpressions; -using Imageflow.Fluent; -using Imageflow.Server; -using Imageflow.Server.HybridCache; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Rewrite; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; - -using Tomlyn.Model; -using Tomlyn.Syntax; - using Imageflow.Server.Configuration.Parsing; using Imageflow.Server.Configuration.Execution; diff --git a/src/Imageflow.Server.Configuration/kitchen-sink.toml b/src/Imageflow.Server.Configuration/kitchen-sink.toml index 4ce25ac1..ce6d8925 100644 --- a/src/Imageflow.Server.Configuration/kitchen-sink.toml +++ b/src/Imageflow.Server.Configuration/kitchen-sink.toml @@ -1,5 +1,75 @@ +# ===== Define connections (credentials & endpoints & configs) to remote services + +[connections.s3-east] +type = "s3" +region = "us-east-1" # required +endpoint = "https://s3.amazonaws.com" +access_key_id = "${env.S3AccessID}" # required +access_key_secret = "${env.S3AccessKey}" # required + +[connections.s3-auto] +type = "s3" +sdk_use_default_credential = true # Use the default credential from the environment (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) + +[connections.s3-cache-connection] +type = "s3" +region = "us-west-2" # required +endpoint = "https://s3.amazonaws.com" +access_key_id = "${env.S3AccessID}" # required +access_key_secret = "${env.S3AccessKey}" # required + +[connections.r2] +type = "s3" +endpoint = "https://asfasfsaffsa.r2.cloudflarestorage.com" +access_key_id = "235r23f23gf33f2awsgdgqw4eggqwe" +secret_access_key = "bsdsabsbaddsabbbasdabdsasdbsadbbasdbsdasbdabsad" +region = "auto" + +[connections.spaces-nyc3] +type = "s3" +endpoint = "https://nyc3.digitaloceanspaces.com" # Find your endpoint in the control panel, under Settings. Prepend "https://". +region = "nyc3" # Use the region in your endpoint. +access_key_id = "C500000000000001O00N" # Access key pair. You can create access key pairs using the control panel or API. +secret_access_key = "${env.SPACES_SECRET}" # Secret access key defined through an environment variable.` + +[connections.azure] +type="azureblob" +account_url = "https://myaccount.blob.core.windows.net" +# credential.connection_string = "${AzureBlobConnection}" +credential.sdk_use_default_credential = true # Use the default credential from the environment (e.g. Azure Managed Identity) https://learn.microsoft.com/en-us/dotnet/azure/sdk/authentication/?tabs=command-line#authentication-in-server-environments + +[connections.https-client] +type = "http" +max_redirections = 6 # Max redirects to follow (0 to prevent redirections) +max_connections = 16 # Max simultaneous connections to the same host - increase unless you hit problems +max_retries = 3 # Max retries per request w/ jitter ttps://github.com/App-vNext/Polly/wiki/Retry-with-jitter +initial_retry_ms = 100 # Initial retry delay in milliseconds +require_https = true # Require HTTPS for all requests (TODO: implement) +# TODO - also implement a timeout, and a max response size, and magic byte verification +request_headers = [ + # { name = "Accept-Encoding", value = "gzip, deflate, br" }, + { name = "User-Agent", value = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36" }, + #{ name = "Accept", value = "image/webp,image/apng,*/*;q=0.8"} + { name = "Authorization", value = "image/webp,image/apng,*/*;q=0.8" }, +] + +# Define caches +[caches.s3-east-1-cache] +type = "s3" +connection = "s3-cache-connection" +bucket = "cache-imageflow-west-2" + +[sources.s3-source] +type = "s3" +connection = "s3-east" + + + +allowed_domains = ["*.example.com", "example.com"] # Allow requests to these domains (TODO: implement) +blocked_domains = ["*.example.org", "example.org"] # Block requests to these domains (TODO: implement) + # ====== Define Image Sources ====== [[source]] name = "fs" # name is required @@ -17,6 +87,8 @@ access_key_id = "${vars.S3AccessID}" # required access_key_secret = "${vars.S3AccessKey}" # required + + [[source]] name = "client1" [sources.http_client] diff --git a/src/Imageflow.Server.Configuration/packages.lock.json b/src/Imageflow.Server.Configuration/packages.lock.json index a18f215a..996a377b 100644 --- a/src/Imageflow.Server.Configuration/packages.lock.json +++ b/src/Imageflow.Server.Configuration/packages.lock.json @@ -1,39 +1,50 @@ { "version": 1, "dependencies": { - "net7.0": { + "net8.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", "requested": "[3.3.4, )", "resolved": "3.3.4", "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, "Tomlyn": { "type": "Direct", - "requested": "[0.16.2, )", - "resolved": "0.16.2", - "contentHash": "NVvOlecYWwhqQdE461UGHUeJ1t2DtGXU+L00LgtBTgWA16bUmMhUIRaCpSkRX5HqAeid/KlXmdH7Sul0mr6HJA==" + "requested": "[0.17.0, )", + "resolved": "0.17.0", + "contentHash": "3BRbxOjZwgdXBGemOvJWuydaG/KsKrnG1Z15Chruvih8Tc8bgtLcmDgo6LPRqiF5LNh6X4Vmos7YuGteBHfePA==" + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" }, "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", "dependencies": { "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", - "Imageflow.Net": "0.10.2" + "Imageflow.Net": "0.12.0" } }, "Imageflow.NativeRuntime.osx-x86_64": { @@ -58,23 +69,22 @@ }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", "dependencies": { - "Microsoft.CSharp": "4.7.0", - "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", - "Newtonsoft.Json": "[13.0.3, 14.0.0)" + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" } }, - "Microsoft.Build.Tasks.Git": { + "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.CSharp": { + "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -124,18 +134,42 @@ }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", - "resolved": "1.2.2", - "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, - "Newtonsoft.Json": { + "System.Collections.Immutable": { "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } }, "System.Memory": { "type": "Transitive", @@ -144,14 +178,33 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } }, "imageflow.server": { "type": "Project", "dependencies": { - "Imageflow.AllPlatforms": "[0.10.2, )", - "Imazen.Common": "[0.1.0--notset, )" + "Imageflow.AllPlatforms": "[0.12.0, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.IO.RecyclableMemoryStream": "[3.0.0, )" } }, "imageflow.server.hybridcache": { @@ -162,16 +215,36 @@ "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imazen.hybridcache": { "type": "Project", "dependencies": { - "Imazen.Common": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" } } } diff --git a/src/Imageflow.Server.DiskCache/ClassicDiskCacheToBlobCacheAdapter.cs b/src/Imageflow.Server.DiskCache/ClassicDiskCacheToBlobCacheAdapter.cs new file mode 100644 index 00000000..f2253564 --- /dev/null +++ b/src/Imageflow.Server.DiskCache/ClassicDiskCacheToBlobCacheAdapter.cs @@ -0,0 +1,113 @@ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Extensibility.ClassicDiskCache; +using Imazen.Common.Extensibility.StreamCache; +using Imazen.Common.Issues; + +namespace Imageflow.Server.DiskCache{ + internal class ClassicDiskCacheToBlobCacheAdapter: IBlobCache{ + + readonly IClassicDiskCache diskCache; + public ClassicDiskCacheToBlobCacheAdapter(string uniqueName, IClassicDiskCache diskCache){ + this.diskCache = diskCache; + this.UniqueName = uniqueName; + } + + public string UniqueName { get; } + + public IEnumerable GetIssues() => diskCache.GetIssues(); + + public BlobCacheCapabilities InitialCacheCapabilities { get; } + public Task> CacheFetch(IBlobCacheRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task CachePut(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + public async Task GetOrCreateBytes(byte[] key, AsyncBytesResult dataProviderCallback, CancellationToken cancellationToken, bool retrieveContentType) + { + var strKey = Encoding.UTF8.GetString(key); + var fileExtension = "jpg"; //TODO: fix this + var classicCacheResult = await diskCache.GetOrCreate(strKey, fileExtension, async (outStream) => { + var dataResult = await dataProviderCallback(cancellationToken); + await outStream.WriteAsync(dataResult.Bytes.Array,0, dataResult.Bytes.Count, + CancellationToken.None); + await outStream.FlushAsync(); + }); + + + var statusString = classicCacheResult.Result switch + { + CacheQueryResult.Hit => classicCacheResult.Data != null ? "MemoryHit" : "DiskHit", + CacheQueryResult.Miss => "Miss", + CacheQueryResult.Failed => "TimeoutAndFailed", + _ => "Unknown" + }; + + var result = new StreamCacheResult(){ + Data = classicCacheResult.Data ?? File.OpenRead(classicCacheResult.PhysicalPath), + ContentType = null, + Status = statusString + }; + return result; + } + + + public Task StartAsync(CancellationToken cancellationToken) + { + return diskCache.StartAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return diskCache.StopAsync(cancellationToken); + } + + + public Task>> CacheSearchByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task>>> CachePurgeByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task>> CacheSearchByTag(string tag, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task>>> CachePurgeByTag(string tag, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task CacheDelete(IBlobStorageReference reference, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task OnCacheEvent(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public ValueTask CacheHealthCheck(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/Imageflow.Server.DiskCache/DiskCacheHostedServiceProxy.cs b/src/Imageflow.Server.DiskCache/DiskCacheHostedServiceProxy.cs deleted file mode 100644 index ed528d5a..00000000 --- a/src/Imageflow.Server.DiskCache/DiskCacheHostedServiceProxy.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using Imazen.Common.Extensibility.ClassicDiskCache; -using Microsoft.Extensions.Hosting; - -namespace Imageflow.Server.DiskCache -{ - internal class DiskCacheHostedServiceProxy: IHostedService - { - private readonly IClassicDiskCache cache; - public DiskCacheHostedServiceProxy(IClassicDiskCache cache) - { - this.cache = cache; - } - public Task StartAsync(CancellationToken cancellationToken) - { - return cache.StartAsync(cancellationToken); - } - - public Task StopAsync(CancellationToken cancellationToken) - { - return cache.StopAsync(cancellationToken); - } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server.DiskCache/DiskCacheOptions.cs b/src/Imageflow.Server.DiskCache/DiskCacheOptions.cs index d5efd4d4..f06a869a 100644 --- a/src/Imageflow.Server.DiskCache/DiskCacheOptions.cs +++ b/src/Imageflow.Server.DiskCache/DiskCacheOptions.cs @@ -1,7 +1,18 @@ +using Imazen.Abstractions; + namespace Imageflow.Server.DiskCache { - public class DiskCacheOptions: Imazen.DiskCache.ClassicDiskCacheOptions + public class DiskCacheOptions: Imazen.DiskCache.ClassicDiskCacheOptions, IUniqueNamed { - public DiskCacheOptions(string physicalCacheDir):base(physicalCacheDir){} + [System.Obsolete("Use DiskCacheOptions(string uniqueName, string physicalCacheDir) instead")] + public DiskCacheOptions(string physicalCacheDir):base(physicalCacheDir){ + this.UniqueName = "LegacyDiskCache"; + } + public DiskCacheOptions(string uniqueName, string physicalCacheDir):base(physicalCacheDir){ + this.UniqueName = uniqueName; + } + + public string UniqueName { get; } + } } \ No newline at end of file diff --git a/src/Imageflow.Server.DiskCache/DiskCacheService.cs b/src/Imageflow.Server.DiskCache/DiskCacheService.cs index 3deb7ba7..c36ae5c7 100644 --- a/src/Imageflow.Server.DiskCache/DiskCacheService.cs +++ b/src/Imageflow.Server.DiskCache/DiskCacheService.cs @@ -1,7 +1,14 @@ +using System; using System.Collections.Generic; +using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; using Imazen.Common.Extensibility.ClassicDiskCache; +using Imazen.Common.Extensibility.StreamCache; using Imazen.Common.Instrumentation.Support.InfoAccumulators; using Imazen.Common.Issues; using Imazen.DiskCache; @@ -9,11 +16,20 @@ namespace Imageflow.Server.DiskCache { - public class DiskCacheService: IClassicDiskCache, IInfoProvider + + + + + public class DiskCacheService: IBlobCache, IInfoProvider { private readonly DiskCacheOptions options; private readonly ClassicDiskCache cache; private readonly ILogger logger; + private IBlobCache blobCacheImplementation; + + public string UniqueName => options.UniqueName; + + public DiskCacheService(DiskCacheOptions options, ILogger logger) { this.options = options; @@ -26,7 +42,35 @@ public IEnumerable GetIssues() { return cache.GetIssues(); } + public async Task GetOrCreateBytes(byte[] key, AsyncBytesResult dataProviderCallback, CancellationToken cancellationToken, bool retrieveContentType) + { + var strKey = Encoding.UTF8.GetString(key); + var fileExtension = "jpg"; //TODO: fix this + var classicCacheResult = await this.GetOrCreate(strKey, fileExtension, async (outStream) => { + var dataResult = await dataProviderCallback(cancellationToken); + await outStream.WriteAsync(dataResult.Bytes.Array,0, dataResult.Bytes.Count, + CancellationToken.None); + await outStream.FlushAsync(); + }); + + + var statusString = classicCacheResult.Result switch + { + CacheQueryResult.Hit => classicCacheResult.Data != null ? "MemoryHit" : "DiskHit", + CacheQueryResult.Miss => "Miss", + CacheQueryResult.Failed => "TimeoutAndFailed", + _ => "Unknown" + }; + + var result = new StreamCacheResult(){ + Data = classicCacheResult.Data ?? File.OpenRead(classicCacheResult.PhysicalPath), + ContentType = null, + Status = statusString + }; + return result; + } + public Task GetOrCreate(string key, string fileExtension, AsyncWriteResult writeCallback) { return cache.GetOrCreate(key, fileExtension, writeCallback); @@ -57,5 +101,41 @@ diskcache_subfolders 8192 */ } + + public Task> CacheFetch(IBlobCacheRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task CachePut(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task>> CacheSearchByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task>>> CachePurgeByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task CacheDelete(IBlobStorageReference reference, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task OnCacheEvent(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public BlobCacheCapabilities InitialCacheCapabilities { get; } + public ValueTask CacheHealthCheck(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } } } \ No newline at end of file diff --git a/src/Imageflow.Server.DiskCache/DiskCacheServiceExtensions.cs b/src/Imageflow.Server.DiskCache/DiskCacheServiceExtensions.cs index 23c6b233..1021481d 100644 --- a/src/Imageflow.Server.DiskCache/DiskCacheServiceExtensions.cs +++ b/src/Imageflow.Server.DiskCache/DiskCacheServiceExtensions.cs @@ -1,9 +1,8 @@ using System; -using Imazen.Common.Extensibility.ClassicDiskCache; +using Imazen.Abstractions.BlobCache; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Imazen.Common.Extensibility.Support; namespace Imageflow.Server.DiskCache { @@ -11,16 +10,15 @@ public static class DiskCacheServiceExtensions { public static IServiceCollection AddImageflowDiskCache(this IServiceCollection services, DiskCacheOptions options) { - services.AddSingleton((container) => + throw new NotSupportedException("Imageflow.Server.DiskCache is no longer supported. Use Imageflow.Server.HybridCache instead."); + services.AddSingleton((container) => { var logger = container.GetRequiredService>(); return new DiskCacheService(options, logger); }); - - - - services.AddHostedService(); + // crucial detail - otherwise IHostedService methods won't be called and stuff will break silently and terribly + services.AddHostedService>(); return services; } diff --git a/src/Imageflow.Server.DiskCache/Imageflow.Server.DiskCache.csproj b/src/Imageflow.Server.DiskCache/Imageflow.Server.DiskCache.csproj index bc7bffdd..1ac6708e 100644 --- a/src/Imageflow.Server.DiskCache/Imageflow.Server.DiskCache.csproj +++ b/src/Imageflow.Server.DiskCache/Imageflow.Server.DiskCache.csproj @@ -1,21 +1,16 @@ - net6.0 Imageflow.Server.DiskCache - Plugin for caching processed images to disk. Imageflow.Server plugin for caching processed images to disk. - true - + - - - - + diff --git a/src/Imageflow.Server.DiskCache/StreamCacheResult.cs b/src/Imageflow.Server.DiskCache/StreamCacheResult.cs new file mode 100644 index 00000000..cb124b2b --- /dev/null +++ b/src/Imageflow.Server.DiskCache/StreamCacheResult.cs @@ -0,0 +1,10 @@ +using System.IO; +using Imazen.Common.Extensibility.StreamCache; + +namespace Imageflow.Server.DiskCache; + +internal class StreamCacheResult: IStreamCacheResult{ + public Stream Data { get; set; } + public string ContentType { get; set; } + public string Status { get; set; } +} \ No newline at end of file diff --git a/src/Imageflow.Server.DiskCache/packages.lock.json b/src/Imageflow.Server.DiskCache/packages.lock.json index 5479148e..60581c77 100644 --- a/src/Imageflow.Server.DiskCache/packages.lock.json +++ b/src/Imageflow.Server.DiskCache/packages.lock.json @@ -1,6 +1,170 @@ { "version": 1, "dependencies": { + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "3.1.3", + "contentHash": "j6r0E+OVinD4s13CIZASYJLLLApStb1yh5Vig7moB2FE1UsMRj4TYJ/xioDjreVA0dyOFpbWny1/n2iSJMbmNg==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.diskcache": { + "type": "Project", + "dependencies": { + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Logging.Abstractions": "[3.1.3, )" + } + } + }, "net6.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", @@ -10,18 +174,18 @@ }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -71,8 +235,8 @@ }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, "System.Memory": { "type": "Transitive", @@ -80,13 +244,148 @@ "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" }, "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.diskcache": { + "type": "Project", + "dependencies": { + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Logging.Abstractions": "[3.1.3, )" + } + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "3.1.3", + "contentHash": "j6r0E+OVinD4s13CIZASYJLLLApStb1yh5Vig7moB2FE1UsMRj4TYJ/xioDjreVA0dyOFpbWny1/n2iSJMbmNg==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Memory": { "type": "Transitive", "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, diff --git a/src/Imageflow.Server.Host/Dockerfile b/src/Imageflow.Server.Host/Dockerfile new file mode 100644 index 00000000..7b16164e --- /dev/null +++ b/src/Imageflow.Server.Host/Dockerfile @@ -0,0 +1,38 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["src/Imageflow.Server.Host/Imageflow.Server.Host.csproj", "src/Imageflow.Server.Host/"] +COPY ["src/Imageflow.Server.Configuration/Imageflow.Server.Configuration.csproj", "src/Imageflow.Server.Configuration/"] +COPY ["src/Imazen.Common/Imazen.Common.csproj", "src/Imazen.Common/"] +COPY ["src/Imazen.Abstractions/Imazen.Abstractions.csproj", "src/Imazen.Abstractions/"] +COPY ["src/Imageflow.Server/Imageflow.Server.csproj", "src/Imageflow.Server/"] +COPY ["src/Imazen.Routing/Imazen.Routing.csproj", "src/Imazen.Routing/"] +COPY ["src/Imageflow.Server.HybridCache/Imageflow.Server.HybridCache.csproj", "src/Imageflow.Server.HybridCache/"] +COPY ["src/Imazen.HybridCache/Imazen.HybridCache.csproj", "src/Imazen.HybridCache/"] +COPY ["src/Imageflow.Server.Storage.RemoteReader/Imageflow.Server.Storage.RemoteReader.csproj", "src/Imageflow.Server.Storage.RemoteReader/"] +COPY ["src/Imageflow.Server.Storage.S3/Imageflow.Server.Storage.S3.csproj", "src/Imageflow.Server.Storage.S3/"] +COPY ["src/Imageflow.Server.Storage.AzureBlob/Imageflow.Server.Storage.AzureBlob.csproj", "src/Imageflow.Server.Storage.AzureBlob/"] +RUN dotnet restore "src/Imageflow.Server.Host/Imageflow.Server.Host.csproj" +COPY . . +WORKDIR "/src/src/Imageflow.Server.Host" +RUN dotnet build "Imageflow.Server.Host.csproj" -c $BUILD_CONFIGURATION -o /app/build --framework net8.0 +RUN dotnet test --no-restore --verbosity normal --framework net8.0 --configuration $BUILD_CONFIGURATION + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "Imageflow.Server.Host.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Imageflow.Server.Host.dll"] diff --git a/src/Imageflow.Server.Host/Imageflow.Server.Host.csproj b/src/Imageflow.Server.Host/Imageflow.Server.Host.csproj index 4a2fe0ff..666aa827 100644 --- a/src/Imageflow.Server.Host/Imageflow.Server.Host.csproj +++ b/src/Imageflow.Server.Host/Imageflow.Server.Host.csproj @@ -1,13 +1,23 @@ - net7.0 + net8.0 enable enable + latest + Linux + Speed + true + true + true + true + + .dockerignore + PreserveNewest @@ -16,7 +26,7 @@ - + diff --git a/src/Imageflow.Server.Host/Startup.cs b/src/Imageflow.Server.Host/Startup.cs index 05a0ddbe..19e1d952 100644 --- a/src/Imageflow.Server.Host/Startup.cs +++ b/src/Imageflow.Server.Host/Startup.cs @@ -1,6 +1,5 @@ namespace Imageflow.Server.Host; using Imageflow.Server.Configuration; -using Microsoft.AspNetCore.Mvc.ApplicationParts; public partial class Startup { diff --git a/src/Imageflow.Server.Host/packages.lock.json b/src/Imageflow.Server.Host/packages.lock.json index 2438ac27..c4b06e9b 100644 --- a/src/Imageflow.Server.Host/packages.lock.json +++ b/src/Imageflow.Server.Host/packages.lock.json @@ -1,19 +1,25 @@ { "version": 1, "dependencies": { - "net7.0": { + "net8.0": { "AWSSDK.Extensions.NETCore.Setup": { "type": "Direct", - "requested": "[3.7.2, )", - "resolved": "3.7.2", - "contentHash": "iFjbEnVB0f6Hr8L3EfdelHG7zxVQrOmeP9UIrX3IODR1eTsrqVrmq0mazdIr0GZK1YG2/DZiVt6tNyV1bayndw==", + "requested": "[3.7.300, )", + "resolved": "3.7.300", + "contentHash": "zMxAHFYSAWHsVV9Cn96nE+V40agRCjT0etF10f0d/nFMMb1z7lecVwNadq9JYyqlDj+jsVRH9ydk4Al4v/1+jg==", "dependencies": { - "AWSSDK.Core": "3.7.6", + "AWSSDK.Core": "3.7.300", "Microsoft.Extensions.Configuration.Abstractions": "2.0.0", "Microsoft.Extensions.DependencyInjection.Abstractions": "2.0.0", "Microsoft.Extensions.Logging.Abstractions": "2.0.0" } }, + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "rGE3+673xn1iZJCoMIuUb9lhqje4DiCB+qTyPAJ97Bw15c4N69ZbMAO/GJyxchaoLJCHIPXP52hwxEzqrllwCg==" + }, "Microsoft.Extensions.Http.Polly": { "type": "Direct", "requested": "[5.0.0, )", @@ -25,26 +31,32 @@ "Polly.Extensions.Http": "3.0.0" } }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, "AWSSDK.Core": { "type": "Transitive", - "resolved": "3.7.103.11", - "contentHash": "twTMAAOA4ztiVtfrq2ZiRUmSnt2CW3DZwGvlbcftM6Dbsc2VGWE/hWv1Rl6GfKpEbrJxkW+FmG2o27XRTg8lqA==" + "resolved": "3.7.302.12", + "contentHash": "OaFHc2FD3vYldtdaH0Sqob3NoTXFBBMKxmq/VV3of5l+hHZmcaAjpJhh1Et0BxggeHqimVOTaoQxEO0PXQHeuw==" }, "AWSSDK.S3": { "type": "Transitive", - "resolved": "3.7.101.49", - "contentHash": "GN0pFLWqDe53UDbMEtZ4RkU64+mtJS4OsADemJ0TTaUlLhH5xJCkj5nhTa7eFG3tiIKbtdeMDUPhX47F3+UimQ==", + "resolved": "3.7.305.28", + "contentHash": "kjnmtEIddQPmS8XxwGwu1Gdc0vO+3bbohPSH59s8OdXnZlSMtsldJM+jwS5LTz4DUusFwMk3E8h0DY0TlwkVzg==", "dependencies": { - "AWSSDK.Core": "[3.7.103.11, 4.0.0)" + "AWSSDK.Core": "[3.7.302.12, 4.0.0)" } }, "Azure.Core": { "type": "Transitive", - "resolved": "1.25.0", - "contentHash": "X8Dd4sAggS84KScWIjEbFAdt2U1KDolQopTPoHVubG2y3CM54f9l6asVrP5Uy384NWXjsspPYaJgz5xHc+KvTA==", + "resolved": "1.37.0", + "contentHash": "ZSWV/ftBM/c/+Eo2hlzeEWntjqNG+8TWXX/DAKjBSWIf7nEvFILQoJL7JZx5HjypDvdNUMj5J2ji8ZpFFSghSg==", "dependencies": { "Microsoft.Bcl.AsyncInterfaces": "1.1.1", - "System.Diagnostics.DiagnosticSource": "4.6.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", "System.Memory.Data": "1.0.2", "System.Numerics.Vectors": "4.5.0", "System.Text.Encodings.Web": "4.7.2", @@ -52,34 +64,53 @@ "System.Threading.Tasks.Extensions": "4.5.4" } }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.10.4", + "contentHash": "hSvisZy9sld0Gik1X94od3+rRXCx+AKgi+iLH6fFdlnRZRePn7RtrqUGSsORiH2h8H2sc4NLTrnuUte1WL+QuQ==", + "dependencies": { + "Azure.Core": "1.36.0", + "Microsoft.Identity.Client": "4.56.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.56.0", + "System.Memory": "4.5.4", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, "Azure.Storage.Blobs": { "type": "Transitive", - "resolved": "12.14.1", - "contentHash": "DvRBWUDMB2LjdRbsBNtz/LiVIYk56hqzSooxx4uq4rCdLj2M+7Vvoa1r+W35Dz6ZXL6p+SNcgEae3oZ+CkPfow==", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", "dependencies": { - "Azure.Storage.Common": "12.13.0", + "Azure.Storage.Common": "12.18.1", "System.Text.Json": "4.7.2" } }, "Azure.Storage.Common": { "type": "Transitive", - "resolved": "12.13.0", - "contentHash": "jDv8xJWeZY2Er9zA6QO25BiGolxg87rItt9CwAp7L/V9EPJeaz8oJydaNL9Wj0+3ncceoMgdiyEv66OF8YUwWQ==", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", "dependencies": { - "Azure.Core": "1.25.0", + "Azure.Core": "1.36.0", "System.IO.Hashing": "6.0.0" } }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", "dependencies": { "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", - "Imageflow.Net": "0.10.2" + "Imageflow.Net": "0.12.0" } }, "Imageflow.NativeRuntime.osx-x86_64": { @@ -104,23 +135,39 @@ }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", "dependencies": { - "Microsoft.CSharp": "4.7.0", - "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", - "Newtonsoft.Json": "[13.0.3, 14.0.0)" + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" } }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==" + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.CSharp": { + "Microsoft.Extensions.Azure": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + "resolved": "1.7.2", + "contentHash": "EJJQKICExhTERoqY/m/A2dkfit7TqOJt2Mnhreg06D2BxioGnoOYV72s5a9NfIN8K4fABl04wRkdJ4+jgSPheQ==", + "dependencies": { + "Azure.Core": "1.37.0", + "Azure.Identity": "1.10.4", + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -130,6 +177,14 @@ "Microsoft.Extensions.Primitives": "2.2.0" } }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" + } + }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", "resolved": "5.0.0", @@ -203,15 +258,38 @@ "resolved": "5.0.0", "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "rr4zbidvHy9r4NvOAs5hdd964Ao2A0pAeFBJKR95u1CJAVzbd1p6tPTXUZ+5ld0cfThiVSGvz6UHwY6JjraTpA==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.22.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "H12YAzEGK55vZ+QpxUzozhW8ZZtgPDuWvgA0JbdIR9UhMUplj29JhIgE2imuH8W2Nw9D8JKygR1uxRFtpSNcrg==", + "dependencies": { + "Microsoft.Identity.Client": "4.56.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.22.0", + "contentHash": "iI+9V+2ciCrbheeLjpmjcqCnhy+r6yCoEcid3nkoFWerHgjVuT6CPM4HODUTtUPe1uwks4wcnAujJ8u+IKogHQ==" + }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", - "resolved": "1.2.2", - "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" }, - "Newtonsoft.Json": { + "Microsoft.NETCore.Platforms": { "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" }, "Polly": { "type": "Transitive", @@ -226,16 +304,62 @@ "Polly": "7.1.0" } }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "4.6.0", - "contentHash": "mbBgoR0rRfl2uimsZ2avZY8g7Xnh1Mza0rJZLPcxqiMWlkGukjmRkuMJ/er+AhQuiRIh80CR/Hpeztr80seV5g==" + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } }, "System.IO.Hashing": { "type": "Transitive", "resolved": "6.0.0", "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==" + }, "System.Memory.Data": { "type": "Transitive", "resolved": "1.0.2", @@ -250,15 +374,46 @@ "resolved": "4.5.0", "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "iTUgB/WtrZ1sWZs84F2hwyQhiRH6QNjQv2DkwrH+WP6RoFga2Q1m3f9/Q7FG8cck8AdHitQkmkXSY8qylcDmuA==" + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } }, "System.Text.Json": { "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } }, "System.Threading.Tasks.Extensions": { "type": "Transitive", @@ -267,14 +422,16 @@ }, "Tomlyn": { "type": "Transitive", - "resolved": "0.16.2", - "contentHash": "NVvOlecYWwhqQdE461UGHUeJ1t2DtGXU+L00LgtBTgWA16bUmMhUIRaCpSkRX5HqAeid/KlXmdH7Sul0mr6HJA==" + "resolved": "0.17.0", + "contentHash": "3BRbxOjZwgdXBGemOvJWuydaG/KsKrnG1Z15Chruvih8Tc8bgtLcmDgo6LPRqiF5LNh6X4Vmos7YuGteBHfePA==" }, "imageflow.server": { "type": "Project", "dependencies": { - "Imageflow.AllPlatforms": "[0.10.2, )", - "Imazen.Common": "[0.1.0--notset, )" + "Imageflow.AllPlatforms": "[0.12.0, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.IO.RecyclableMemoryStream": "[3.0.0, )" } }, "imageflow.server.configuration": { @@ -283,7 +440,7 @@ "Imageflow.Server": "[0.1.0--notset, )", "Imageflow.Server.HybridCache": "[0.1.0--notset, )", "Imazen.Common": "[0.1.0--notset, )", - "Tomlyn": "[0.16.2, )" + "Tomlyn": "[0.17.0, )" } }, "imageflow.server.hybridcache": { @@ -297,8 +454,9 @@ "imageflow.server.storage.azureblob": { "type": "Project", "dependencies": { - "Azure.Storage.Blobs": "[12.14.1, )", - "Imazen.Common": "[0.1.0--notset, )" + "Azure.Storage.Blobs": "[12.19.1, )", + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Azure": "[1.7.2, )" } }, "imageflow.server.storage.remotereader": { @@ -311,20 +469,41 @@ "imageflow.server.storage.s3": { "type": "Project", "dependencies": { - "AWSSDK.S3": "[3.7.101.49, )", - "Imazen.Common": "[0.1.0--notset, )" + "AWSSDK.S3": "[3.7.305.28, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imazen.hybridcache": { "type": "Project", "dependencies": { - "Imazen.Common": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" } } } diff --git a/src/Imageflow.Server.Host/test.ps1 b/src/Imageflow.Server.Host/test.ps1 new file mode 100644 index 00000000..10d66329 --- /dev/null +++ b/src/Imageflow.Server.Host/test.ps1 @@ -0,0 +1,146 @@ + +# Get the directory this file is in, and change to it. +$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +Set-Location $scriptPath + +# First, we publish to a folder. +dotnet publish -c Release ./Imageflow.Server.Host.csproj -o ./test-publish/ +# if the above fails, exit with a non-zero exit code. +if ($LASTEXITCODE -ne 0) { + Write-Output "Failed to publish the AOT test project. Exiting." + Write-Warning "Failed to publish the AOT test project. Exiting." + + exit 1 +} + +# run the executable in the background in ./publish/native/Imageflow.Server.Host.exe or ./publish/native/Imageflow.Server.Host +$process = $null +if (Test-Path -Path ./publish/native/Imageflow.Server.Host.exe) { + $process = Start-Process -FilePath ./test-publish/Imageflow.Server.Host.exe -NoNewWindow -PassThru -RedirectStandardOutput "./output.log" +} else { + $process = Start-Process -FilePath ./test-publish/Imageflow.Server.Host -NoNewWindow -PassThru -RedirectStandardOutput "./output.log" +} +# report on the process, if it started +if ($null -eq $process) { + Write-Output "Failed to start the server. Exiting." + Write-Warning "Failed to start the server. Exiting." + exit 1 +} +Write-Output "Started the server with PID $($process.Id)" + +# quit if the process failed to start +if ($LASTEXITCODE -ne 0) { + exit 1 +} +# store the PID of the executable +$server_pid = $process.Id + +# wait for the server to start 200ms +Start-Sleep -Milliseconds 200 + +$output = (Get-Content "./output.log"); +# if null, it failed to start +if ($output -eq $null) { + Write-Error "Failed to start the server (no output). Exiting." + exit 1 +} + +Write-Output "Server output:" +Write-Output $output + +# parse the port from the output log +$port = 5000 +$portRegex = [regex]::new("Now listening on: http://localhost:(\d+)") +$portMatch = $portRegex.Match($output) +if ($portMatch.Success) { + $port = $portMatch.Groups[1].Value +} + + + +# if the process doesn't respond to a request, sleep 5 seconds and try again +$timeout = 5 +$timeoutCounter = 0 +while ($timeoutCounter -lt $timeout) { + + # try to make a request to the server + $timeoutMs = $timeoutCounter * 500 + 200 + $url = "http://localhost:$port/" + try{ + $response = Invoke-WebRequest -Uri $url -TimeoutSec 1 -OutVariable response + if ($response -ne $null) { + Write-Output "Server responded to GET $url with status code $($response.StatusCode)" + break + } + } catch { + Write-Warning "Failed to make a request to $url with exception $_" + $timeoutCounter++ + + # if the process is not running, exit with a non-zero exit code + if (-not (Get-Process -Id $server_pid -ErrorAction SilentlyContinue)) { + Write-Warning "Server process with PID $server_pid is not running, crash detected. Exiting." + exit 1 + } + Start-Sleep -Seconds 1 + continue + } + Write-Warning "Server is not responding to requests at $url yet (timeout $timeoutMs), sleeping 1 second" + # Find what's new in the output log that isn't in $output + $newOutput = Get-Content "./output.log" | Select-Object -Skip $output.Length + Write-Output $newOutput + + Start-Sleep -Seconds 1 + $timeoutCounter++ +} + +$testsFailed = 0 +try +{ + # test /imageflow/version + $version = Invoke-WebRequest -Uri http://localhost:5000/imageflow/version + if ($LASTEXITCODE -ne 0) + { + Write-Error "Request to /imageflow/version failed with exit code $LASTEXITCODE" + $testsFailed += 1 + } +} catch { + Write-Error "Request to /imageflow/version failed with exception $_" + $testsFailed += 1 +} + +# test /imageflow/resize/width/10 +try +{ + $resize = Invoke-WebRequest -Uri http://localhost:5000/imageflow/resize/width/10 + if ($LASTEXITCODE -ne 0) + { + Write-Warning "Request to /imageflow/resize/width/10 failed with exit code $LASTEXITCODE" + $testsFailed += 1 + } +} catch { + Write-Warning "Request to /imageflow/resize/width/10 failed with exception $_" + $testsFailed += 1 +} +# exit with a non-zero exit code if any tests failed +if ($testsFailed -ne 0) +{ + Write-Warning "$testsFailed tests failed. Exiting." +} + +# kill the server +Stop-Process -Id $server_pid +if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to kill the server process with PID $server_pid" +} + +# print the process output +Get-Content "./output.log" + +# exit with a non-zero exit code if any tests failed +if ($testsFailed -ne 0) { + Write-Warning "$testsFailed tests failed. Exiting." + exit 1 +} +Write-Output "YAYYYY" +Write-Output "All tests passed. Exiting." +exit 0 \ No newline at end of file diff --git a/src/Imageflow.Server.HybridCache/HybridCacheHostedServiceProxy.cs b/src/Imageflow.Server.HybridCache/HybridCacheHostedServiceProxy.cs deleted file mode 100644 index 49da2962..00000000 --- a/src/Imageflow.Server.HybridCache/HybridCacheHostedServiceProxy.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using Imazen.Common.Extensibility.StreamCache; -using Microsoft.Extensions.Hosting; - -namespace Imageflow.Server.HybridCache -{ - internal class HybridCacheHostedServiceProxy: IHostedService - { - - private readonly IStreamCache cache; - public HybridCacheHostedServiceProxy(IStreamCache cache) - { - this.cache = cache; - } - public Task StartAsync(CancellationToken cancellationToken) - { - return cache.StartAsync(cancellationToken); - } - - public Task StopAsync(CancellationToken cancellationToken) - { - return cache.StopAsync(cancellationToken); - } - - } -} \ No newline at end of file diff --git a/src/Imageflow.Server.HybridCache/HybridCacheOptions.cs b/src/Imageflow.Server.HybridCache/HybridCacheOptions.cs index 55b2dc75..23bf0d92 100644 --- a/src/Imageflow.Server.HybridCache/HybridCacheOptions.cs +++ b/src/Imageflow.Server.HybridCache/HybridCacheOptions.cs @@ -1,88 +1,12 @@ -using System; +namespace Imageflow.Server.HybridCache; -namespace Imageflow.Server.HybridCache +public class HybridCacheOptions : Imazen.HybridCache.HybridCacheOptions { - public class HybridCacheOptions + public HybridCacheOptions(string cacheDir) : base(cacheDir) { - /// - /// Where to store the cached files and the database - /// - public string DiskCacheDirectory { get; set; } - - /// - /// How many RAM bytes to use when writing asynchronously to disk before we switch to writing synchronously. - /// Defaults to 100MiB. - /// - public long QueueSizeLimitInBytes { get; set; } = 100 * 1024 * 1024; - - /// - /// Defaults to 1 GiB. Don't set below 9MB or no files will be cached, since 9MB is reserved just for empty directory entries. - /// - public long CacheSizeLimitInBytes { get; set; } = 1 * 1024 * 1024 * 1024; - - /// - /// The minimum number of bytes to free when running a cleanup task. Defaults to 1MiB; - /// - public long MinCleanupBytes { get; set; } = 1 * 1024 * 1024; - - /// - /// How many MiB of ram to use when writing asynchronously to disk before we switch to writing synchronously. - /// Defaults to 100MiB. - /// - public long WriteQueueMemoryMb - { - get - { - return QueueSizeLimitInBytes / 1024 / 1024; - } - set - { - QueueSizeLimitInBytes = value * 1024 * 1024; - } - } - - /// - /// Defaults to 1 GiB. Don't set below 9MB or no files will be cached, since 9MB is reserved just for empty directory - /// entries. - /// - public long CacheSizeMb { get - { - return CacheSizeLimitInBytes / 1024 / 1024; - } - set - { - CacheSizeLimitInBytes = value * 1024 * 1024; - } - } - - /// - /// The minimum number of mibibytes (1024*1024) to free when running a cleanup task. Defaults to 1MiB; - /// - public long EvictionSweepSizeMb { get - { - return MinCleanupBytes / 1024 / 1024; - } - set - { - MinCleanupBytes = value * 1024 * 1024; - } - } - - /// - /// The minimum age of files to delete. Defaults to 10 seconds. - /// - public TimeSpan MinAgeToDelete { get; set; } = TimeSpan.FromSeconds(10); - - /// - /// The number of shards to split the metabase into. More shards means more open log files, slower shutdown. - /// But more shards also mean less lock contention and faster start time for individual cached requests. - /// Defaults to 8. You have to delete the database directory each time you change this number. - /// - public int DatabaseShards { get; set; } = 8; + } - public HybridCacheOptions(string cacheDir) - { - DiskCacheDirectory = cacheDir; - } + public HybridCacheOptions(string uniqueName, string cacheDir) : base(uniqueName, cacheDir) + { } } \ No newline at end of file diff --git a/src/Imageflow.Server.HybridCache/HybridCacheService.cs b/src/Imageflow.Server.HybridCache/HybridCacheService.cs index 0a39e85f..88820a1e 100644 --- a/src/Imageflow.Server.HybridCache/HybridCacheService.cs +++ b/src/Imageflow.Server.HybridCache/HybridCacheService.cs @@ -1,64 +1,35 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Imazen.Common.Extensibility.StreamCache; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Logging; using Imazen.Common.Issues; -using Imazen.HybridCache; -using Imazen.HybridCache.MetaStore; -using Microsoft.Extensions.Logging; namespace Imageflow.Server.HybridCache { - public class HybridCacheService : IStreamCache + public class HybridCacheService : IBlobCacheProvider { - private readonly Imazen.HybridCache.HybridCache cache; - public HybridCacheService(HybridCacheOptions options, ILogger logger) + private readonly List namedCaches = new List(); + public HybridCacheService(IEnumerable namedCacheConfigurations, IReLoggerFactory loggerFactory) { - - var cacheOptions = new Imazen.HybridCache.HybridCacheOptions(options.DiskCacheDirectory) - { - AsyncCacheOptions = new AsyncCacheOptions() - { - MaxQueuedBytes = Math.Max(0,options.QueueSizeLimitInBytes), - WriteSynchronouslyWhenQueueFull = false, - MoveFileOverwriteFunc = (from, to) => File.Move(from, to, true) - }, - CleanupManagerOptions = new CleanupManagerOptions() - { - MaxCacheBytes = Math.Max(0, options.CacheSizeLimitInBytes), - MinCleanupBytes = Math.Max(0, options.MinCleanupBytes), - MinAgeToDelete = options.MinAgeToDelete.Ticks > 0 ? options.MinAgeToDelete : TimeSpan.Zero, - } - }; - var database = new MetaStore(new MetaStoreOptions(options.DiskCacheDirectory) - { - Shards = Math.Max(1, options.DatabaseShards), - MaxLogFilesPerShard = 3, - }, cacheOptions, logger); - cache = new Imazen.HybridCache.HybridCache(database, cacheOptions, logger); + namedCaches.AddRange(namedCacheConfigurations.Select(c => new HybridNamedCache(c, loggerFactory))); } - public IEnumerable GetIssues() + public HybridCacheService(HybridCacheOptions options, IReLoggerFactory loggerFactory) { - return cache.GetIssues(); + namedCaches.Add(new HybridNamedCache(options, loggerFactory)); } + public IEnumerable GetIssues() => namedCaches.SelectMany(c => c.GetIssues()); + + public Task StartAsync(CancellationToken cancellationToken) { - return cache.StartAsync(cancellationToken); + return Task.WhenAll(namedCaches.Select(c => c.StartAsync(cancellationToken))); } public Task StopAsync(CancellationToken cancellationToken) { - return cache.StopAsync(cancellationToken); + return Task.WhenAll(namedCaches.Select(c => c.StopAsync(cancellationToken))); } - public Task GetOrCreateBytes(byte[] key, AsyncBytesResult dataProviderCallback, CancellationToken cancellationToken, - bool retrieveContentType) - { - return cache.GetOrCreateBytes(key, dataProviderCallback, cancellationToken, retrieveContentType); - } + public IEnumerable GetBlobCaches() => namedCaches; } } diff --git a/src/Imageflow.Server.HybridCache/HybridCacheServiceExtensions.cs b/src/Imageflow.Server.HybridCache/HybridCacheServiceExtensions.cs index cf3c6fb4..30b0f547 100644 --- a/src/Imageflow.Server.HybridCache/HybridCacheServiceExtensions.cs +++ b/src/Imageflow.Server.HybridCache/HybridCacheServiceExtensions.cs @@ -1,6 +1,7 @@ -using Imazen.Common.Extensibility.StreamCache; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Logging; +using Imazen.Common.Extensibility.Support; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Imageflow.Server.HybridCache { @@ -10,13 +11,26 @@ public static class HybridCacheServiceExtensions public static IServiceCollection AddImageflowHybridCache(this IServiceCollection services, HybridCacheOptions options) { - services.AddSingleton((container) => + services.AddImageflowReLogStoreAndReLoggerFactoryIfMissing(); + services.AddSingleton((container) => { - var logger = container.GetRequiredService>(); - return new HybridCacheService(options, logger); + var loggerFactory = container.GetRequiredService(); + return new HybridCacheService(options, loggerFactory); }); - services.AddHostedService(); + services.AddHostedService>(); + return services; + } + + public static IServiceCollection AddImageflowHybridCaches(this IServiceCollection services, IEnumerable namedCacheConfigurations) + { + services.AddSingleton((container) => + { + var loggerFactory = container.GetRequiredService(); + return new HybridCacheService(namedCacheConfigurations, loggerFactory); + }); + + services.AddHostedService>(); return services; } } diff --git a/src/Imageflow.Server.HybridCache/HybridNamedCache.cs b/src/Imageflow.Server.HybridCache/HybridNamedCache.cs new file mode 100644 index 00000000..80e642c2 --- /dev/null +++ b/src/Imageflow.Server.HybridCache/HybridNamedCache.cs @@ -0,0 +1,11 @@ +using Imazen.Abstractions.Logging; + +namespace Imageflow.Server.HybridCache +{ + internal class HybridNamedCache : Imazen.HybridCache.HybridCache + { + public HybridNamedCache(HybridCacheOptions options, IReLoggerFactory loggerFactory): base(options, loggerFactory) + {} + + } +} diff --git a/src/Imageflow.Server.HybridCache/Imageflow.Server.HybridCache.csproj b/src/Imageflow.Server.HybridCache/Imageflow.Server.HybridCache.csproj index cb81d680..1bc20769 100644 --- a/src/Imageflow.Server.HybridCache/Imageflow.Server.HybridCache.csproj +++ b/src/Imageflow.Server.HybridCache/Imageflow.Server.HybridCache.csproj @@ -1,15 +1,13 @@ - net6.0 Imageflow.Server.HybridCache - Plugin for caching processed images to disk while limiting the overall cache size. Imageflow.Server plugin for caching processed images to a limit amount of space on disk. - true - + + - runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Imageflow.Server.HybridCache/packages.lock.json b/src/Imageflow.Server.HybridCache/packages.lock.json index 1839a31d..821e512d 100644 --- a/src/Imageflow.Server.HybridCache/packages.lock.json +++ b/src/Imageflow.Server.HybridCache/packages.lock.json @@ -1,6 +1,187 @@ { "version": 1, "dependencies": { + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.hybridcache": { + "type": "Project", + "dependencies": { + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + } + }, "net6.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", @@ -22,18 +203,23 @@ }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -72,8 +258,24 @@ }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } }, "System.Memory": { "type": "Transitive", @@ -81,20 +283,178 @@ "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" }, "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.hybridcache": { + "type": "Project", + "dependencies": { + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "System.Memory": { "type": "Transitive", "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imazen.hybridcache": { "type": "Project", "dependencies": { - "Imazen.Common": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" } } } diff --git a/src/Imageflow.Server.Storage.AzureBlob/AzureBlob.cs b/src/Imageflow.Server.Storage.AzureBlob/AzureBlob.cs deleted file mode 100644 index 7af11358..00000000 --- a/src/Imageflow.Server.Storage.AzureBlob/AzureBlob.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.IO; -using Azure; -using Azure.Storage.Blobs.Models; -using Imazen.Common.Storage; - -namespace Imageflow.Server.Storage.AzureBlob -{ - internal class AzureBlob :IBlobData - { - private readonly Response response; - - internal AzureBlob(Response r) - { - response = r; - } - - public bool? Exists => true; - public DateTime? LastModifiedDateUtc => response.Value.Details.LastModified.UtcDateTime; - public Stream OpenRead() - { - return response.Value.Content; - } - - public void Dispose() - { - response?.Value?.Dispose(); - } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/AzureBlobHelper.cs b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobHelper.cs new file mode 100644 index 00000000..1dbcac6e --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobHelper.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; +using System.Linq; +using Azure.Storage.Blobs.Models; +using Imazen.Abstractions.Blobs; + +namespace Imageflow.Server.Storage.AzureBlob +{ + internal static class AzureBlobHelper + { + internal static IConsumableBlob CreateConsumableBlob(AzureBlobStorageReference reference, BlobDownloadStreamingResult r) + { + // metadata starting with t_ is a tag + + var attributes = new BlobAttributes() + { + BlobByteCount = r.Content.CanSeek ? r.Content.Length : null, + ContentType = r.Details.ContentType, + Etag = r.Details.ETag.ToString(), + LastModifiedDateUtc = r.Details.LastModified.UtcDateTime, + BlobStorageReference = reference, + StorageTags = r.Details.Metadata.Where(kvp => kvp.Key.StartsWith("t_")) + .Select(kvp => SearchableBlobTag.CreateUnvalidated(kvp.Key.Substring(2), kvp.Value)).ToList() + }; + return new ConsumableStreamBlob(attributes, r.Content, r); + } + } +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/AzureBlobService.cs b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobService.cs index 7b96f0c1..071aab59 100644 --- a/src/Imageflow.Server.Storage.AzureBlob/AzureBlobService.cs +++ b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobService.cs @@ -1,31 +1,67 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Azure; +using Azure; using Azure.Storage.Blobs; -using Imazen.Common.Storage; -using Microsoft.Extensions.Logging; +using Imageflow.Server.Storage.AzureBlob.Caching; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Microsoft.Extensions.Azure; namespace Imageflow.Server.Storage.AzureBlob { - public class AzureBlobService : IBlobProvider + public class AzureBlobService : IBlobWrapperProvider, IBlobCacheProvider, IBlobWrapperProviderZoned { private readonly List mappings = new List(); + private readonly List caches = new List(); + public IEnumerable GetBlobCaches() => caches; + private readonly BlobServiceClient client; - public AzureBlobService(AzureBlobServiceOptions options, ILogger logger) + private readonly IAzureClientFactory clientFactory; + + + public string UniqueName { get; } + public IEnumerable GetPrefixesAndZones() + { + return mappings.Select(m => new BlobWrapperPrefixZone(m.UrlPrefix, + new LatencyTrackingZone($"azure::blob/{m.Container}", 100))); + } + + public AzureBlobService(AzureBlobServiceOptions options, IReLoggerFactory loggerFactory, BlobServiceClient defaultClient, IAzureClientFactory clientFactory) { - client = new BlobServiceClient(options.ConnectionString, options.BlobClientOptions); + UniqueName = options.UniqueName ?? "azure-blob"; + var nameOrInstance = options.GetOrCreateClient(); + if (nameOrInstance.HasValue) + { + if (nameOrInstance.Value.Client != null) + client = nameOrInstance.Value.Client; + else + client = clientFactory.CreateClient(nameOrInstance.Value.Name); + } + else + { + client = defaultClient; + } + this.clientFactory = clientFactory; + foreach (var m in options.Mappings) { mappings.Add(m); } mappings.Sort((a, b) => b.UrlPrefix.Length.CompareTo(a.UrlPrefix.Length)); + + options.NamedCaches.ForEach(c => + { + var cache = new AzureBlobCache(c, clientName => clientName == null ? client : clientFactory.CreateClient(clientName), loggerFactory); //TODO! get logging working + caches.Add(cache); + }); } + + public IEnumerable GetPrefixes() { return mappings.Select(m => m.UrlPrefix); @@ -36,14 +72,15 @@ public bool SupportsPath(string virtualPath) return mappings.Any(s => virtualPath.StartsWith(s.UrlPrefix, s.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); } + - public async Task Fetch(string virtualPath) + public async Task> Fetch(string virtualPath) { var mapping = mappings.FirstOrDefault(s => virtualPath.StartsWith(s.UrlPrefix, s.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); if (mapping.UrlPrefix == null) { - return null; + return CodeResult.Err(HttpStatus.NotFound.WithAddFrom($"Azure blob mapping not found for \"{virtualPath}\"")); } var partialKey = virtualPath.Substring(mapping.UrlPrefix.Length).TrimStart('/'); @@ -60,22 +97,24 @@ public async Task Fetch(string virtualPath) try { - var blobClient = client.GetBlobContainerClient(mapping.Container).GetBlobClient(key); - - var s = await blobClient.DownloadAsync(); - return new AzureBlob(s); + var containerClient = client.GetBlobContainerClient(mapping.Container); + var blobClient = containerClient.GetBlobClient(key); + var reference = new AzureBlobStorageReference(containerClient.Uri.AbsoluteUri, key); + var s = await blobClient.DownloadStreamingAsync(); + var latencyZone = new LatencyTrackingZone($"azure::blob/{mapping.Container}", 100); + return CodeResult.Ok(new BlobWrapper(latencyZone,AzureBlobHelper.CreateConsumableBlob(reference, s.Value))); } catch (RequestFailedException e) { if (e.Status == 404) { - throw new BlobMissingException($"Azure blob \"{key}\" not found.\n({e.Message})", e); + return CodeResult.Err(HttpStatus.NotFound.WithAddFrom($"Azure blob \"{key}\" not found.\n({e.Message})")); } - throw; - } } + + } } \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/AzureBlobServiceExtensions.cs b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobServiceExtensions.cs index f9ba6d43..719350aa 100644 --- a/src/Imageflow.Server.Storage.AzureBlob/AzureBlobServiceExtensions.cs +++ b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobServiceExtensions.cs @@ -1,6 +1,8 @@ -using Imazen.Common.Storage; +using Azure.Storage.Blobs; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.Logging; +using Microsoft.Extensions.Azure; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Imageflow.Server.Storage.AzureBlob { @@ -10,10 +12,14 @@ public static class AzureBlobServiceExtensions public static IServiceCollection AddImageflowAzureBlobService(this IServiceCollection services, AzureBlobServiceOptions options) { - services.AddSingleton((container) => + services.AddImageflowReLogStoreAndReLoggerFactoryIfMissing(); + services.AddSingleton((container) => { - var logger = container.GetRequiredService>(); - return new AzureBlobService(options, logger); + var loggerFactory = container.GetRequiredService(); + var blobServiceClient = container.GetService(); + var clientFactory = container.GetService>(); + + return new AzureBlobService(options, loggerFactory, blobServiceClient, clientFactory); }); return services; diff --git a/src/Imageflow.Server.Storage.AzureBlob/AzureBlobServiceOptions.cs b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobServiceOptions.cs index 9217871d..44f057d4 100644 --- a/src/Imageflow.Server.Storage.AzureBlob/AzureBlobServiceOptions.cs +++ b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobServiceOptions.cs @@ -1,22 +1,67 @@ -using System; -using System.Collections.Generic; using Azure.Storage.Blobs; +using Imageflow.Server.Storage.AzureBlob.Caching; namespace Imageflow.Server.Storage.AzureBlob { public class AzureBlobServiceOptions - { - public BlobClientOptions BlobClientOptions { get; set; } - public string ConnectionString { get; set; } + { + [Obsolete("Use BlobServiceClient instead")] + public BlobClientOptions? BlobClientOptions { get; set; } + + [Obsolete("Use BlobServiceClient instead")] + public string? ConnectionString { get; set; } + + internal readonly List NamedCaches = new List(); + + internal BlobClientOrName? BlobServiceClient { get; set; } + public string? UniqueName { get; set; } internal readonly List Mappings = new List(); - public AzureBlobServiceOptions(string connectionString, BlobClientOptions blobClientOptions = null) + [Obsolete("Use AzureBlobServiceOptions(BlobServiceClient client) or .ICalledAddBlobServiceClient instead")] + public AzureBlobServiceOptions(string connectionString, BlobClientOptions? blobClientOptions = null) { BlobClientOptions = blobClientOptions ?? new BlobClientOptions(); ConnectionString = connectionString; } + /// + /// If you use the Azure SDK to call AddBlobServiceClient, you can use ICalledAddBlobServiceClient to create an AzureBlobServiceOptions instance without directly specifying it. + /// + /// + public AzureBlobServiceOptions(BlobServiceClient blobServiceClient) + { + BlobServiceClient = new BlobClientOrName(blobServiceClient); + } + + /// + /// Relies on a separate + /// + internal AzureBlobServiceOptions() + { + } + + /// + /// If you use the Azure SDK to call AddBlobServiceClient, you can use this method to create an AzureBlobServiceOptions instance. + /// https://learn.microsoft.com/en-us/dotnet/azure/sdk/dependency-injection + /// + /// + // ReSharper disable once InconsistentNaming + public static AzureBlobServiceOptions ICalledAddBlobServiceClient(){ + return new AzureBlobServiceOptions(); + } + // ReSharper disable once InconsistentNaming + public static AzureBlobServiceOptions ICalledAddBlobServiceClientWithName(string blobClientName){ + return new AzureBlobServiceOptions(){ + BlobServiceClient = new BlobClientOrName(blobClientName) + }; + } + + +//turn off obsolete warnings for this line +#pragma warning disable 618 + internal BlobClientOrName? GetOrCreateClient() => BlobServiceClient ?? ((ConnectionString != null && BlobClientOptions != null) ? new BlobClientOrName(new BlobServiceClient(ConnectionString, BlobClientOptions)) : default(BlobClientOrName?)); +#pragma warning restore 618 public AzureBlobServiceOptions MapPrefix(string urlPrefix, string container) => MapPrefix(urlPrefix, container, ""); @@ -48,5 +93,17 @@ public AzureBlobServiceOptions MapPrefix(string urlPrefix, string container, str }); return this; } + + /// + /// Adds a named cache location to the AzureBlobService. This allows you to use the same service instance to provide persistence for caching result images. + /// You must also ensure that the bucket is located in the same AWS region as the Imageflow Server, and that it is not publicly accessible. + /// + /// + /// + public AzureBlobServiceOptions AddNamedCacheConfiguration(NamedCacheConfiguration namedCacheConfiguration) + { + NamedCaches.Add(namedCacheConfiguration); + return this; + } } } \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/AzureBlobStorageReference.cs b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobStorageReference.cs new file mode 100644 index 00000000..cb216e5a --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/AzureBlobStorageReference.cs @@ -0,0 +1,15 @@ +using Imazen.Abstractions.Blobs; +using Imazen.Common.Extensibility.Support; + +namespace Imageflow.Server.Storage.AzureBlob; + +internal record AzureBlobStorageReference(string AccountAndContainerPrefix, string BlobName) : IBlobStorageReference +{ + public string GetFullyQualifiedRepresentation() + { + return $"{AccountAndContainerPrefix}/{BlobName}"; + } + + public int EstimateAllocatedBytesRecursive => + 24 + AccountAndContainerPrefix.EstimateMemorySize(true) + BlobName.EstimateMemorySize(true); +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/Caching/AzureBlobCache.cs b/src/Imageflow.Server.Storage.AzureBlob/Caching/AzureBlobCache.cs new file mode 100644 index 00000000..da0953cb --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/Caching/AzureBlobCache.cs @@ -0,0 +1,224 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Imazen.Abstractions.BlobCache; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using System.Collections.Generic; +using System.Linq; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; + +namespace Imageflow.Server.Storage.AzureBlob.Caching +{ + + /// + /// Best used with Azure Block Blob Premium Storage (SSD) (LRS and Flat Namespace are fine; GRS is not needed for caches). Costs ~20% more but is much faster. + /// + internal class AzureBlobCache : IBlobCache + { + private NamedCacheConfiguration config; + private BlobServiceClient defaultBlobServiceClient; + private ILogger logger; + + private Tuple[] containerExistenceCache; + + private Dictionary serviceClients; + private bool GetContainerExistsMaybe(string containerName) + { + var result = containerExistenceCache.FirstOrDefault(x => x.Item1 == containerName); + if (result == null) + { + return false; + } + return result.Item2; + } + private void SetContainerExists(string containerName, bool exists) + { + for (int i = 0; i < containerExistenceCache.Length; i++) + { + if (containerExistenceCache[i].Item1 == containerName) + { + containerExistenceCache[i] = new Tuple(containerName, exists); + } + } + } + +// https://devblogs.microsoft.com/azure-sdk/best-practices-for-using-azure-sdk-with-asp-net-core/ + + public AzureBlobCache(NamedCacheConfiguration config, Func blobServiceFactory, + ILoggerFactory loggerFactory) + { + this.config = config; + // Map BlobGroupConfigurations dict, replacing those keys with the .Location.BlobClient value + + this.serviceClients = config.BlobGroupConfigurations.Select(p => + new KeyValuePair(p.Key, + p.Value.Location.AzureClient.Resolve(blobServiceFactory))).ToDictionary(x => x.Key, x => x.Value); + + this.defaultBlobServiceClient = blobServiceFactory(null); + this.logger = loggerFactory.CreateLogger("AzureBlobCache"); + + this.containerExistenceCache = config.BlobGroupConfigurations.Values.Select(x => x.Location.ContainerName) + .Distinct().Select(x => new Tuple(x, false)).ToArray(); + + this.InitialCacheCapabilities = new BlobCacheCapabilities + { + CanFetchMetadata = true, + CanFetchData = true, + CanConditionalFetch = false, + CanPut = true, + CanConditionalPut = false, + CanDelete = false, + CanSearchByTag = false, + CanPurgeByTag = false, + CanReceiveEvents = false, + SupportsHealthCheck = false, + SubscribesToRecentRequest = false, + SubscribesToExternalHits = true, + SubscribesToFreshResults = true, + RequiresInlineExecution = false, + FixedSize = false + }; + } + + + + public string UniqueName => config.CacheName; + + internal BlobServiceClient GetClientFor(BlobGroup group) + { + return serviceClients[group]; + } + internal BlobGroupConfiguration GetConfigFor(BlobGroup group) + { + if (config.BlobGroupConfigurations.TryGetValue(group, out var groupConfig)) + { + return groupConfig; + } + throw new Exception($"No configuration for blob group {group} in cache {UniqueName}"); + } + + internal string TransformKey(string key) + { + switch (config.KeyTransform) + { + case KeyTransform.Identity: + return key; + default: + throw new Exception($"Unknown key transform {config.KeyTransform}"); + } + } + + internal string GetKeyFor(BlobGroup group, string key) + { + var groupConfig = GetConfigFor(group); + return groupConfig.Location.BlobPrefix + TransformKey(key); + } + + + + public async Task CachePut(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + if (e.Result == null || e.Result.IsError) return CodeResult.Err(HttpStatus.BadRequest.WithMessage("CachePut cannot be called with an invalid result")); + var group = e.BlobCategory; + var key = e.OriginalRequest.CacheKeyHashString; + var groupConfig = GetConfigFor(group); + var azureKey = GetKeyFor(group, key); + // TODO: validate key eventually + var container = GetClientFor(group).GetBlobContainerClient(groupConfig.Location.ContainerName); + var blob = container.GetBlobClient(azureKey); + if (!GetContainerExistsMaybe(groupConfig.Location.ContainerName) && !groupConfig.CreateContainerIfMissing) + { + try + { + await container.CreateIfNotExistsAsync(); + SetContainerExists(groupConfig.Location.ContainerName, true); + } + catch (Azure.RequestFailedException ex) + { + LogIfSerious(ex, groupConfig.Location.ContainerName, key); + return CodeResult.Err(new HttpStatus(ex.Status).WithAppend(ex.Message)); + } + } + try + { + using var consumable = await e.Result.Unwrap().CreateConsumable(e.BlobFactory, cancellationToken); + using var data = consumable.BorrowStream(DisposalPromise.CallerDisposesBlobOnly); + await blob.UploadAsync(data, cancellationToken); + return CodeResult.Ok(); + } + catch (Azure.RequestFailedException ex) + { + + LogIfSerious(ex, groupConfig.Location.ContainerName, key); + return CodeResult.Err(new HttpStatus(ex.Status).WithAppend(ex.Message)); + } + } + + + + public async Task> CacheFetch(IBlobCacheRequest request, CancellationToken cancellationToken = default) + { + var group = request.BlobCategory; + var key = request.CacheKeyHashString; + var groupConfig = GetConfigFor(group); + var azureKey = GetKeyFor(group, key); + var container = GetClientFor(group).GetBlobContainerClient(groupConfig.Location.ContainerName); + var blob = container.GetBlobClient(azureKey); + var storage = new AzureBlobStorageReference(container.Uri.AbsoluteUri, azureKey); + try + { + var response = await blob.DownloadStreamingAsync(new BlobDownloadOptions(), cancellationToken); + SetContainerExists(groupConfig.Location.ContainerName, true); + return BlobCacheFetchFailure.OkResult(new BlobWrapper(null,AzureBlobHelper.CreateConsumableBlob(storage, response))); + + } + catch (Azure.RequestFailedException ex) + { + //For cache misses, just return a null blob. + if (ex.Status == 404) + { + return BlobCacheFetchFailure.MissResult(this, this); + } + LogIfSerious(ex, groupConfig.Location.ContainerName, key); + return BlobCacheFetchFailure.ErrorResult(new HttpStatus(ex.Status).WithAppend(ex.Message), this, this); + } + } + + + public Task CacheDelete(IBlobStorageReference reference, CancellationToken cancellationToken = default) + { + return Task.FromResult(CodeResult.Err(HttpStatus.NotImplemented)); + + } + internal void LogIfSerious(Azure.RequestFailedException ex, string containerName, string key) + { + // Implement similar logging as in the S3 version, adjusted for Azure exceptions and error codes. + } + + + + public Task>> CacheSearchByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + return Task.FromResult(CodeResult>.Err(HttpStatus.NotImplemented)); + } + + public Task>>> CachePurgeByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + return Task.FromResult(CodeResult>>.Err(HttpStatus.NotImplemented)); + } + + public Task OnCacheEvent(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + return Task.FromResult(CodeResult.Err(HttpStatus.NotImplemented)); + } + + public BlobCacheCapabilities InitialCacheCapabilities { get; } + public ValueTask CacheHealthCheck(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobClientOrName.cs b/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobClientOrName.cs new file mode 100644 index 00000000..f1007435 --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobClientOrName.cs @@ -0,0 +1,51 @@ +using System; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Azure; + +namespace Imageflow.Server.Storage.AzureBlob.Caching +{ + internal struct BlobClientOrName + { + internal readonly BlobServiceClient? Client; + internal readonly string? Name; + + internal BlobClientOrName(BlobServiceClient client) + { + Client = client; + Name = null; + } + internal BlobClientOrName(string name) + { + Client = null; + Name = name; + } + + public bool IsEmpty => Client == null && Name == null; + + public BlobClientOrName Or(string name) + { + if (IsEmpty) return new BlobClientOrName(name); + return this; + } + public BlobClientOrName Or(BlobServiceClient client) + { + if (IsEmpty) return new BlobClientOrName(client); + return this; + } + + public BlobServiceClient? Resolve(IAzureClientFactory clientFactory) + { + if (Client != null) return Client; + if (Name != null) return clientFactory.CreateClient(Name); + return null; + } + + + + public BlobServiceClient Resolve(Func clientFactory) + { + if (Client != null) return Client; + return clientFactory(Name); + } + } +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobGroupConfiguration.cs b/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobGroupConfiguration.cs new file mode 100644 index 00000000..5832fce1 --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobGroupConfiguration.cs @@ -0,0 +1,27 @@ + +// Contains BlobGroupLifecycle and BlobGroupLocation +using System; +namespace Imageflow.Server.Storage.AzureBlob.Caching +{ + internal readonly struct BlobGroupConfiguration + { + internal readonly BlobGroupLocation Location; + internal readonly BlobGroupLifecycle Lifecycle; + + internal readonly bool UpdateLifecycleRules; + internal readonly bool CreateContainerIfMissing; + + internal BlobGroupConfiguration(BlobGroupLocation location, BlobGroupLifecycle lifecycle, bool createBucketIfMissing, bool updateLifecycleRules){ + //location cannot be empty + if (location.ContainerName == null) + { + throw new ArgumentNullException(nameof(location)); + } + Location = location; + Lifecycle = lifecycle; + + this.CreateContainerIfMissing = createBucketIfMissing; + this.UpdateLifecycleRules = updateLifecycleRules; + } + } +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobGroupLifecycle.cs b/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobGroupLifecycle.cs new file mode 100644 index 00000000..b564feea --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobGroupLifecycle.cs @@ -0,0 +1,29 @@ +using System; +namespace Imageflow.Server.Storage.AzureBlob.Caching +{ + + internal readonly struct BlobGroupLifecycle + { + + internal bool? LastAccessTimeTracking { get; } + internal int? DaysAfterLastAccessTimeGreaterThan { get; } + + internal BlobGroupLifecycle(bool? lastAccessTimeTracking, int? daysAfterLastAccessTimeGreaterThan) + { + LastAccessTimeTracking = lastAccessTimeTracking; + DaysAfterLastAccessTimeGreaterThan = daysAfterLastAccessTimeGreaterThan; + if (DaysAfterLastAccessTimeGreaterThan.HasValue && DaysAfterLastAccessTimeGreaterThan.Value < 2) + { + throw new ArgumentException("DaysAfterLastAccessTimeGreaterThan must be greater than 1, if specified"); + } + if (DaysAfterLastAccessTimeGreaterThan.HasValue && lastAccessTimeTracking != true) + { + throw new ArgumentException("DaysAfterLastAccessTimeGreaterThan cannot be specified if LastAccessTimeTracking is not enabled"); + } + } + + internal static BlobGroupLifecycle NonExpiring => new BlobGroupLifecycle(null, null); + internal static BlobGroupLifecycle SlidingExpiry(int? daysUnusedBeforeExpiry) => new BlobGroupLifecycle(daysUnusedBeforeExpiry.HasValue, daysUnusedBeforeExpiry); + } + +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobGroupLocation.cs b/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobGroupLocation.cs new file mode 100644 index 00000000..8cb2921c --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/Caching/BlobGroupLocation.cs @@ -0,0 +1,33 @@ +namespace Imageflow.Server.Storage.AzureBlob.Caching +{ + /// + /// An immutable data structure that holds two strings, Bucket, and BlobPrefix. + /// BlobPrefix is the prefix of the blob key, not including the bucket name. It cannot start with a slash, and must be a valid S3 blob string + /// Bucket must be a valid S3 bucket name. Use BlobStringValidator + /// + internal readonly struct BlobGroupLocation + { + internal readonly string ContainerName; + internal readonly string BlobPrefix; + internal readonly BlobClientOrName AzureClient; + + // TOOD: create from DI using azure name + + internal BlobGroupLocation(string containerName, string blobPrefix, BlobClientOrName client) + { + //TODO + // if (!BlobStringValidator.ValidateBucketName(containerName, out var error)) + // { + // throw new ArgumentException(error, nameof(containerName)); + // } + // if (!BlobStringValidator.ValidateKeyPrefix(blobPrefix, out error)) + // { + // throw new ArgumentException(error, nameof(blobPrefix)); + // } + ContainerName = containerName; + BlobPrefix = blobPrefix; + AzureClient = client; + } + + } +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/Caching/CacheBucketCreation.cs b/src/Imageflow.Server.Storage.AzureBlob/Caching/CacheBucketCreation.cs new file mode 100644 index 00000000..c8726b51 --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/Caching/CacheBucketCreation.cs @@ -0,0 +1,8 @@ +namespace Imageflow.Server.Storage.AzureBlob.Caching +{ + public enum CacheBucketCreation{ + DoNotCreateIfMissing, + CreateIfMissing, + + } +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/Caching/CacheBucketLifecycleRules.cs b/src/Imageflow.Server.Storage.AzureBlob/Caching/CacheBucketLifecycleRules.cs new file mode 100644 index 00000000..1fa4a889 --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/Caching/CacheBucketLifecycleRules.cs @@ -0,0 +1,7 @@ +namespace Imageflow.Server.Storage.AzureBlob.Caching +{ + internal enum CacheBucketLifecycleRules{ + DoNotUpdate, + ConfigureExpiryForCacheFolders + } +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/Caching/NamedCacheConfiguration.cs b/src/Imageflow.Server.Storage.AzureBlob/Caching/NamedCacheConfiguration.cs new file mode 100644 index 00000000..7ebce97b --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/Caching/NamedCacheConfiguration.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using Imazen.Abstractions.BlobCache; + +namespace Imageflow.Server.Storage.AzureBlob.Caching +{ + internal enum KeyTransform{ + Identity + } + public readonly struct NamedCacheConfiguration + { + + internal readonly string CacheName; + + internal readonly KeyTransform KeyTransform; + internal readonly BlobClientOrName? BlobClient; + internal readonly Dictionary BlobGroupConfigurations; + + + /// + /// Creates a new named cache configuration with the specified blob group configurations + /// + /// + /// + /// + internal NamedCacheConfiguration(string cacheName, BlobClientOrName defaultClient, Dictionary blobGroupConfigurations) + { + CacheName = cacheName; + BlobClient = defaultClient; + BlobGroupConfigurations = blobGroupConfigurations; + KeyTransform = KeyTransform.Identity; + } + + + /// + /// Creates a new named cache configuration with the specified sliding expiry days + /// + /// The name of the cache, for use in the rest of the configuration + /// If null, will use the default client. + /// The bucket to use for the cache. Must be in the same region or performance will be terrible. + /// If null, then no expiry will occur + /// If true, missing buckets will be created in the default region for the S3 client + /// If true, lifecycle rules will be synchronized. + /// + internal NamedCacheConfiguration(string cacheName, BlobClientOrName defaultClient, string cacheBucketName, CacheBucketCreation createIfMissing, CacheBucketLifecycleRules updateLifecycleRule, int? slidingExpiryDays) + { + // slidingExpiryDays cannot be less than 3, if specified + if (slidingExpiryDays.HasValue && slidingExpiryDays.Value < 3) + { + throw new ArgumentException("slidingExpiryDays cannot be less than 3, if specified", nameof(slidingExpiryDays)); + } + var blobMetadataExpiry = slidingExpiryDays.HasValue ? slidingExpiryDays.Value * 4 : (int?)null; + var createBucketIfMissing = createIfMissing == CacheBucketCreation.CreateIfMissing; + var updateLifecycleRules = updateLifecycleRule == CacheBucketLifecycleRules.ConfigureExpiryForCacheFolders; + + KeyTransform = KeyTransform.Identity; + CacheName = cacheName; + BlobClient = defaultClient; + BlobGroupConfigurations = new Dictionary + { + { BlobGroup.GeneratedCacheEntry, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/blobs/", defaultClient), BlobGroupLifecycle.SlidingExpiry(slidingExpiryDays), createBucketIfMissing, updateLifecycleRules) }, + { BlobGroup.SourceMetadata, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/source-metadata/", defaultClient), BlobGroupLifecycle.NonExpiring, createBucketIfMissing, updateLifecycleRules) }, + { BlobGroup.CacheEntryMetadata, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/blob-metadata/", defaultClient), BlobGroupLifecycle.SlidingExpiry(blobMetadataExpiry), createBucketIfMissing, updateLifecycleRules) }, + { BlobGroup.Essential, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/essential/", defaultClient), BlobGroupLifecycle.NonExpiring, createBucketIfMissing, updateLifecycleRules) } + }; + } + + /// + /// Creates a new named cache configuration with the specified cache name and bucket name and createIfMissing (no expiry) + /// + /// The name of the cache, for use in the rest of the configuration + /// If null, will use the default client. + /// The bucket to use for the cache. Must be in the same region or performance will be terrible. + /// If true, missing buckets will be created in the default region for the Azure client + internal NamedCacheConfiguration(string cacheName, BlobClientOrName defaultClient, string cacheBucketName, CacheBucketCreation createIfMissing) + : this(cacheName, defaultClient, cacheBucketName, createIfMissing, CacheBucketLifecycleRules.DoNotUpdate, null) + { + } + /// + /// Creates a new named cache configuration with the specified cache name and bucket name and createIfMissing (no expiry) + /// + /// The name of the cache, for use in the rest of the configuration + /// If null, will use the default client. + /// The bucket to use for the cache. Must be in the same region or performance will be terrible. + /// If true, missing buckets will be created in the default region for the Azure client + public NamedCacheConfiguration(string cacheName, string cacheBucketName, string blobClientName, CacheBucketCreation createIfMissing) + : this(cacheName, new BlobClientOrName(blobClientName), cacheBucketName, createIfMissing, CacheBucketLifecycleRules.DoNotUpdate, null) + { + } + + } +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/Global.cs b/src/Imageflow.Server.Storage.AzureBlob/Global.cs new file mode 100644 index 00000000..017a0b94 --- /dev/null +++ b/src/Imageflow.Server.Storage.AzureBlob/Global.cs @@ -0,0 +1,2 @@ +global using IBlobResult = Imazen.Abstractions.Resulting.IDisposableResult; \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.AzureBlob/Imageflow.Server.Storage.AzureBlob.csproj b/src/Imageflow.Server.Storage.AzureBlob/Imageflow.Server.Storage.AzureBlob.csproj index 0be1a3ef..6dfcb3ff 100644 --- a/src/Imageflow.Server.Storage.AzureBlob/Imageflow.Server.Storage.AzureBlob.csproj +++ b/src/Imageflow.Server.Storage.AzureBlob/Imageflow.Server.Storage.AzureBlob.csproj @@ -1,27 +1,24 @@ - netstandard2.0 Imageflow.Server.Storage.AzureBlob - Plugin for fetching source images from Azure Blob Storage. Imageflow.Server plugin for fetching source images from Azure Blob Storage. - true - - + - - runtime; build; native; contentfiles; analyzers; buildtransitive all + + diff --git a/src/Imageflow.Server.Storage.AzureBlob/packages.lock.json b/src/Imageflow.Server.Storage.AzureBlob/packages.lock.json index b035b5e1..6e92ccfa 100644 --- a/src/Imageflow.Server.Storage.AzureBlob/packages.lock.json +++ b/src/Imageflow.Server.Storage.AzureBlob/packages.lock.json @@ -4,11 +4,11 @@ ".NETStandard,Version=v2.0": { "Azure.Storage.Blobs": { "type": "Direct", - "requested": "[12.14.1, )", - "resolved": "12.14.1", - "contentHash": "DvRBWUDMB2LjdRbsBNtz/LiVIYk56hqzSooxx4uq4rCdLj2M+7Vvoa1r+W35Dz6ZXL6p+SNcgEae3oZ+CkPfow==", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", "dependencies": { - "Azure.Storage.Common": "12.13.0", + "Azure.Storage.Common": "12.18.1", "System.Text.Json": "4.7.2" } }, @@ -18,14 +18,29 @@ "resolved": "3.3.4", "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" }, + "Microsoft.Extensions.Azure": { + "type": "Direct", + "requested": "[1.7.2, )", + "resolved": "1.7.2", + "contentHash": "EJJQKICExhTERoqY/m/A2dkfit7TqOJt2Mnhreg06D2BxioGnoOYV72s5a9NfIN8K4fABl04wRkdJ4+jgSPheQ==", + "dependencies": { + "Azure.Core": "1.37.0", + "Azure.Identity": "1.10.4", + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, "NETStandard.Library": { @@ -39,11 +54,11 @@ }, "Azure.Core": { "type": "Transitive", - "resolved": "1.25.0", - "contentHash": "X8Dd4sAggS84KScWIjEbFAdt2U1KDolQopTPoHVubG2y3CM54f9l6asVrP5Uy384NWXjsspPYaJgz5xHc+KvTA==", + "resolved": "1.37.0", + "contentHash": "ZSWV/ftBM/c/+Eo2hlzeEWntjqNG+8TWXX/DAKjBSWIf7nEvFILQoJL7JZx5HjypDvdNUMj5J2ji8ZpFFSghSg==", "dependencies": { "Microsoft.Bcl.AsyncInterfaces": "1.1.1", - "System.Diagnostics.DiagnosticSource": "4.6.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", "System.Memory.Data": "1.0.2", "System.Numerics.Vectors": "4.5.0", "System.Text.Encodings.Web": "4.7.2", @@ -51,27 +66,49 @@ "System.Threading.Tasks.Extensions": "4.5.4" } }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.10.4", + "contentHash": "hSvisZy9sld0Gik1X94od3+rRXCx+AKgi+iLH6fFdlnRZRePn7RtrqUGSsORiH2h8H2sc4NLTrnuUte1WL+QuQ==", + "dependencies": { + "Azure.Core": "1.36.0", + "Microsoft.Identity.Client": "4.56.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.56.0", + "System.Memory": "4.5.4", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, "Azure.Storage.Common": { "type": "Transitive", - "resolved": "12.13.0", - "contentHash": "jDv8xJWeZY2Er9zA6QO25BiGolxg87rItt9CwAp7L/V9EPJeaz8oJydaNL9Wj0+3ncceoMgdiyEv66OF8YUwWQ==", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", "dependencies": { - "Azure.Core": "1.25.0", + "Azure.Core": "1.36.0", "System.IO.Hashing": "6.0.0" } }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", "dependencies": { "System.Threading.Tasks.Extensions": "4.5.4" } }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -81,6 +118,14 @@ "Microsoft.Extensions.Primitives": "2.2.0" } }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" + } + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", "resolved": "2.2.0", @@ -110,6 +155,15 @@ "resolved": "2.2.0", "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "VOM1pPMi9+7/4Vc9aPLU8btHOBQy1+AvpqxLxFI2OVtqGv+1klPaV59g9R6aSt2U7ijfB3TjvAO4Tc/cn9/hxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Primitives": "2.1.0" + } + }, "Microsoft.Extensions.Primitives": { "type": "Transitive", "resolved": "2.2.0", @@ -119,6 +173,29 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "rr4zbidvHy9r4NvOAs5hdd964Ao2A0pAeFBJKR95u1CJAVzbd1p6tPTXUZ+5ld0cfThiVSGvz6UHwY6JjraTpA==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.22.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "H12YAzEGK55vZ+QpxUzozhW8ZZtgPDuWvgA0JbdIR9UhMUplj29JhIgE2imuH8W2Nw9D8JKygR1uxRFtpSNcrg==", + "dependencies": { + "Microsoft.Identity.Client": "4.56.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.22.0", + "contentHash": "iI+9V+2ciCrbheeLjpmjcqCnhy+r6yCoEcid3nkoFWerHgjVuT6CPM4HODUTtUPe1uwks4wcnAujJ8u+IKogHQ==" + }, "Microsoft.NETCore.Platforms": { "type": "Transitive", "resolved": "1.1.0", @@ -126,8 +203,8 @@ }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, "System.Buffers": { "type": "Transitive", @@ -136,10 +213,22 @@ }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "4.6.0", - "contentHash": "mbBgoR0rRfl2uimsZ2avZY8g7Xnh1Mza0rJZLPcxqiMWlkGukjmRkuMJ/er+AhQuiRIh80CR/Hpeztr80seV5g==", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", "dependencies": { - "System.Memory": "4.5.3" + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" } }, "System.IO.Hashing": { @@ -153,8 +242,8 @@ }, "System.Memory": { "type": "Transitive", - "resolved": "4.5.4", - "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", "dependencies": { "System.Buffers": "4.5.1", "System.Numerics.Vectors": "4.4.0", @@ -177,16 +266,38 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.7.1", - "contentHash": "zOHkQmzPCn5zm/BH+cxC1XbUS3P4Yoi3xzW7eRgVpDR2tPGSzyMZ17Ig1iRkfJuY0nhxkQQde8pgePNiA7z7TQ==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==", + "dependencies": { + "System.Memory": "4.5.3" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "iTUgB/WtrZ1sWZs84F2hwyQhiRH6QNjQv2DkwrH+WP6RoFga2Q1m3f9/Q7FG8cck8AdHitQkmkXSY8qylcDmuA==", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", "dependencies": { "System.Buffers": "4.5.1", - "System.Memory": "4.5.4" + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, "System.Text.Json": { @@ -211,9 +322,609 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + } + }, + "net6.0": { + "Azure.Storage.Blobs": { + "type": "Direct", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Extensions.Azure": { + "type": "Direct", + "requested": "[1.7.2, )", + "resolved": "1.7.2", + "contentHash": "EJJQKICExhTERoqY/m/A2dkfit7TqOJt2Mnhreg06D2BxioGnoOYV72s5a9NfIN8K4fABl04wRkdJ4+jgSPheQ==", + "dependencies": { + "Azure.Core": "1.37.0", + "Azure.Identity": "1.10.4", + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.37.0", + "contentHash": "ZSWV/ftBM/c/+Eo2hlzeEWntjqNG+8TWXX/DAKjBSWIf7nEvFILQoJL7JZx5HjypDvdNUMj5J2ji8ZpFFSghSg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Memory.Data": "1.0.2", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.10.4", + "contentHash": "hSvisZy9sld0Gik1X94od3+rRXCx+AKgi+iLH6fFdlnRZRePn7RtrqUGSsORiH2h8H2sc4NLTrnuUte1WL+QuQ==", + "dependencies": { + "Azure.Core": "1.36.0", + "Microsoft.Identity.Client": "4.56.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.56.0", + "System.Memory": "4.5.4", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "VOM1pPMi9+7/4Vc9aPLU8btHOBQy1+AvpqxLxFI2OVtqGv+1klPaV59g9R6aSt2U7ijfB3TjvAO4Tc/cn9/hxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Primitives": "2.1.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "rr4zbidvHy9r4NvOAs5hdd964Ao2A0pAeFBJKR95u1CJAVzbd1p6tPTXUZ+5ld0cfThiVSGvz6UHwY6JjraTpA==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.22.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "H12YAzEGK55vZ+QpxUzozhW8ZZtgPDuWvgA0JbdIR9UhMUplj29JhIgE2imuH8W2Nw9D8JKygR1uxRFtpSNcrg==", + "dependencies": { + "Microsoft.Identity.Client": "4.56.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.22.0", + "contentHash": "iI+9V+2ciCrbheeLjpmjcqCnhy+r6yCoEcid3nkoFWerHgjVuT6CPM4HODUTtUPe1uwks4wcnAujJ8u+IKogHQ==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "JGkzeqgBsiZwKJZ1IxPNsDFZDhUvuEdX8L8BDC8N3KOj+6zMcNU28CNN59TpZE/VJYy9cP+5M+sbxtWJx3/xtw==", + "dependencies": { + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.6.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + } + }, + "net8.0": { + "Azure.Storage.Blobs": { + "type": "Direct", + "requested": "[12.19.1, )", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", + "dependencies": { + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Extensions.Azure": { + "type": "Direct", + "requested": "[1.7.2, )", + "resolved": "1.7.2", + "contentHash": "EJJQKICExhTERoqY/m/A2dkfit7TqOJt2Mnhreg06D2BxioGnoOYV72s5a9NfIN8K4fABl04wRkdJ4+jgSPheQ==", + "dependencies": { + "Azure.Core": "1.37.0", + "Azure.Identity": "1.10.4", + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.37.0", + "contentHash": "ZSWV/ftBM/c/+Eo2hlzeEWntjqNG+8TWXX/DAKjBSWIf7nEvFILQoJL7JZx5HjypDvdNUMj5J2ji8ZpFFSghSg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Memory.Data": "1.0.2", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.10.4", + "contentHash": "hSvisZy9sld0Gik1X94od3+rRXCx+AKgi+iLH6fFdlnRZRePn7RtrqUGSsORiH2h8H2sc4NLTrnuUte1WL+QuQ==", + "dependencies": { + "Azure.Core": "1.36.0", + "Microsoft.Identity.Client": "4.56.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.56.0", + "System.Memory": "4.5.4", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Azure.Storage.Common": { + "type": "Transitive", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", + "dependencies": { + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "VOM1pPMi9+7/4Vc9aPLU8btHOBQy1+AvpqxLxFI2OVtqGv+1klPaV59g9R6aSt2U7ijfB3TjvAO4Tc/cn9/hxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Primitives": "2.1.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "rr4zbidvHy9r4NvOAs5hdd964Ao2A0pAeFBJKR95u1CJAVzbd1p6tPTXUZ+5ld0cfThiVSGvz6UHwY6JjraTpA==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.22.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "H12YAzEGK55vZ+QpxUzozhW8ZZtgPDuWvgA0JbdIR9UhMUplj29JhIgE2imuH8W2Nw9D8JKygR1uxRFtpSNcrg==", + "dependencies": { + "Microsoft.Identity.Client": "4.56.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.22.0", + "contentHash": "iI+9V+2ciCrbheeLjpmjcqCnhy+r6yCoEcid3nkoFWerHgjVuT6CPM4HODUTtUPe1uwks4wcnAujJ8u+IKogHQ==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.FileSystem.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "JGkzeqgBsiZwKJZ1IxPNsDFZDhUvuEdX8L8BDC8N3KOj+6zMcNU28CNN59TpZE/VJYy9cP+5M+sbxtWJx3/xtw==", + "dependencies": { + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.6.0" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==" + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "4.7.2", + "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } } diff --git a/src/Imageflow.Server.Storage.RemoteReader/Imageflow.Server.Storage.RemoteReader.csproj b/src/Imageflow.Server.Storage.RemoteReader/Imageflow.Server.Storage.RemoteReader.csproj index 14dac475..557f20b7 100644 --- a/src/Imageflow.Server.Storage.RemoteReader/Imageflow.Server.Storage.RemoteReader.csproj +++ b/src/Imageflow.Server.Storage.RemoteReader/Imageflow.Server.Storage.RemoteReader.csproj @@ -1,12 +1,10 @@ - netstandard2.0 Imageflow.Server.Storage.RemoteReader - Plugin for fetching source images from remote URLs. Imageflow.Server plugin for fetching source images from remote URLs. - true - + diff --git a/src/Imageflow.Server.Storage.RemoteReader/RemoteReaderService.cs b/src/Imageflow.Server.Storage.RemoteReader/RemoteReaderService.cs index f62d7f83..b0673ab1 100644 --- a/src/Imageflow.Server.Storage.RemoteReader/RemoteReaderService.cs +++ b/src/Imageflow.Server.Storage.RemoteReader/RemoteReaderService.cs @@ -55,7 +55,7 @@ public async Task Fetch(string virtualPath) var urlBase64 = remote[0]; var hmac = remote[1]; - + var sig = Signatures.SignString(urlBase64, options.SigningKey, 8); if (hmac != sig) { @@ -78,36 +78,34 @@ public async Task Fetch(string virtualPath) } var httpClientName = httpClientSelector(uri); - using (var http = httpFactory.CreateClient(httpClientName)) + // Per the docs, we do not need to dispose HttpClient instances. HttpFactories track backing resources and handle + // everything. https://source.dot.net/#Microsoft.Extensions.Http/IHttpClientFactory.cs,4f4eda17fc4cd91b + var client = httpFactory.CreateClient(httpClientName); + try { + var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); - - try - { - var resp = await http.GetAsync(url); - - if (!resp.IsSuccessStatusCode) - { - logger?.LogWarning( - "RemoteReader blob {VirtualPath} not found. The remote {Url} responded with status: {StatusCode}", - virtualPath, url, resp.StatusCode); - throw new BlobMissingException( - $"RemoteReader blob \"{virtualPath}\" not found. The remote \"{url}\" responded with status: {resp.StatusCode}"); - } - - return new RemoteReaderBlob(resp); - } - catch (BlobMissingException) - { - throw; - } - catch (Exception ex) + if (!resp.IsSuccessStatusCode) { - logger?.LogWarning(ex, "RemoteReader blob error retrieving {Url} for {VirtualPath}", url, - virtualPath); + logger?.LogWarning( + "RemoteReader blob {VirtualPath} not found. The remote {Url} responded with status: {StatusCode}", + virtualPath, url, resp.StatusCode); throw new BlobMissingException( - $"RemoteReader blob error retrieving \"{url}\" for \"{virtualPath}\".", ex); + $"RemoteReader blob \"{virtualPath}\" not found. The remote \"{url}\" responded with status: {resp.StatusCode}"); } + + return new RemoteReaderBlob(resp); + } + catch (BlobMissingException) + { + throw; + } + catch (Exception ex) + { + logger?.LogWarning(ex, "RemoteReader blob error retrieving {Url} for {VirtualPath}", url, + virtualPath); + throw new BlobMissingException( + $"RemoteReader blob error retrieving \"{url}\" for \"{virtualPath}\".", ex); } } diff --git a/src/Imageflow.Server.Storage.RemoteReader/RemoteReaderServiceExtensions.cs b/src/Imageflow.Server.Storage.RemoteReader/RemoteReaderServiceExtensions.cs index 49c34193..1dda3369 100644 --- a/src/Imageflow.Server.Storage.RemoteReader/RemoteReaderServiceExtensions.cs +++ b/src/Imageflow.Server.Storage.RemoteReader/RemoteReaderServiceExtensions.cs @@ -1,7 +1,6 @@ using Imazen.Common.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using System; using System.Net.Http; namespace Imageflow.Server.Storage.RemoteReader diff --git a/src/Imageflow.Server.Storage.RemoteReader/packages.lock.json b/src/Imageflow.Server.Storage.RemoteReader/packages.lock.json index 6a1e8c54..2120f2b8 100644 --- a/src/Imageflow.Server.Storage.RemoteReader/packages.lock.json +++ b/src/Imageflow.Server.Storage.RemoteReader/packages.lock.json @@ -22,12 +22,12 @@ }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, "NETStandard.Library": { @@ -41,16 +41,16 @@ }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", "dependencies": { "System.Threading.Tasks.Extensions": "4.5.4" } }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -137,8 +137,8 @@ }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, "System.Buffers": { "type": "Transitive", @@ -156,8 +156,8 @@ }, "System.Memory": { "type": "Transitive", - "resolved": "4.5.4", - "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", "dependencies": { "System.Buffers": "4.5.1", "System.Numerics.Vectors": "4.4.0", @@ -171,8 +171,18 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "ZD9TMpsmYJLrxbbmdvhwt9YEgG5WntEnZ/d1eH8JBX9LBp+Ju8BSBhUGbZMNVHHomWo2KVImJhTDl2hIgw/6MA==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } }, "System.Threading.Tasks.Extensions": { "type": "Transitive", @@ -182,9 +192,301 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + } + }, + "net6.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "kT1ijDKZuSUhBtYoC1sXrmVKP7mA08h9Xrsr4VrS/QOtiKCEtUTTd7dd3XI9dwAb46tZSak13q/zdIcr4jqbyg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "5.0.0", + "Microsoft.Extensions.Logging": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "5.0.0", + "Microsoft.Extensions.Options": "5.0.0" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Rc2kb/p3Ze6cP6rhFC3PJRdWGbLvSHZc0ev7YlyeU6FmHciDMLrhoVoTUEzKPhN5ZjFgKF1Cf5fOz8mCMIkvpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "5.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ORj7Zh81gC69TyvmcUm9tSzytcy8AVousi+IVRAI8nLieQjOFryRusSFh7+aLk16FN9pQNqJAiMd7BTKINK0kA==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "MgOwK6tPzB6YNH21wssJcw/2MKwee8b2gI7SllYfn6rvTpIrVvVS5HAjSU2vqSku1fwqRvWP0MdIi14qjd93Aw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "5.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "5.0.0", + "Microsoft.Extensions.Options": "5.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "NxP6ahFcBnnSfwNBi2KH2Oz8Xl5Sm2krjId/jRR3I7teFphwiUoUeZPwTNA21EX+5PtjqmyAvKaOeBXcJjcH/w==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "CBvR92TCJ5uBIdd9/HzDSrxYak+0W/3+yxrNg8Qm6Bmrkh5L+nu6m3WeazQehcZ5q1/6dDA7J5YdQjim0165zg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "5.0.0", + "Microsoft.Extensions.Primitives": "5.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[5.0.0, )", + "resolved": "5.0.0", + "contentHash": "kT1ijDKZuSUhBtYoC1sXrmVKP7mA08h9Xrsr4VrS/QOtiKCEtUTTd7dd3XI9dwAb46tZSak13q/zdIcr4jqbyg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "5.0.0", + "Microsoft.Extensions.Logging": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "5.0.0", + "Microsoft.Extensions.Options": "5.0.0" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Rc2kb/p3Ze6cP6rhFC3PJRdWGbLvSHZc0ev7YlyeU6FmHciDMLrhoVoTUEzKPhN5ZjFgKF1Cf5fOz8mCMIkvpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "5.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ORj7Zh81gC69TyvmcUm9tSzytcy8AVousi+IVRAI8nLieQjOFryRusSFh7+aLk16FN9pQNqJAiMd7BTKINK0kA==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "MgOwK6tPzB6YNH21wssJcw/2MKwee8b2gI7SllYfn6rvTpIrVvVS5HAjSU2vqSku1fwqRvWP0MdIi14qjd93Aw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "5.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "5.0.0", + "Microsoft.Extensions.Logging.Abstractions": "5.0.0", + "Microsoft.Extensions.Options": "5.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "NxP6ahFcBnnSfwNBi2KH2Oz8Xl5Sm2krjId/jRR3I7teFphwiUoUeZPwTNA21EX+5PtjqmyAvKaOeBXcJjcH/w==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "CBvR92TCJ5uBIdd9/HzDSrxYak+0W/3+yxrNg8Qm6Bmrkh5L+nu6m3WeazQehcZ5q1/6dDA7J5YdQjim0165zg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "5.0.0", + "Microsoft.Extensions.Primitives": "5.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } } diff --git a/src/Imageflow.Server.Storage.S3/Caching/BlobGroupConfiguration.cs b/src/Imageflow.Server.Storage.S3/Caching/BlobGroupConfiguration.cs index c6836b84..73b3906f 100644 --- a/src/Imageflow.Server.Storage.S3/Caching/BlobGroupConfiguration.cs +++ b/src/Imageflow.Server.Storage.S3/Caching/BlobGroupConfiguration.cs @@ -1,18 +1,18 @@ // Contains BlobGroupLifecycle and BlobGroupLocation using System; -using Amazon; namespace Imageflow.Server.Storage.S3.Caching { - internal readonly struct BlobGroupConfiguration + internal readonly record struct BlobGroupConfiguration { internal readonly BlobGroupLocation Location; internal readonly BlobGroupLifecycle Lifecycle; internal readonly bool UpdateLifecycleRules; internal readonly bool CreateBucketIfMissing; + internal readonly bool TestBucketCapabilities; - internal BlobGroupConfiguration(BlobGroupLocation location, BlobGroupLifecycle lifecycle, bool createBucketIfMissing, bool updateLifecycleRules){ + internal BlobGroupConfiguration(BlobGroupLocation location, BlobGroupLifecycle lifecycle, bool createBucketIfMissing, bool updateLifecycleRules, bool testBucketCapabilities){ //location cannot be empty if (location.BucketName == null) { @@ -23,6 +23,7 @@ internal BlobGroupConfiguration(BlobGroupLocation location, BlobGroupLifecycle l this.CreateBucketIfMissing = createBucketIfMissing; this.UpdateLifecycleRules = updateLifecycleRules; + this.TestBucketCapabilities = testBucketCapabilities; } } } \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.S3/Caching/BlobGroupLocation.cs b/src/Imageflow.Server.Storage.S3/Caching/BlobGroupLocation.cs index 075e6b84..12c53978 100644 --- a/src/Imageflow.Server.Storage.S3/Caching/BlobGroupLocation.cs +++ b/src/Imageflow.Server.Storage.S3/Caching/BlobGroupLocation.cs @@ -7,24 +7,26 @@ namespace Imageflow.Server.Storage.S3.Caching /// /// An immutable data structure that holds two strings, Bucket, and BlobPrefix. /// BlobPrefix is the prefix of the blob key, not including the bucket name. It cannot start with a slash, and must be a valid S3 blob string - /// Bucket must be a valid S3 bucket name. Use StringValidator + /// Bucket must be a valid S3 bucket name. Use BlobStringValidator /// - internal readonly struct BlobGroupLocation: IDisposable + internal record BlobGroupLocation: IDisposable { internal readonly string BucketName; internal readonly string BlobPrefix; - internal readonly IAmazonS3 S3Client; + internal readonly string BlobTagsPrefix; + internal readonly IAmazonS3? S3Client; - internal BlobGroupLocation(string bucketName, string blobPrefix, IAmazonS3 s3Client) + internal BlobGroupLocation(string bucketName, string blobPrefix, string blobTagsPrefix, IAmazonS3? s3Client) { - if (!StringValidator.ValidateBucketName(bucketName, out var error)) - { - throw new ArgumentException(error, nameof(bucketName)); - } - if (!StringValidator.ValidateKeyPrefix(blobPrefix, out error)) - { - throw new ArgumentException(error, nameof(blobPrefix)); - } + // if (!Imageflow.Server.Caching.BlobStringValidator.ValidateBucketName(bucketName, out var error)) + // { + // throw new ArgumentException(error, nameof(bucketName)); + // } + // if (!BlobStringValidator.ValidateKeyPrefix(blobPrefix, out error)) + // { + // throw new ArgumentException(error, nameof(blobPrefix)); + // } + BlobTagsPrefix = blobTagsPrefix; BucketName = bucketName; BlobPrefix = blobPrefix; S3Client = s3Client; @@ -32,7 +34,7 @@ internal BlobGroupLocation(string bucketName, string blobPrefix, IAmazonS3 s3Cli public void Dispose() { - S3Client.Dispose(); + S3Client?.Dispose(); } } } \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.S3/Caching/NamedCacheConfiguration.cs b/src/Imageflow.Server.Storage.S3/Caching/NamedCacheConfiguration.cs index 99528e6b..d6600295 100644 --- a/src/Imageflow.Server.Storage.S3/Caching/NamedCacheConfiguration.cs +++ b/src/Imageflow.Server.Storage.S3/Caching/NamedCacheConfiguration.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using Amazon.S3; -using Imazen.Common.Storage.Caching; +using Imazen.Abstractions.BlobCache; namespace Imageflow.Server.Storage.S3.Caching { @@ -36,7 +36,7 @@ internal NamedCacheConfiguration(string cacheName, IAmazonS3 defaultS3Client, Di /// /// Creates a new named cache configuration with the specified sliding expiry days /// - /// The name of the cache, for use in the rest of the configuration + /// The unique name of the cache, for use in the rest of the configuration /// If null, will use the default client. /// The bucket to use for the cache. Must be in the same region or performance will be terrible. /// If null, then no expiry will occur @@ -59,10 +59,10 @@ public NamedCacheConfiguration(string cacheName, IAmazonS3 defaultS3Client, stri DefaultS3Client = defaultS3Client; BlobGroupConfigurations = new Dictionary { - { BlobGroup.CacheEntry, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/blobs/", defaultS3Client), new BlobGroupLifecycle(slidingExpiryDays, slidingExpiryDays), createBucketIfMissing, updateLifecycleRules) }, - { BlobGroup.SourceMetadata, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/source-metadata/", defaultS3Client), new BlobGroupLifecycle(null, null), createBucketIfMissing, updateLifecycleRules) }, - { BlobGroup.CacheMetadata, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/blob-metadata/", defaultS3Client), new BlobGroupLifecycle(blobMetadataExpiry, blobMetadataExpiry), createBucketIfMissing, updateLifecycleRules) }, - { BlobGroup.Essential, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/essential/", defaultS3Client), new BlobGroupLifecycle(null, null), createBucketIfMissing, updateLifecycleRules) } + { BlobGroup.GeneratedCacheEntry, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/blobs/files/", "imageflow-cache/blobs/by-tags/",defaultS3Client), new BlobGroupLifecycle(slidingExpiryDays, slidingExpiryDays), createBucketIfMissing, updateLifecycleRules, false) }, + { BlobGroup.SourceMetadata, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/source-metadata/files/", "imageflow-cache/source-metadata/by-tags/", defaultS3Client), new BlobGroupLifecycle(null, null), createBucketIfMissing, updateLifecycleRules, false ) }, + { BlobGroup.CacheEntryMetadata, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/blob-metadata/files/", "imageflow-cache/blob-metadata/by-tags/",defaultS3Client), new BlobGroupLifecycle(blobMetadataExpiry, blobMetadataExpiry), createBucketIfMissing, updateLifecycleRules, false) }, + { BlobGroup.Essential, new BlobGroupConfiguration(new BlobGroupLocation(cacheBucketName, "imageflow-cache/essential/files/", "imageflow-cache/essential/by-tags/",defaultS3Client), new BlobGroupLifecycle(null, null), createBucketIfMissing, updateLifecycleRules, false) } }; } diff --git a/src/Imageflow.Server.Storage.S3/Caching/S3BlobCache.cs b/src/Imageflow.Server.Storage.S3/Caching/S3BlobCache.cs index 8fd56a85..aff70cfd 100644 --- a/src/Imageflow.Server.Storage.S3/Caching/S3BlobCache.cs +++ b/src/Imageflow.Server.Storage.S3/Caching/S3BlobCache.cs @@ -1,126 +1,345 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Amazon.S3; -using Imageflow.Server.Storage.S3.Caching; -using Imazen.Common.Storage; -using Imazen.Common.Storage.Caching; +using Amazon.S3.Model; +using Imazen.Abstractions.BlobCache; using Microsoft.Extensions.Logging; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using BlobFetchResult = Imazen.Abstractions.Resulting.IResult; -namespace Imageflow.Server.Storage.S3.Caching{ +namespace Imageflow.Server.Storage.S3.Caching +{ - internal class S3BlobCache :IBlobCache + internal class S3BlobCache : IBlobCache { - private NamedCacheConfiguration m; - private IAmazonS3 s3client; - private ILogger logger; + private readonly NamedCacheConfiguration config; + private readonly IAmazonS3 s3Client; + private readonly IReLoggerFactory loggerFactory; - public S3BlobCache(NamedCacheConfiguration m, IAmazonS3 s3client, ILogger logger) + private readonly S3LifecycleUpdater lifecycleUpdater; + + public S3BlobCache(NamedCacheConfiguration config, IAmazonS3 s3Client, IReLoggerFactory loggerFactory) { - this.m = m; - this.s3client = s3client; - this.logger = logger; + this.config = config; + this.s3Client = s3Client; + this.loggerFactory = loggerFactory; + lifecycleUpdater = new S3LifecycleUpdater(config, s3Client, loggerFactory.CreateReLogger($"S3BlobCache('{config.CacheName}')")); + InitialCacheCapabilities = new BlobCacheCapabilities + { + CanFetchMetadata = true, + CanFetchData = true, + CanConditionalFetch = false, + CanPut = true, + CanConditionalPut = false, + CanDelete = true, + CanSearchByTag = true, + CanPurgeByTag = true, + CanReceiveEvents = true, + SupportsHealthCheck = true, + SubscribesToRecentRequest = false, + SubscribesToExternalHits = false, + FixedSize = false, + SubscribesToFreshResults = true, + RequiresInlineExecution = false + }; } - public string Name => m.CacheName; - internal BlobGroupConfiguration GetConfigFor(BlobGroup group) + public string UniqueName => config.CacheName; + + private BlobGroupConfiguration GetConfigFor(BlobGroup group) { - if (m.BlobGroupConfigurations.TryGetValue(group, out var groupConfig)) + if (config.BlobGroupConfigurations.TryGetValue(group, out var groupConfig)) { return groupConfig; } - throw new Exception($"No configuration for blob group {group} in cache {Name}"); + throw new Exception($"No configuration for blob group {group} in cache {UniqueName}"); } - internal string TransformKey(string key) + private string TransformKey(string key) { - switch (m.KeyTransform) + switch (config.KeyTransform) { case KeyTransform.Identity: return key; default: - throw new Exception($"Unknown key transform {m.KeyTransform}"); + throw new Exception($"Unknown key transform {config.KeyTransform}"); } } - internal string GetKeyFor(BlobGroup group, string key) + private string GetKeyFor(IBlobCacheRequest request) { - var groupConfig = GetConfigFor(group); - return groupConfig.Location.BlobPrefix + TransformKey(key); // We don't add an extension because we can't verify the first few bytes. + var groupConfig = GetConfigFor(request.BlobCategory); + return groupConfig.Location.BlobPrefix + TransformKey(request.CacheKeyHashString); // We don't add an extension because we can't verify the first few bytes. } - - /// - /// This method may or may not have worth. If the index engine is external, it is more likely to know. - /// - /// - /// - /// - /// - public Task MayExist(BlobGroup group, string key, CancellationToken cancellationToken = default) + internal S3BlobStorageReference GetReferenceFor(IBlobCacheRequest request) { - return Task.FromResult(true); + var groupConfig = GetConfigFor(request.BlobCategory); + return new S3BlobStorageReference(groupConfig.Location.BucketName, GetKeyFor(request)); } - - public async Task Put(BlobGroup group, string key, IBlobData data, ICacheBlobPutOptions options, CancellationToken cancellationToken = default) + + public async Task CachePut(ICacheEventDetails e, CancellationToken cancellationToken = default) { - var groupConfig = GetConfigFor(group); - var s3key = GetKeyFor(group, key); + if (e.Result == null) throw new ArgumentNullException(nameof(e), "CachePut requires a non-null eventDetails.Result"); + // Create a consumable copy (we assume all puts require usability) + using var input = await e.Result.Unwrap().CreateConsumable(e.BlobFactory, cancellationToken); + // First make sure everything is in order, bucket exists, lifecycle is set, etc. + await lifecycleUpdater.UpdateIfIncompleteAsync(); + + var groupConfig = GetConfigFor(e.OriginalRequest.BlobCategory); + var s3Key = GetKeyFor(e.OriginalRequest); + // TODO: validate keys var bucket = groupConfig.Location.BucketName; - var client = groupConfig.Location.S3Client ?? s3client; - var req = new Amazon.S3.Model.PutObjectRequest() { BucketName = bucket, Key = s3key, InputStream = data.OpenRead(), ContentType = "application/octet-stream" }; + var client = groupConfig.Location.S3Client ?? s3Client; +#if NETSTANDARD2_1_OR_GREATER + await using var stream = input.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob); +#else + using var stream = input.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob); +#endif + // create put requests for each tag as well, /tagindex/tagkey/tagvalue/mainkey try{ - var response = await client.PutObjectAsync(req); - return new CacheBlobPutResult((int)response.HttpStatusCode, null, true); + if (input.Attributes.StorageTags != null) + { + // This makes it possible to use prefix queries to find all blobs with a given tag + var tagTasks = input.Attributes.StorageTags + .Select(tag => $"{groupConfig.Location.BlobTagsPrefix}/{tag.Key}/{tag.Value}/{s3Key}") + .Select(tagKey => new PutObjectRequest() + { + BucketName = bucket, Key = tagKey, InputStream = stream, ContentType = input.Attributes.ContentType, + }) + .Select(req => client.PutObjectAsync(req, cancellationToken)) + .ToList(); + var tagPutResults = await Task.WhenAll(tagTasks); + if (tagPutResults.Any(r => r.HttpStatusCode != HttpStatusCode.OK)) + { + return CodeResult.ErrFrom(tagPutResults.First(r => r.HttpStatusCode != HttpStatusCode.OK).HttpStatusCode, + $"S3 (tag simulation book-keeping) PutObject {bucket}/{s3Key}"); + } + } + var objReq = new PutObjectRequest() + { + BucketName = bucket, Key = s3Key, + InputStream = stream, ContentType = input.Attributes.ContentType, + }; + var response = await client.PutObjectAsync(objReq, cancellationToken); + return response.HttpStatusCode != HttpStatusCode.OK ? + CodeResult.ErrFrom(response.HttpStatusCode, $"S3 PutObject {bucket}/{s3Key}") : CodeResult.Ok(response.HttpStatusCode); } catch (AmazonS3Exception se) { - LogIfSerious(se, bucket, key); - if ("NoSuchBucket".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) - return new CacheBlobPutResult((int)se.StatusCode, se.Message, false); - if (se.StatusCode == System.Net.HttpStatusCode.Forbidden || "AccessDenied".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) - return new CacheBlobPutResult((int)se.StatusCode, se.Message, false); + var converted = ConvertIfRoutine(se, bucket, s3Key, "PutObject"); + LogIfSerious(se, bucket, s3Key); + if (converted != null) return CodeResult.Err(converted.Value); throw; } } + + internal HttpStatus? ConvertIfRoutine(AmazonS3Exception se, string bucket, string key, string action) + { + var normalizedCode = (HttpStatus)se.StatusCode; + if ("NoSuchBucket".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase) || + "NoSuchKey".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) + { + normalizedCode = HttpStatus.NotFound; + } + if ("AccessDenied".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) + { + normalizedCode = HttpStatus.Forbidden; + } + if (normalizedCode == HttpStatus.Forbidden || normalizedCode == HttpStatus.NotFound) + { + return normalizedCode.WithAddFrom($"S3 {action} {bucket}/{key} error code {se.ErrorCode} exception {se.Message}"); + } + return null; + } - public async Task TryFetchBlob(BlobGroup group, string key, CancellationToken cancellationToken = default) + public async Task CacheFetch(IBlobCacheRequest request, CancellationToken cancellationToken = default) { - var groupConfig = GetConfigFor(group); - var s3key = GetKeyFor(group, key); + + var groupConfig = GetConfigFor(request.BlobCategory); + var s3Key = GetKeyFor(request); var bucket = groupConfig.Location.BucketName; - var client = groupConfig.Location.S3Client ?? s3client; - var req = new Amazon.S3.Model.GetObjectRequest() { BucketName = bucket, Key = s3key }; + var client = groupConfig.Location.S3Client ?? s3Client; + var req = new GetObjectRequest() + { + BucketName = bucket, Key = s3Key, + EtagToMatch = request.Conditions?.IfMatch, + EtagToNotMatch = request.Conditions?.IfNoneMatch?.FirstOrDefault(), + + }; try{ - var response = await client.GetObjectAsync(req); - // We need to determine if the response successfully fetched the blob or not. - bool success = response.HttpStatusCode == System.Net.HttpStatusCode.OK; - - return new CacheBlobFetchResult(new S3Blob(response),response.HttpStatusCode == System.Net.HttpStatusCode.OK, (int)response.HttpStatusCode, null); + var result = await client.GetObjectAsync(req, cancellationToken); + if (result.HttpStatusCode == HttpStatusCode.OK) + { + var latencyZone = new LatencyTrackingZone($"s3::bucket/{bucket}", 100); + return BlobCacheFetchFailure.OkResult( + new BlobWrapper(latencyZone,S3BlobHelpers.CreateS3Blob(result))); + } + + // 404/403 are cache misses and return these + return BlobCacheFetchFailure.ErrorResult( + ((HttpStatus)result.HttpStatusCode).WithAddFrom($"S3 (cache fetch) GetObject {bucket}/{s3Key}")); + + + } catch (AmazonS3Exception se) { + LogIfSerious(se, bucket, s3Key); + var converted = ConvertIfRoutine(se, bucket, s3Key, "GetObject"); + if (converted != null) return BlobCacheFetchFailure.ErrorResult(converted.Value); + throw; + } + } + + public async Task CacheDelete(IBlobStorageReference reference, CancellationToken cancellationToken = default) + { + var (bucket, key) = (S3BlobStorageReference) reference; + var client = s3Client; + + // TODO: also delete tag pointer blobs (first fetch metadata head to get tags, the delete each tag) + + try{ + var result = await client.DeleteObjectAsync(new DeleteObjectRequest() + { + BucketName = bucket, Key = key, + }, cancellationToken); + return result.HttpStatusCode == HttpStatusCode.OK ? + CodeResult.Ok() : CodeResult.ErrFrom(result.HttpStatusCode, $"S3 DeleteObject {bucket}/{key}"); } catch (AmazonS3Exception se) { LogIfSerious(se, bucket, key); - //For cache misses, just return a null blob. - if ("NoSuchBucket".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) - return new CacheBlobFetchResult(null,false, (int)se.StatusCode, se.Message); - if (se.StatusCode == System.Net.HttpStatusCode.NotFound || "NoSuchKey".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) - return new CacheBlobFetchResult(null,false, (int)se.StatusCode, se.Message); - if (se.StatusCode == System.Net.HttpStatusCode.Forbidden || "AccessDenied".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) - return new CacheBlobFetchResult(null,false, (int)se.StatusCode, se.Message); - throw; + var converted = ConvertIfRoutine(se, bucket, key, "S3 DeleteObject {bucket}/{key}"); + if (converted != null) return CodeResult.Err(converted.Value); + throw; } } internal void LogIfSerious(AmazonS3Exception se, string bucketName, string key){ + var logger = loggerFactory.CreateLogger(); // if the bucket is missing, it's notable. if ("NoSuchBucket".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) logger.LogError(se, $"Amazon S3 bucket \"{bucketName}\" not found. The bucket may not exist or you may not have permission to access it.\n({se.Message})"); // if there's an authorization or authentication error, it's notable - if (se.StatusCode == System.Net.HttpStatusCode.Forbidden || "AccessDenied".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) + if (se.StatusCode == HttpStatusCode.Forbidden || "AccessDenied".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) logger.LogError(se, $"Amazon S3 blob \"{key}\" in bucket \"{bucketName}\" not accessible. The blob may not exist or you may not have permission to access it.\n({se.Message})"); // InvalidAccessKeyId and InvalidSecurity if ("InvalidAccessKeyId".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase) || "InvalidSecurity".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) logger.LogError(se, $"Your S3 credentials are not working. Caching to bucket \"{bucketName}\" is not operational.\n({se.Message})"); } + + + async IAsyncEnumerable SearchByTag(SearchableBlobTag tag, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // We need to search within each group, and iterate with the continuation token + foreach (var group in config.BlobGroupConfigurations.Keys) + { + var groupConfig = GetConfigFor(group); + var bucket = groupConfig.Location.BucketName; + var client = groupConfig.Location.S3Client ?? s3Client; + var prefix = $"{groupConfig.Location.BlobTagsPrefix}/{tag.Key}/{tag.Value}/"; + var req = new ListObjectsV2Request() + { + BucketName = bucket, Prefix = prefix, + }; + var continuationToken = ""; + do + { + req.ContinuationToken = continuationToken; + var result = await client.ListObjectsV2Async(req, cancellationToken); + foreach (var obj in result.S3Objects) + { + // subtract prefix, verify tag matches + if (!obj.Key.StartsWith(prefix)) throw new InvalidOperationException($"Unexpected key {obj.Key} in S3 bucket {bucket}"); + var mainKey = obj.Key.Substring(prefix.Length); + yield return new S3BlobStorageReference(bucket, mainKey); + } + continuationToken = result.NextContinuationToken; + } while (continuationToken != null); + } + } + + public Task>> CacheSearchByTag(SearchableBlobTag tag, + CancellationToken cancellationToken = default) + { + var enumerable = SearchByTag(tag, cancellationToken); + + return Task.FromResult(CodeResult>.Ok(enumerable)); + // catch (AmazonS3Exception e) + // TODO: determine if there are any routine errors + } + + public Task>>> CachePurgeByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + + var enumerable = PurgeByTag(tag, cancellationToken); + + return Task.FromResult(CodeResult>>.Ok(enumerable)); + // catch (AmazonS3Exception e) + // TODO: determine if there are any routine errors + } + + private async IAsyncEnumerable> PurgeByTag(SearchableBlobTag tag, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Use search + delete + var enumerable = SearchByTag(tag, cancellationToken); + await foreach (var reference in enumerable) + { + var result = await CacheDelete(reference, cancellationToken); + yield return result.IsError ? CodeResult.Err(result.UnwrapError()) : CodeResult.Ok(reference); + } + } + + + public Task OnCacheEvent(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + // TODO: handle renewals + return Task.FromResult(CodeResult.Ok()); + } + + public BlobCacheCapabilities InitialCacheCapabilities { get; } + public async ValueTask CacheHealthCheck(CancellationToken cancellationToken = default) + { + var testResults = await lifecycleUpdater.CreateAndReadTestFilesAsync(true); + var newCaps = InitialCacheCapabilities; + if (testResults.WritesFailed) + newCaps = newCaps with + { + CanPut = false, + CanConditionalPut = false + }; + if (testResults.ReadsFailed) + newCaps = newCaps with + { + CanFetchData = false, + CanFetchMetadata = false, + CanConditionalFetch = false + }; + if (testResults.DeleteFailed) + newCaps = newCaps with + { + CanDelete = false, + CanPurgeByTag = false + }; + if (testResults.ListFailed) + newCaps = newCaps with + { + CanSearchByTag = false, + CanPurgeByTag = false + }; + if (testResults.Results.Count != 0) + { + return BlobCacheHealthDetails.Errors( + $"S3 health and access failed: {testResults.Results.Count} errors (CanGet={!testResults.ReadsFailed}, CanPut={!testResults.WritesFailed}, CanDelete={!testResults.DeleteFailed}, CanList={!testResults.ListFailed})" + , testResults.Results, newCaps); + } + + return BlobCacheHealthDetails.FullHealth(newCaps); + + } } } \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.S3/Caching/S3LifecycleUpdater.cs b/src/Imageflow.Server.Storage.S3/Caching/S3LifecycleUpdater.cs index a2cc4e32..ee890de4 100644 --- a/src/Imageflow.Server.Storage.S3/Caching/S3LifecycleUpdater.cs +++ b/src/Imageflow.Server.Storage.S3/Caching/S3LifecycleUpdater.cs @@ -1,25 +1,49 @@ +using System; using Amazon.S3; using Amazon.S3.Model; +using Imazen.Common.Concurrency; using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Amazon.Runtime; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; namespace Imageflow.Server.Storage.S3.Caching { internal class S3LifecycleUpdater { - private readonly ILogger logger; - public S3LifecycleUpdater(NamedCacheConfiguration config, IAmazonS3 defaultClient, ILogger logger){ + private readonly IReLogger logger; + public S3LifecycleUpdater(NamedCacheConfiguration config, IAmazonS3 defaultClient, IReLogger logger){ this.config = config; - this.logger = logger; + this.logger = logger.WithSubcategory(nameof(S3LifecycleUpdater)); this.defaultClient = defaultClient; } private NamedCacheConfiguration config; private IAmazonS3 defaultClient; + + + private BasicAsyncLock updateLock = new BasicAsyncLock(); + private bool updateComplete = false; + internal async Task UpdateIfIncompleteAsync(){ + if (updateComplete){ + return; + } + using (var unused = await updateLock.LockAsync()) + { + if (updateComplete){ + return; + } + await CreateBucketsAsync(); + await UpdateLifecycleRulesAsync(); + await CreateAndReadTestFilesAsync(false); + updateComplete = true; + } + } internal async Task CreateBucketsAsync(){ var buckets = config.BlobGroupConfigurations.Values.ToList(); // Filter out those with the same bucket name @@ -28,8 +52,13 @@ internal async Task CreateBucketsAsync(){ // await all simultaneous requests await Task.WhenAll(distinctBuckets.Select(async (v) => { try{ + if (v.CreateBucketIfMissing == false){ + return; + } var client = v.Location.S3Client ?? defaultClient; - await client.DoesS3BucketExistAsync(v.Location.BucketName).ContinueWith(async (task) => { + + await Amazon.S3.Util.AmazonS3Util.DoesS3BucketExistV2Async(client, v.Location.BucketName) + .ContinueWith(async (task) => { if (!task.Result){ // log missing logger.LogInformation($"S3 Bucket {v.Location.BucketName} does not exist. Creating..."); @@ -62,6 +91,9 @@ internal async Task UpdateLifecycleRulesAsync(){ // await all simultaneous requests await Task.WhenAll(bucketGroups.Select(async (group) => { + if (group.First().UpdateLifecycleRules == false){ + return; + } var client = group.First().Location.S3Client ?? defaultClient; // Fetch the lifecycle rules GetLifecycleConfigurationResponse lifecycleConfigurationResponse; @@ -125,6 +157,125 @@ await Task.WhenAll(bucketGroups.Select(async (group) => { }; })); } + + internal record class TestFilesResult( List Results, bool ReadsFailed, bool WritesFailed, bool ListFailed, bool DeleteFailed); + /// + /// Returns all errors encountered + /// + /// + /// + internal async Task CreateAndReadTestFilesAsync(bool forceAll) + { + var buckets = config.BlobGroupConfigurations.Values.ToList(); + // Filter out those with the same bucket name + var bucketGroups = buckets.GroupBy(v => v.Location.BucketName).ToList(); + + var results = new List(); + bool readsFailed = false; + bool writesFailed = false; + bool listFailed = false; + bool deleteFailed = false; + // await all simultaneous requests + await Task.WhenAll(config.BlobGroupConfigurations.Values.Select(async (blobGroupConfig) => + { + if (!forceAll && blobGroupConfig.TestBucketCapabilities == false) + { + return; + } + var client = blobGroupConfig.Location.S3Client ?? defaultClient; + var bucket = blobGroupConfig.Location.BucketName; + var key = blobGroupConfig.Location.BlobPrefix + "/empty-healthcheck-file"; + + var putResult = await TryS3OperationAsync(bucket, key, "Put", async () => + { + var putObjectRequest = new PutObjectRequest + { + BucketName = bucket, + Key = key, + ContentBody = "" + }; + return await client.PutObjectAsync(putObjectRequest); + }); + if (putResult.IsError) + { + writesFailed = true; + results.Add(putResult); + } + // now try listing + var listResult = await TryS3OperationAsync(bucket, key, "List", async () => + await client.ListObjectsAsync(new ListObjectsRequest + { + BucketName = bucket, + Prefix = key + })); + if (listResult.IsError){ + listFailed = true; + results.Add(listResult); + } + // now try reading + var getResult = await TryS3OperationAsync(bucket, key, "Get", async () => + await client.GetObjectAsync(new GetObjectRequest + { + BucketName = bucket, + Key = key + })); + if (getResult.IsError){ + readsFailed = true; + results.Add(getResult); + } + + var key2 = blobGroupConfig.Location.BlobPrefix + "/empty-healthcheck-file2"; + + var putResult2 = await TryS3OperationAsync(bucket, key2, "Put", async () => + { + var putObjectRequest = new PutObjectRequest + { + BucketName = bucket, + Key = key2, + ContentBody = "" + }; + return await client.PutObjectAsync(putObjectRequest); + }); + // now try deleting + var deleteResult = await TryS3OperationAsync(bucket, key, "Delete", async () => + await client.DeleteObjectAsync(new DeleteObjectRequest + { + BucketName = bucket, + Key = key + })); + if (deleteResult.IsError){ + deleteFailed = true; + results.Add(deleteResult); + } + + })); + return new TestFilesResult(results, readsFailed, writesFailed, listFailed, deleteFailed); + } + + private async Task TryS3OperationAsync(string bucketName, string key, string operationName, + Func> operation) + { + try + { + var response = await operation(); + if (response.HttpStatusCode != System.Net.HttpStatusCode.OK) + { + var err = CodeResult.Err( + new HttpStatus(response.HttpStatusCode).WithAddFrom($"S3 {operationName} {bucketName} {key}")); + logger.LogAsError(err); + return err; + } + + return CodeResult.Ok(); + } + catch (AmazonS3Exception e) + { + var err = CodeResult.FromException(e, $"S3 {operationName} {bucketName} {key}"); + logger.LogAsError(err); + return err; + } + } + } } diff --git a/src/Imageflow.Server.Storage.S3/Imageflow.Server.Storage.S3.csproj b/src/Imageflow.Server.Storage.S3/Imageflow.Server.Storage.S3.csproj index 7854eb57..9ebc1495 100644 --- a/src/Imageflow.Server.Storage.S3/Imageflow.Server.Storage.S3.csproj +++ b/src/Imageflow.Server.Storage.S3/Imageflow.Server.Storage.S3.csproj @@ -1,20 +1,23 @@ - netstandard2.0 + netstandard2.0;net8.0;net6.0 Imageflow.Server.Storage.S3 - Plugin for fetching source images from S3. Imageflow.Server plugin for fetching source images from S3. true + latest + enable + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Imageflow.Server.Storage.S3/PrefixMapping.cs b/src/Imageflow.Server.Storage.S3/PrefixMapping.cs index b23d24c2..44269b7d 100644 --- a/src/Imageflow.Server.Storage.S3/PrefixMapping.cs +++ b/src/Imageflow.Server.Storage.S3/PrefixMapping.cs @@ -6,7 +6,7 @@ namespace Imageflow.Server.Storage.S3 internal struct PrefixMapping : IDisposable { internal string Prefix; - internal IAmazonS3 S3Client; + internal IAmazonS3? S3Client; internal string Bucket; internal string BlobPrefix; internal bool IgnorePrefixCase; diff --git a/src/Imageflow.Server.Storage.S3/S3Blob.cs b/src/Imageflow.Server.Storage.S3/S3Blob.cs index 1af4c3ef..a7d202d4 100644 --- a/src/Imageflow.Server.Storage.S3/S3Blob.cs +++ b/src/Imageflow.Server.Storage.S3/S3Blob.cs @@ -1,34 +1,34 @@ using System; using System.IO; +using System.Linq; using Amazon.S3.Model; -using Imazen.Common.Storage; -using Imazen.Common.Storage.Caching; +using Imazen.Abstractions.Blobs; namespace Imageflow.Server.Storage.S3 { - internal class S3Blob : IBlobData, IDisposable, ICacheBlobData, ICacheBlobDataExpiry + internal static class S3BlobHelpers { - private readonly GetObjectResponse response; - - internal S3Blob(GetObjectResponse r) + public static ConsumableStreamBlob CreateS3Blob(GetObjectResponse r) { - response = r; - } - - public bool? Exists => true; - public DateTime? LastModifiedDateUtc => response.LastModified.ToUniversalTime(); - - public DateTimeOffset? EstimatedExpiry => response.Expiration?.ExpiryDateUtc; - + if (r.HttpStatusCode != System.Net.HttpStatusCode.OK) + { + throw new Exception($"S3 returned {r.HttpStatusCode} for {r.BucketName}/{r.Key}"); + } - public Stream OpenRead() - { - return response.ResponseStream; - } - - public void Dispose() - { - response?.Dispose(); + // metadata starting with t_ is a tag + var tags = r.Metadata.Keys.Where(k => k.StartsWith("t_")) + .Select(key => SearchableBlobTag.CreateUnvalidated(key.Substring(2), r.Metadata[key])).ToList(); + var a = new BlobAttributes() + { + BlobByteCount = r.ContentLength, + ContentType = r.Headers?.ContentType, + Etag = r.ETag, + LastModifiedDateUtc = r.LastModified.ToUniversalTime(), + StorageTags = tags, + EstimatedExpiry = r.Expiration?.ExpiryDateUtc, + BlobStorageReference = new S3BlobStorageReference(r.BucketName, r.Key) + }; + return new ConsumableStreamBlob(a, r.ResponseStream, r); } } } \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.S3/S3BlobStorageReference.cs b/src/Imageflow.Server.Storage.S3/S3BlobStorageReference.cs new file mode 100644 index 00000000..0d34ad75 --- /dev/null +++ b/src/Imageflow.Server.Storage.S3/S3BlobStorageReference.cs @@ -0,0 +1,14 @@ +using Imazen.Abstractions.Blobs; +using Imazen.Common.Extensibility.Support; + +namespace Imageflow.Server.Storage.S3; + +internal record S3BlobStorageReference(string BucketName, string Key) : IBlobStorageReference +{ + public string GetFullyQualifiedRepresentation() + { + return $"s3://{BucketName}/{Key}"; + } + + public int EstimateAllocatedBytesRecursive => 24 + BucketName.EstimateMemorySize(true) + Key.EstimateMemorySize(true); +} \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.S3/S3Service.cs b/src/Imageflow.Server.Storage.S3/S3Service.cs index c3d47657..8171895e 100644 --- a/src/Imageflow.Server.Storage.S3/S3Service.cs +++ b/src/Imageflow.Server.Storage.S3/S3Service.cs @@ -4,34 +4,44 @@ using System.Threading.Tasks; using Amazon.S3; using Imageflow.Server.Storage.S3.Caching; -using Imazen.Common.Storage; -using Imazen.Common.Storage.Caching; -using Microsoft.Extensions.Logging; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; namespace Imageflow.Server.Storage.S3 { - public class S3Service : IBlobProvider, IDisposable, IBlobCacheProvider + public class S3Service : IBlobWrapperProvider, IDisposable, IBlobCacheProvider, IBlobWrapperProviderZoned { - private readonly List mappings = new List(); - private readonly List namedCaches = new List(); + private readonly List mappings = []; + private readonly List namedCaches = []; - private readonly IAmazonS3 s3client; + private readonly IAmazonS3 s3Client; - public S3Service(S3ServiceOptions options, IAmazonS3 s3client, ILogger logger) + public S3Service(S3ServiceOptions options, IAmazonS3 s3Client, IReLoggerFactory loggerFactory) { - this.s3client = s3client; + this.s3Client = s3Client; + UniqueName = options.UniqueName; foreach (var m in options.Mappings) { mappings.Add(m); } - foreach (var m in options.NamedCaches) + foreach (var m in options.NamedCaches) { - namedCaches.Add(new S3BlobCache(m, s3client, logger)); + namedCaches.Add(new S3BlobCache(m, s3Client, loggerFactory)); } //TODO: verify this sorts longest first mappings.Sort((a,b) => b.Prefix.Length.CompareTo(a.Prefix.Length)); } + public string UniqueName { get; } + public IEnumerable GetPrefixesAndZones() + { + return mappings.Select(m => new BlobWrapperPrefixZone(m.Prefix, + new LatencyTrackingZone($"s3::bucket/{m.Bucket}", 100))); + } + public IEnumerable GetPrefixes() { return mappings.Select(m => m.Prefix); @@ -43,13 +53,14 @@ public bool SupportsPath(string virtualPath) m.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); } - public async Task Fetch(string virtualPath) + public async Task> Fetch(string virtualPath) { var mapping = mappings.FirstOrDefault(m => virtualPath.StartsWith(m.Prefix, m.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); if (mapping.Prefix == null) { - return null; + return CodeResult.Err((HttpStatus.NotFound, $"No S3 mapping found for virtual path \"{virtualPath}\"")); + } var partialKey = virtualPath.Substring(mapping.Prefix.Length).TrimStart('/'); @@ -64,11 +75,12 @@ public async Task Fetch(string virtualPath) try { - var client = mapping.S3Client ?? this.s3client; + var client = mapping.S3Client ?? this.s3Client; var req = new Amazon.S3.Model.GetObjectRequest() { BucketName = mapping.Bucket, Key = key }; + var latencyZone = new LatencyTrackingZone($"s3::bucket/{mapping.Bucket}", 100); var s = await client.GetObjectAsync(req); - return new S3Blob(s); + return new BlobWrapper(latencyZone,S3BlobHelpers.CreateS3Blob(s)); } catch (AmazonS3Exception se) { if (se.StatusCode == System.Net.HttpStatusCode.NotFound || "NoSuchKey".Equals(se.ErrorCode, StringComparison.OrdinalIgnoreCase)) @@ -81,24 +93,12 @@ public async Task Fetch(string virtualPath) public void Dispose() { - try { s3client?.Dispose(); } catch { } - } - - public bool TryGetCache(string name, out IBlobCache cache) - { - var c = namedCaches.FirstOrDefault(v => v.Name == name); - if (c != null) - { - cache = c; - return true; - } - cache = null; - return false; + try { s3Client?.Dispose(); } catch { } } - public IEnumerable GetCacheNames() + IEnumerable IBlobCacheProvider.GetBlobCaches() { - return namedCaches.Select(v => v.Name); + return namedCaches; } } diff --git a/src/Imageflow.Server.Storage.S3/S3ServiceExtensions.cs b/src/Imageflow.Server.Storage.S3/S3ServiceExtensions.cs index f1b35c32..72ee18ea 100644 --- a/src/Imageflow.Server.Storage.S3/S3ServiceExtensions.cs +++ b/src/Imageflow.Server.Storage.S3/S3ServiceExtensions.cs @@ -1,7 +1,7 @@ using Amazon.S3; -using Imazen.Common.Storage; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.Logging; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Imageflow.Server.Storage.S3 { @@ -12,11 +12,12 @@ public static class S3ServiceExtensions public static IServiceCollection AddImageflowS3Service(this IServiceCollection services, S3ServiceOptions options) { - services.AddSingleton((container) => + services.AddImageflowReLogStoreAndReLoggerFactoryIfMissing(); + services.AddSingleton((container) => { - var logger = container.GetRequiredService>(); + var loggerFactory = container.GetRequiredService(); var s3 = container.GetRequiredService(); - return new S3Service(options, s3, logger); + return new S3Service(options, s3, loggerFactory); }); return services; diff --git a/src/Imageflow.Server.Storage.S3/S3ServiceOptions.cs b/src/Imageflow.Server.Storage.S3/S3ServiceOptions.cs index 291a15f8..5d50483a 100644 --- a/src/Imageflow.Server.Storage.S3/S3ServiceOptions.cs +++ b/src/Imageflow.Server.Storage.S3/S3ServiceOptions.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using Amazon; -using Amazon.Runtime; using Amazon.S3; using Imageflow.Server.Storage.S3.Caching; @@ -11,6 +9,15 @@ public class S3ServiceOptions { internal readonly List Mappings = new List(); internal readonly List NamedCaches = new List(); + internal string UniqueName { get; private set; } = "s3"; + + public S3ServiceOptions SetUniqueName(string uniqueName) + { + UniqueName = uniqueName; + return this; + } + + public S3ServiceOptions MapPrefix(string prefix, string bucket) => MapPrefix(prefix, bucket, ""); @@ -26,7 +33,6 @@ public S3ServiceOptions MapPrefix(string prefix, string bucket, string blobPrefi /// Maps a given prefix to a specified location within a bucket /// /// The prefix to capture image requests within - /// The configuration (such as region endpoint or service URL, etc) to use /// The bucket to serve images from /// The path within the bucket to serve images from. Can be an empty string to serve /// from root of bucket. @@ -52,7 +58,7 @@ public S3ServiceOptions MapPrefix(string prefix, string bucket, string blobPrefi /// (requires that actual blobs all be lowercase). /// /// - public S3ServiceOptions MapPrefix(string prefix, IAmazonS3 s3Client, string bucket, string blobPrefix, bool ignorePrefixCase, bool lowercaseBlobPath) + public S3ServiceOptions MapPrefix(string prefix, IAmazonS3? s3Client, string bucket, string blobPrefix, bool ignorePrefixCase, bool lowercaseBlobPath) { prefix = prefix.TrimStart('/').TrimEnd('/'); if (prefix.Length == 0) @@ -82,7 +88,7 @@ public S3ServiceOptions MapPrefix(string prefix, IAmazonS3 s3Client, string buck /// /// /// - public S3ServiceOptions AddNamedCacheConfiguration(NamedCacheConfiguration namedCacheConfiguration) + public S3ServiceOptions AddNamedCache(NamedCacheConfiguration namedCacheConfiguration) { NamedCaches.Add(namedCacheConfiguration); return this; diff --git a/src/Imageflow.Server.Storage.S3/StringValidator.cs b/src/Imageflow.Server.Storage.S3/StringValidator.cs deleted file mode 100644 index 35255043..00000000 --- a/src/Imageflow.Server.Storage.S3/StringValidator.cs +++ /dev/null @@ -1,193 +0,0 @@ -// utility functions to valid key and bucket names for usage on S3 - -// Path: src/Imageflow.Server.Storage.S3/StringValidator.cs -using System.Globalization; -using System.Linq; - -namespace Imageflow.Server.Storage.S3 -{ - internal static class StringValidator - { - /// - /// Validates that the string is a valid S3 key. - /// - /// - /// - /// - internal static bool ValidateKey(string key, out string error) - { - error = null; - if (key == null || key.Length == 0) - { - error = "Key cannot be null or empty"; - return false; - } - - // Byte encoded as UTF-8 must be <= 1024 bytes - if (System.Text.Encoding.UTF8.GetByteCount(key) > 1024) - { - error = "Key cannot be longer than 1024 characters when encoded as UTF-8"; - return false; - } - - return true; - } - - // validate key is sane, ()'*._-!A-Za-z0-0 - internal static bool ValidateKeySane(string key, out string error) - { - error = null; - - if (!ValidateKey(key, out error)) - { - return false; - } - - if (key.StartsWith("/")) - { - error = "Key cannot start with /"; - return false; - } - if (key.StartsWith("./")) - { - error = "Key cannot start with ./"; - return false; - } - if (key.StartsWith("../")) - { - error = "Key cannot start with ../"; - return false; - } - if (key.EndsWith(".")) - { - error = "Key cannot end with ."; - return false; - } - if (key.Contains("//")) - { - error = "Key cannot contain //"; - return false; - } - - if (!key.ToList().All(c => char.IsLetterOrDigit(c) || c == '(' || c == ')' || c == '\'' || c=='/' || c == '.' || c == '_' || c == '-')) - { - error = "Key should only contain letters, numbers, (),/, *, ', ., _, and -"; - return false; - } - - return true; - } - - // validate key prefix is empty, null, or sand AND ends with / - internal static bool ValidateKeyPrefix(string key, out string error) - { - error = null; - if (key == null || key.Length == 0) - { - return true; - } - - if (!ValidateKeySane(key, out error)) - { - return false; - } - - if (!key.EndsWith("/")) - { - error = "Key prefix must end with /"; - return false; - } - - return true; - } - - /// - /// Validates that the string is a valid S3 bucket name - /// - /// - /// - /// Bucket names must be between 3 (min) and 63 (max) characters long. - /// Bucket names can consist only of lowercase letters, numbers, dots (.), and hyphens (-). - /// Bucket names must begin and end with a letter or number. - /// Bucket names must not contain two adjacent periods. - /// Bucket names must not be formatted as an IP address (for example, 192.168.5.4). - /// Bucket names must not start with the prefix xn--. - /// Bucket names must not start with the prefix sthree- and the prefix sthree-configurator. - /// Bucket names must not end with the suffix -s3alias. This suffix is reserved for access point alias names. For more information, see Using a bucket-style alias for your S3 bucket access point. - /// Bucket names must not end with the suffix --ol-s3. This suffix is reserved for Object Lambda Access Point alias names. For more information, see How to use a bucket-style alias for your S3 bucket Object Lambda Access Point. - /// - /// The error message - /// - internal static bool ValidateBucketName(string bucket, out string error) - { - error = null; - if (bucket == null || bucket.Length == 0) - { - error = "Bucket name cannot be null or empty"; - return false; - } - - // Bucket names must not be formatted as an IP address (for example, 192.168.5.4). - if (System.Net.IPAddress.TryParse(bucket, out _)) - { - error = "Bucket name cannot be an IP address"; - return false; - } - // Bucket names can consist only of lowercase letters, numbers, dots (.), and hyphens (-). - if (!bucket.ToList().All(c => (c >= 'a' && c <= 'z') || char.IsDigit(c) || c == '.' || c == '-')) - { - error = "Bucket name can only contain lowercase letters, numbers, dots, and hyphens"; - return false; - } - - if (bucket.Length < 3) - { - error = "Bucket name must be at least 3 characters long"; - return false; - } - - if (bucket.Length > 63) - { - error = "Bucket name cannot be longer than 63 characters"; - return false; - } - if (bucket.Contains("..")) - { - error = "Bucket name cannot contain two periods in a row"; - return false; - } - - if (!char.IsLetterOrDigit(bucket[0])) - { - error = "Bucket name must start with a letter or number"; - return false; - } - - if (!char.IsLetterOrDigit(bucket[bucket.Length - 1])) - { - error = "Bucket name must end with a letter or number"; - return false; - } - - if (bucket.StartsWith("xn--")) - { - error = "Bucket name cannot start with xn--"; - return false; - } - - if (bucket.EndsWith("-s3alias") || bucket.EndsWith("--ol-s3")) - { - error = "Bucket name cannot end with -s3alias or --ol-s3"; - return false; - } - - if (bucket.StartsWith("sthree-") || bucket.StartsWith("sthree-configurator")) - { - error = "Bucket name cannot start with sthree- or sthree-configurator"; - return false; - } - - return true; - } - } -} diff --git a/src/Imageflow.Server.Storage.S3/design.md b/src/Imageflow.Server.Storage.S3/design.md new file mode 100644 index 00000000..05323c40 --- /dev/null +++ b/src/Imageflow.Server.Storage.S3/design.md @@ -0,0 +1,7 @@ +## Caveats + +Currently AWS accounts are limited to 10 directory buckets (S3 Express One Zone), so warn users about auto-creation + +https://docs.aws.amazon.com/AmazonS3/latest/userguide/directory-buckets-overview.html + +https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html#ConsistencyModel \ No newline at end of file diff --git a/src/Imageflow.Server.Storage.S3/packages.lock.json b/src/Imageflow.Server.Storage.S3/packages.lock.json index eeb5b266..1e6d65c7 100644 --- a/src/Imageflow.Server.Storage.S3/packages.lock.json +++ b/src/Imageflow.Server.Storage.S3/packages.lock.json @@ -4,11 +4,11 @@ ".NETStandard,Version=v2.0": { "AWSSDK.S3": { "type": "Direct", - "requested": "[3.7.101.49, )", - "resolved": "3.7.101.49", - "contentHash": "GN0pFLWqDe53UDbMEtZ4RkU64+mtJS4OsADemJ0TTaUlLhH5xJCkj5nhTa7eFG3tiIKbtdeMDUPhX47F3+UimQ==", + "requested": "[3.7.305.28, )", + "resolved": "3.7.305.28", + "contentHash": "kjnmtEIddQPmS8XxwGwu1Gdc0vO+3bbohPSH59s8OdXnZlSMtsldJM+jwS5LTz4DUusFwMk3E8h0DY0TlwkVzg==", "dependencies": { - "AWSSDK.Core": "[3.7.103.11, 4.0.0)" + "AWSSDK.Core": "[3.7.302.12, 4.0.0)" } }, "Microsoft.CodeAnalysis.PublicApiAnalyzers": { @@ -19,12 +19,12 @@ }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, "NETStandard.Library": { @@ -38,24 +38,49 @@ }, "AWSSDK.Core": { "type": "Transitive", - "resolved": "3.7.103.11", - "contentHash": "twTMAAOA4ztiVtfrq2ZiRUmSnt2CW3DZwGvlbcftM6Dbsc2VGWE/hWv1Rl6GfKpEbrJxkW+FmG2o27XRTg8lqA==", + "resolved": "3.7.302.12", + "contentHash": "OaFHc2FD3vYldtdaH0Sqob3NoTXFBBMKxmq/VV3of5l+hHZmcaAjpJhh1Et0BxggeHqimVOTaoQxEO0PXQHeuw==", "dependencies": { "Microsoft.Bcl.AsyncInterfaces": "1.1.0" } }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Imageflow.Net": { + "type": "Transitive", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" + } + }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg==", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", "dependencies": { - "System.Threading.Tasks.Extensions": "4.5.2" + "System.Threading.Tasks.Extensions": "4.5.4" } }, - "Microsoft.Build.Tasks.Git": { + "Microsoft.Bcl.HashCode": { "type": "Transitive", "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -103,6 +128,14 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "2.3.2", + "contentHash": "Oh1qXXFdJFcHozvb4H6XYLf2W0meZFuG0A+TfapFPj9z5fd4vxiARGEhAaLj/6XWQaMYIv4SH/9Q6H78Hw0E2Q==", + "dependencies": { + "System.Memory": "4.5.5" + } + }, "Microsoft.NETCore.Platforms": { "type": "Transitive", "resolved": "1.1.0", @@ -110,47 +143,458 @@ }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, "System.Buffers": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "AwarXzzoDwX6BgrhjoJsk6tUezZEozOT5Y9QKF94Gl4JK91I4PIIBkBco9068Y9/Dra8Dkbie99kXB8+1BaYKw==" + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } }, "System.Memory": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", "dependencies": { - "System.Buffers": "4.4.0", + "System.Buffers": "4.5.1", "System.Numerics.Vectors": "4.4.0", - "System.Runtime.CompilerServices.Unsafe": "4.5.0" + "System.Runtime.CompilerServices.Unsafe": "4.5.3" } }, "System.Numerics.Vectors": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.2", - "contentHash": "wprSFgext8cwqymChhrBLu62LMg/1u92bU+VOwyfBimSPVFXtsNqEWC92Pf9ofzJFlk4IHmJA75EDJn1b2goAQ==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } }, "System.Threading.Tasks.Extensions": { "type": "Transitive", - "resolved": "4.5.2", - "contentHash": "BG/TNxDFv0svAzx8OiMXDlsHfGw623BZ8tCXw4YLhDFDvDhNUEV58jKYMGRnkbJNm7c3JNNJDiN7JBMzxRBR2w==", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", "dependencies": { - "System.Runtime.CompilerServices.Unsafe": "4.5.2" + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } + } + }, + "net6.0": { + "AWSSDK.S3": { + "type": "Direct", + "requested": "[3.7.305.28, )", + "resolved": "3.7.305.28", + "contentHash": "kjnmtEIddQPmS8XxwGwu1Gdc0vO+3bbohPSH59s8OdXnZlSMtsldJM+jwS5LTz4DUusFwMk3E8h0DY0TlwkVzg==", + "dependencies": { + "AWSSDK.Core": "[3.7.302.12, 4.0.0)" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "3.7.302.12", + "contentHash": "OaFHc2FD3vYldtdaH0Sqob3NoTXFBBMKxmq/VV3of5l+hHZmcaAjpJhh1Et0BxggeHqimVOTaoQxEO0PXQHeuw==" + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "Imageflow.Net": { + "type": "Transitive", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "2.3.2", + "contentHash": "Oh1qXXFdJFcHozvb4H6XYLf2W0meZFuG0A+TfapFPj9z5fd4vxiARGEhAaLj/6XWQaMYIv4SH/9Q6H78Hw0E2Q==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" + } + } + }, + "net8.0": { + "AWSSDK.S3": { + "type": "Direct", + "requested": "[3.7.305.28, )", + "resolved": "3.7.305.28", + "contentHash": "kjnmtEIddQPmS8XxwGwu1Gdc0vO+3bbohPSH59s8OdXnZlSMtsldJM+jwS5LTz4DUusFwMk3E8h0DY0TlwkVzg==", + "dependencies": { + "AWSSDK.Core": "[3.7.302.12, 4.0.0)" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "AWSSDK.Core": { + "type": "Transitive", + "resolved": "3.7.302.12", + "contentHash": "OaFHc2FD3vYldtdaH0Sqob3NoTXFBBMKxmq/VV3of5l+hHZmcaAjpJhh1Et0BxggeHqimVOTaoQxEO0PXQHeuw==" + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "Imageflow.Net": { + "type": "Transitive", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "2.3.2", + "contentHash": "Oh1qXXFdJFcHozvb4H6XYLf2W0meZFuG0A+TfapFPj9z5fd4vxiARGEhAaLj/6XWQaMYIv4SH/9Q6H78Hw0E2Q==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" + } } } } diff --git a/src/Imageflow.Server/BlobFetchCache.cs b/src/Imageflow.Server/BlobFetchCache.cs deleted file mode 100644 index 7094ce83..00000000 --- a/src/Imageflow.Server/BlobFetchCache.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Threading.Tasks; -using Imazen.Common.Storage; - -namespace Imageflow.Server -{ - internal class BlobFetchCache - { - public BlobFetchCache(string virtualPath, BlobProvider provider) - { - this.virtualPath = virtualPath; - this.provider = provider; - resultFetched = false; - blobFetched = false; - } - - private readonly BlobProvider provider; - private readonly string virtualPath; - private bool resultFetched; - private bool blobFetched; - private BlobProviderResult? result; - private IBlobData blob; - - internal BlobProviderResult? GetBlobResult() - { - if (resultFetched) return result; - result = provider.GetResult(virtualPath); - resultFetched = true; - return result; - } - - internal async Task GetBlob() - { - if (blobFetched) return blob; - var blobResult = GetBlobResult(); - if (blobResult != null) blob = await blobResult.Value.GetBlob(); - blobFetched = true; - return blob; - } - - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/BlobProvider.cs b/src/Imageflow.Server/BlobProvider.cs deleted file mode 100644 index d726e2ab..00000000 --- a/src/Imageflow.Server/BlobProvider.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Imazen.Common.Storage; - -namespace Imageflow.Server -{ - internal class BlobProvider - { - private readonly List blobProviders = new List(); - private readonly List blobPrefixes = new List(); - private readonly List pathMappings; - public BlobProvider(IEnumerable blobProviders, List pathMappings) - { - this.pathMappings = pathMappings.ToList(); - this.pathMappings.Sort((a, b) => b.VirtualPath.Length.CompareTo(a.VirtualPath.Length)); - - foreach (var provider in blobProviders) - { - this.blobProviders.Add(provider); - foreach (var prefix in provider.GetPrefixes()) - { - var conflictingPrefix = - blobPrefixes.FirstOrDefault(p => - prefix.StartsWith(p, StringComparison.OrdinalIgnoreCase) || - p.StartsWith(prefix,StringComparison.OrdinalIgnoreCase)); - if (conflictingPrefix != null) - { - throw new InvalidOperationException($"Blob Provider failure: Prefix {{prefix}} conflicts with prefix {conflictingPrefix}"); - } - // We don't check for conflicts with PathMappings because / is a path mapping usually, - // and we simply prefer blobs over files if there are overlapping prefixes. - blobPrefixes.Add(prefix); - } - } - } - - internal BlobProviderResult? GetResult(string virtualPath) - { - return GetBlobResult(virtualPath) ?? GetFileResult(virtualPath); - } - - private BlobProviderResult? GetBlobResult(string virtualPath) - { - if (blobPrefixes.Any(p => virtualPath.StartsWith(p, StringComparison.OrdinalIgnoreCase))) - { - foreach (var provider in blobProviders) - { - if (provider.SupportsPath(virtualPath)) - { - return new BlobProviderResult() - { - IsFile = false, - GetBlob = () => provider.Fetch(virtualPath) - }; - } - } - } - return null; - } - - private BlobProviderResult? GetFileResult(string virtualPath) - { - var mapping = pathMappings.FirstOrDefault( - m => virtualPath.StartsWith(m.VirtualPath, - m.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)); - if (mapping.PhysicalPath == null || mapping.VirtualPath == null) return null; - - var relativePath = virtualPath - .Substring(mapping.VirtualPath.Length) - .Replace('/', Path.DirectorySeparatorChar) - .TrimStart(Path.DirectorySeparatorChar); - - var physicalDir = Path.GetFullPath(mapping.PhysicalPath.TrimEnd(Path.DirectorySeparatorChar)); - - var physicalPath = Path.GetFullPath(Path.Combine( - physicalDir, - relativePath)); - if (!physicalPath.StartsWith(physicalDir, StringComparison.Ordinal)) - { - return null; //We stopped a directory traversal attack (most likely) - } - - - var lastWriteTimeUtc = File.GetLastWriteTimeUtc(physicalPath); - if (lastWriteTimeUtc.Year == 1601) // file doesn't exist, pass to next middleware - { - return null; - - } - - return new BlobProviderResult() - { - IsFile = true, - GetBlob = () => Task.FromResult(new BlobProviderFile() - { - Path = physicalPath, - Exists = true, - LastModifiedDateUtc = lastWriteTimeUtc - } as IBlobData) - }; - } - - } - - - -} \ No newline at end of file diff --git a/src/Imageflow.Server/BlobProviderFile.cs b/src/Imageflow.Server/BlobProviderFile.cs deleted file mode 100644 index 60e3797a..00000000 --- a/src/Imageflow.Server/BlobProviderFile.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.IO; -using Imazen.Common.Storage; - -namespace Imageflow.Server -{ - internal class BlobProviderFile : IBlobData - { - public string Path; - public bool? Exists { get; set; } - public DateTime? LastModifiedDateUtc { get; set; } - public Stream OpenRead() - { - return new FileStream(Path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); - } - - public void Dispose() - { - } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/BlobProviderResult.cs b/src/Imageflow.Server/BlobProviderResult.cs deleted file mode 100644 index f2b82f7a..00000000 --- a/src/Imageflow.Server/BlobProviderResult.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Threading.Tasks; -using Imazen.Common.Storage; - -namespace Imageflow.Server -{ - internal struct BlobProviderResult - { - internal bool IsFile; - internal Func> GetBlob; - } - -} \ No newline at end of file diff --git a/src/Imageflow.Server/CacheBackend.cs b/src/Imageflow.Server/CacheBackend.cs deleted file mode 100644 index 6c4b6e83..00000000 --- a/src/Imageflow.Server/CacheBackend.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Imageflow.Server -{ - internal enum CacheBackend - { - ClassicDiskCache, - StreamCache, - NoCache, - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/Caching/CachingManager.cs b/src/Imageflow.Server/Caching/CachingManager.cs deleted file mode 100644 index 624df1eb..00000000 --- a/src/Imageflow.Server/Caching/CachingManager.cs +++ /dev/null @@ -1,16 +0,0 @@ -// We can handle blob caching remote URLs -// We can disk cache remote URLs and source blobs - -// we can disk cache results -// we can blob cache results - -// we can mem cache stuff too - - -// Some blob caches need to renew entries so they don't expire (s3) -// Blob caches are named -// Disk caches *need* to be named -// Mem caches *need* to be named (different quotas, perhaps) - -// For fetching from high-latency caches, we can use ExistenceProbableMap to determine if we should go ahead and start fetching the source image already - diff --git a/src/Imageflow.Server/Caching/ExistenceProbableMap.cs b/src/Imageflow.Server/Caching/ExistenceProbableMap.cs deleted file mode 100644 index bd7bfd0b..00000000 --- a/src/Imageflow.Server/Caching/ExistenceProbableMap.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.Buffers; -using System.IO; -using System.IO.Compression; -using System.Security; -using System.Threading; -using System.Threading.Tasks; -using Imazen.Common.Instrumentation.Support; -using Imazen.Common.Storage; -using Imazen.Common.Storage.Caching; -using Microsoft.IO; - -namespace Imageflow.Server.Caching -{ - /// - /// Uses a ConcurrentBitArray of size 2^24 to track whether a key is likely to exist in the cache. - /// Syncs via blob serialization. Bits representing buckets can only be set to 1, never 0. Works for any string key using SHA256 subsequence. - /// - internal class ExistenceProbableMap - { - private readonly ConcurrentBitArray buckets; - - private const int hashBits = 24; - - private const int bucketCount = 1 << hashBits; - public ExistenceProbableMap(){ - buckets = new ConcurrentBitArray(bucketCount); - } - - private static string BlobName => $"existence-probable-map-{hashBits}-bit.bin"; - - - //ReadAllBytesAsync ArrayPool - - internal static async Task ReadAllBytesIntoBitArrayAsync(Stream stream, ConcurrentBitArray target, MemoryPool pool, CancellationToken cancellationToken = default) - { - if (stream == null) return; - try - { - - if (stream.CanSeek) - { - if (stream.Length != target.ByteCount) - { - throw new Exception($"ExistenceProbableMap.ReadAllBytesIntoBitArrayAsync: Expected {target.ByteCount} bytes in stream, got {stream.Length}"); - } - } - - var bufferOwner = pool.Rent(target.ByteCount); - var bytesRead = await stream.ReadAsync(bufferOwner.Memory, cancellationToken).ConfigureAwait(false); - if (bytesRead == 0) - { - bufferOwner.Dispose(); - throw new IOException("ExistenceProbableMap.ReadAllBytesIntoBitArrayAsync: Stream was empty."); - } - if (bytesRead != target.ByteCount) - { - bufferOwner.Dispose(); - throw new IOException($"ExistenceProbableMap.ReadAllBytesIntoBitArrayAsync: Expected {target.ByteCount} bytes, got {bytesRead}"); - } - // ensure no more bytes can be read - if (stream.ReadByte() != -1) - { - bufferOwner.Dispose(); - throw new IOException("ExistenceProbableMap.ReadAllBytesIntoBitArrayAsync: Stream was longer than {target.ByteCount} bytes."); - } - // copy to target - target.LoadFromSpan(bufferOwner.Memory[..bytesRead].Span); - } - finally - { - stream.Dispose(); - } - } - - internal static async Task FetchSync(IBlobCache cache, ConcurrentBitArray target = null, CancellationToken cancellationToken = default){ - var fetchResult = await cache.TryFetchBlob(BlobGroup.Essential, BlobName, cancellationToken); - using (fetchResult){ - if (fetchResult.DataExists){ - target ??= new ConcurrentBitArray(bucketCount); - await ReadAllBytesIntoBitArrayAsync(fetchResult.Data.OpenRead(),target, MemoryPool.Shared, cancellationToken); - return target; - } - } - return null; - } - - - public async Task Sync(IBlobCache cache, CancellationToken cancellationToken = default){ - - // Fetch - var existingData = await FetchSync(cache, cancellationToken: cancellationToken); - if (existingData != null){ - //Merge - buckets.MergeTrueBitsFrom(existingData); - } - //Put - var bytes = buckets.ToBytes(); - await cache.Put(BlobGroup.Essential, BlobName, new BytesBlobData(bytes), CacheBlobPutOptions.Default, cancellationToken); - } - - - internal static int HashFor(byte[] data) - { - var hash = System.Security.Cryptography.SHA256.HashData(data); - var getBits = System.BitConverter.ToUInt32(hash, 0); - return (int)(getBits >> (32 - hashBits)); - } - internal static int HashFor(string s) => HashFor(System.Text.Encoding.UTF8.GetBytes(s)); - - public bool MayExist(string key){ - var hash = HashFor(key); - return buckets[hash]; - } - public void MarkExists(string key){ - var hash = HashFor(key); - buckets[hash] = true; - } - - public void MarkExists(byte[] data){ - var hash = HashFor(data); - buckets[hash] = true; - } - public bool MayExist(byte[] data){ - var hash = HashFor(data); - return buckets[hash]; - } - - - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/DiagnosticsPage.cs b/src/Imageflow.Server/DiagnosticsPage.cs deleted file mode 100644 index 19b6501c..00000000 --- a/src/Imageflow.Server/DiagnosticsPage.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Reflection; -using System.Security; -using System.Text; -using System.Threading.Tasks; -using Imazen.Common.Extensibility.ClassicDiskCache; -using Imazen.Common.Extensibility.StreamCache; -using Imazen.Common.Issues; -using Imazen.Common.Storage; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace Imageflow.Server -{ - internal class DiagnosticsPage - { - private readonly IWebHostEnvironment env; - private readonly IStreamCache streamCache; - private readonly IClassicDiskCache diskCache; - private readonly IList blobProviders; - private readonly ImageflowMiddlewareOptions options; - internal DiagnosticsPage(ImageflowMiddlewareOptions options,IWebHostEnvironment env, ILogger logger, - IStreamCache streamCache, - IClassicDiskCache diskCache, IList blobProviders) - { - this.options = options; - this.env = env; - this.streamCache = streamCache; - this.diskCache = diskCache; - this.blobProviders = blobProviders; - } - - public static bool MatchesPath(string path) => "/imageflow.debug".Equals(path, StringComparison.Ordinal); - - private static bool IsLocalRequest(HttpContext context) => - context.Connection.RemoteIpAddress.Equals(context.Connection.LocalIpAddress) || - IPAddress.IsLoopback(context.Connection.RemoteIpAddress); - - public async Task Invoke(HttpContext context) - { - var providedPassword = context.Request.Query["password"].ToString(); - var passwordMatch = !string.IsNullOrEmpty(options.DiagnosticsPassword) - && options.DiagnosticsPassword == providedPassword; - - string s; - if (passwordMatch || - options.DiagnosticsAccess == AccessDiagnosticsFrom.AnyHost || - (options.DiagnosticsAccess == AccessDiagnosticsFrom.LocalHost && IsLocalRequest(context))){ - s = await GeneratePage(context); - context.Response.StatusCode = 200; - } - else - { - s = - "You can configure access to this page via the imageflow.toml [diagnostics] section, or in C# via ImageflowMiddlewareOptions.SetDiagnosticsPageAccess(allowLocalhost, password)\r\n\r\n"; - if (options.DiagnosticsAccess == AccessDiagnosticsFrom.LocalHost) - { - s += "You can access this page from the localhost\r\n\r\n"; - } - else - { - s += "Access to this page from localhost is disabled\r\n\r\n"; - } - - if (!string.IsNullOrEmpty(options.DiagnosticsPassword)) - { - s += "You can access this page by adding ?password=[insert password] to the URL.\r\n\r\n"; - } - else - { - s += "You can set a password via imageflow.toml [diagnostics] allow_with_password='' or in C# with SetDiagnosticsPageAccess to access this page remotely.\r\n\r\n"; - } - context.Response.StatusCode = 401; //Unauthorized - } - - context.Response.Headers[HeaderNames.CacheControl] = "no-store"; - context.Response.ContentType = "text/plain; charset=utf-8"; - context.Response.Headers.Add("X-Robots-Tag", "none"); - var bytes = Encoding.UTF8.GetBytes(s); - await context.Response.Body.WriteAsync(bytes, 0, bytes.Length); - } - - private Task GeneratePage(HttpContext context) - { - var s = new StringBuilder(8096); - var now = DateTime.UtcNow.ToString(NumberFormatInfo.InvariantInfo); - s.AppendLine($"Diagnostics for Imageflow at {context?.Request.Host.Value} generated {now} UTC"); - - try - { - using var job = new Bindings.JobContext(); - var version = job.GetVersionInfo(); - s.AppendLine($"libimageflow {version.LongVersionString}"); - } - catch (Exception e) - { - s.AppendLine($"Failed to get libimageflow version: {e.Message}"); - } - - s.AppendLine("Please remember to provide this page when contacting support."); - var issues = diskCache?.GetIssues().ToList() ?? new List(); - issues.AddRange(streamCache?.GetIssues() ?? new List()); - s.AppendLine($"{issues.Count} issues detected:\r\n"); - foreach (var i in issues.OrderBy(i => i?.Severity)) - s.AppendLine($"{i?.Source}({i?.Severity}):\t{i?.Summary}\n\t\t\t{i?.Details?.Replace("\n", "\r\n\t\t\t")}\n"); - - - s.AppendLine(options.Licensing.Result.ProvidePublicLicensesPage()); - - - s.AppendLine("\nInstalled Plugins"); - - if (streamCache != null) s.AppendLine(this.streamCache.GetType().FullName); - if (diskCache != null) s.AppendLine(this.diskCache.GetType().FullName); - foreach (var provider in blobProviders) - { - s.AppendLine(provider.GetType().FullName); - } - - - s.AppendLine("\nAccepted querystring keys:\n"); - s.AppendLine(string.Join(", ", PathHelpers.SupportedQuerystringKeys)); - - s.AppendLine("\nAccepted file extensions:\n"); - s.AppendLine(string.Join(", ", PathHelpers.AcceptedImageExtensions)); - - s.AppendLine("\nEnvironment information:\n"); - s.AppendLine( - $"Running on {Environment.OSVersion} and CLR {Environment.Version} and .NET Core {GetNetCoreVersion()}"); - - try { - var wow64 = Environment.GetEnvironmentVariable("PROCESSOR_ARCHITEW6432"); - var arch = Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE"); - s.AppendLine("OS arch: " + arch + (string.IsNullOrEmpty(wow64) - ? "" - : " !! Warning, running as 32-bit on a 64-bit OS(" + wow64 + - "). This will limit ram usage !!")); - } catch (SecurityException) { - s.AppendLine( - "Failed to detect operating system architecture - security restrictions prevent reading environment variables"); - } - //Get loaded assemblies for later use - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - - //List loaded assemblies, and also detect plugin assemblies that are not being used. - s.AppendLine("\nLoaded assemblies:\n"); - - foreach (var a in assemblies) { - var assemblyName = new AssemblyName(a.FullName); - var line = ""; - var error = a.GetExceptionForReading(); - if (error != null) { - line += $"{assemblyName.Name,-40} Failed to read assembly attributes: {error.Message}"; - } else { - var version = $"{a.GetFileVersion()} ({assemblyName.Version})"; - var infoVersion = $"{a.GetInformationalVersion()}"; - line += $"{assemblyName.Name,-40} File: {version,-25} Informational: {infoVersion,-30}"; - } - - s.AppendLine(line); - } - - s.AppendLine( - "\n\nWhen fetching a remote license file (if you have one), the following information is sent via the querystring."); - foreach (var pair in options.Licensing.Result.GetReportPairs().GetInfo()) { - s.AppendFormat(" {0,32} {1}\n", pair.Key, pair.Value); - } - - - s.AppendLine(options.Licensing.Result.DisplayLastFetchUrl()); - - return Task.FromResult(s.ToString()); - } - - - private static string GetNetCoreVersion() - { - return System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; - } - - - } - - internal static class AssemblyExtensions - { - private static T GetFirstAttribute(this ICustomAttributeProvider a) - { - try - { - var attrs = a.GetCustomAttributes(typeof(T), false); - if (attrs.Length > 0) return (T)attrs[0]; - } - catch(FileNotFoundException) { - //Missing dependencies - } - // ReSharper disable once EmptyGeneralCatchClause - catch (Exception) { } - return default; - } - - public static Exception GetExceptionForReading(this Assembly a) - { - try { - var unused = a.GetCustomAttributes(typeof(T), false); - } catch (Exception e) { - return e; - } - return null; - } - - public static string GetInformationalVersion(this Assembly a) - { - return GetFirstAttribute(a)?.InformationalVersion; - } - public static string GetFileVersion(this Assembly a) - { - return GetFirstAttribute(a)?.Version; - } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/ExtensionlessPath.cs b/src/Imageflow.Server/ExtensionlessPath.cs deleted file mode 100644 index f0ff483a..00000000 --- a/src/Imageflow.Server/ExtensionlessPath.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace Imageflow.Server -{ - internal struct ExtensionlessPath - { - internal string Prefix { get; set; } - - internal StringComparison PrefixComparison { get; set; } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/GlobalInfoProvider.cs b/src/Imageflow.Server/GlobalInfoProvider.cs deleted file mode 100644 index eb57a04e..00000000 --- a/src/Imageflow.Server/GlobalInfoProvider.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Imazen.Common.Extensibility.ClassicDiskCache; -using Imazen.Common.Extensibility.StreamCache; -using Imazen.Common.Instrumentation.Support; -using Imazen.Common.Instrumentation.Support.InfoAccumulators; -using Imazen.Common.Storage; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.Logging; - -namespace Imageflow.Server -{ - internal class GlobalInfoProvider: IInfoProvider - { - private readonly IWebHostEnvironment env; - private readonly IStreamCache streamCache; - private readonly ImageflowMiddlewareOptions options; - private readonly List pluginNames; - private readonly List infoProviders; - public GlobalInfoProvider(ImageflowMiddlewareOptions options,IWebHostEnvironment env, ILogger logger, - IStreamCache streamCache, - IClassicDiskCache diskCache, IList blobProviders) - { - this.env = env; - this.streamCache = streamCache; - this.options = options; - var plugins = new List(){logger, streamCache, diskCache}.Concat(blobProviders).ToList(); - infoProviders = plugins.OfType().ToList(); - - pluginNames = plugins - .Where(p => p != null) - .Select(p => - { - var t = p.GetType(); - if (t.Namespace != null && - (t.Namespace.StartsWith("Imazen") || - t.Namespace.StartsWith("Imageflow") || - t.Namespace.StartsWith("Microsoft.Extensions.Logging") || - t.Namespace.StartsWith("Microsoft.Extensions.Caching"))) - { - return t.Name; - } - else - { - return t.FullName; - } - }).ToList(); - - - - } - - private string iisVersion; - - internal void CopyHttpContextInfo(HttpContext context) - { - if (iisVersion == null) - { - var serverVars = context.Features.Get(); - iisVersion = serverVars?["SERVER_SOFTWARE"]; - } - - } - - private static string GetNetCoreVersion() - { - return System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; - } - - public void Add(IInfoAccumulator query) - { - var q = query.WithPrefix("proc_"); - if (iisVersion != null) - q.Add("iis", iisVersion); - - q.Add("default_commands", PathHelpers.SerializeCommandString(options.CommandDefaults)); - - var a = Assembly.GetAssembly(this.GetType()).GetInformationalVersion(); - if (a.LastIndexOf('+') >= 0) - { - q.Add("git_commit", a.Substring(a.LastIndexOf('+') + 1)); - } - q.Add("info_version", Assembly.GetAssembly(this.GetType()).GetInformationalVersion()); - q.Add("file_version", Assembly.GetAssembly(this.GetType()).GetFileVersion()); - - - if (env.ContentRootPath != null) - { - // ReSharper disable once StringLiteralTypo - q.Add("apppath_hash", Utilities.Sha256TruncatedBase64(env.ContentRootPath, 6)); - } - - query.Add("imageflow",1); - query.AddString("enabled_cache", options.ActiveCacheBackend.ToString()); - if (streamCache != null) query.AddString("stream_cache", streamCache.GetType().Name); - query.Add("map_web_root", options.MapWebRoot); - query.Add("use_presets_exclusively", options.UsePresetsExclusively); - query.Add("request_signing_default", options.RequestSignatureOptions?.DefaultRequirement.ToString() ?? "never"); - query.Add("default_cache_control", options.DefaultCacheControlString); - - foreach (var s in pluginNames) - { - query.Add("p",s); - } - foreach (var p in infoProviders) - { - p?.Add(query); - } - - } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/ImageData.cs b/src/Imageflow.Server/ImageData.cs deleted file mode 100644 index 5ea2666f..00000000 --- a/src/Imageflow.Server/ImageData.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace Imageflow.Server -{ - internal struct ImageData - { - public ArraySegment ResultBytes; - public string ContentType; - } -} diff --git a/src/Imageflow.Server/Imageflow.Server.csproj b/src/Imageflow.Server/Imageflow.Server.csproj index f5a92583..a5c67cc1 100644 --- a/src/Imageflow.Server/Imageflow.Server.csproj +++ b/src/Imageflow.Server/Imageflow.Server.csproj @@ -1,25 +1,34 @@  + - net6.0 + net6.0;net8.0 Imageflow.Server Imageflow .NET Server - Middleware for fetching, processing, and caching images on-demand. Imageflow.Server - Middleware for fetching, processing, and caching images on-demand. Commercial licenses available. - true + + true + true + true - + + + + + + + - runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -28,5 +37,6 @@ + diff --git a/src/Imageflow.Server/ImageflowMiddleware.cs b/src/Imageflow.Server/ImageflowMiddleware.cs index 61790efb..60f8ce5a 100644 --- a/src/Imageflow.Server/ImageflowMiddleware.cs +++ b/src/Imageflow.Server/ImageflowMiddleware.cs @@ -1,21 +1,21 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Imageflow.Bindings; using Imazen.Common.Extensibility.ClassicDiskCache; using Imazen.Common.Extensibility.StreamCache; using Imazen.Common.Instrumentation; using Imazen.Common.Instrumentation.Support.InfoAccumulators; using Imazen.Common.Licensing; using Imazen.Common.Storage; -using Microsoft.Net.Http.Headers; +using Imazen.Abstractions.BlobCache; +using Imageflow.Server.Internal; +using Imageflow.Server.LegacyOptions; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.DependencyInjection; +using Imazen.Abstractions.Logging; +using Imazen.Routing.Health; +using Imazen.Routing.Serving; +using Microsoft.Extensions.DependencyInjection; namespace Imageflow.Server { @@ -23,426 +23,82 @@ namespace Imageflow.Server public class ImageflowMiddleware { private readonly RequestDelegate next; - private readonly ILogger logger; - // ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable - private readonly IWebHostEnvironment env; - private readonly IClassicDiskCache diskCache; - private readonly IStreamCache streamCache; - private readonly BlobProvider blobProvider; - private readonly DiagnosticsPage diagnosticsPage; - private readonly LicensePage licensePage; private readonly ImageflowMiddlewareOptions options; private readonly GlobalInfoProvider globalInfoProvider; + private readonly IImageServer imageServer; public ImageflowMiddleware( RequestDelegate next, IWebHostEnvironment env, - IEnumerable> logger, + IServiceProvider serviceProvider, // You can request IEnumerable to get all of them. + IEnumerable loggerFactories, ILoggerFactory legacyLoggerFactory, + IEnumerable retainedLogStores, +#pragma warning disable CS0618 // Type or member is obsolete IEnumerable diskCaches, IEnumerable streamCaches, IEnumerable blobProviders, +#pragma warning restore CS0618 // Type or member is obsolete + IEnumerable blobWrapperProviders, + IEnumerable blobCaches, + IEnumerable blobCacheProviders, ImageflowMiddlewareOptions options) { + + var retainedLogStore = retainedLogStores.FirstOrDefault() ?? new ReLogStore(new ReLogStoreOptions()); + var loggerFactory = loggerFactories.FirstOrDefault() ?? new ReLoggerFactory(legacyLoggerFactory, retainedLogStore); + var logger = loggerFactory.CreateReLogger("ImageflowMiddleware"); + this.next = next; - options.Licensing ??= new Licensing(LicenseManagerSingleton.GetOrCreateSingleton( - "imageflow_", new[] {env.ContentRootPath, Path.GetTempPath()})); this.options = options; - this.env = env; - this.logger = logger.FirstOrDefault(); - diskCache = diskCaches.FirstOrDefault(); - var streamCacheArray = streamCaches.ToArray(); - if (streamCacheArray.Count() > 1) - { - throw new InvalidOperationException("Only 1 IStreamCache instance can be registered at a time"); - } - - streamCache = streamCacheArray.FirstOrDefault(); + var container = new ImageServerContainer(serviceProvider); + globalInfoProvider = new GlobalInfoProvider(container); + container.Register(globalInfoProvider); - var providers = blobProviders.ToList(); - var mappedPaths = options.MappedPaths.ToList(); - if (options.MapWebRoot) - { - if (this.env?.WebRootPath == null) - throw new InvalidOperationException("Cannot call MapWebRoot if env.WebRootPath is null"); - mappedPaths.Add(new PathMapping("/", this.env.WebRootPath)); - } + - //Determine the active cache backend - var streamCacheEnabled = streamCache != null && options.AllowCaching; - var diskCacheEnabled = this.diskCache != null && options.AllowDiskCaching; - - if (streamCacheEnabled) - options.ActiveCacheBackend = CacheBackend.StreamCache; - else if (diskCacheEnabled) - options.ActiveCacheBackend = CacheBackend.ClassicDiskCache; - else - options.ActiveCacheBackend = CacheBackend.NoCache; + container.Register(env); + container.Register(logger); + + + new MiddlewareOptionsServerBuilder(container, logger, retainedLogStore, options,env).PopulateServices(); - options.Licensing.Initialize(this.options); - - blobProvider = new BlobProvider(providers, mappedPaths); - diagnosticsPage = new DiagnosticsPage(options, env, this.logger, streamCache, this.diskCache, providers); - licensePage = new LicensePage(options); - globalInfoProvider = new GlobalInfoProvider(options, env, this.logger, streamCache, this.diskCache, providers); - options.Licensing.FireHeartbeat(); - GlobalPerf.Singleton.SetInfoProviders(new List(){globalInfoProvider}); + var startDiag = new StartupDiagnostics(container); + startDiag.LogIssues(logger); + startDiag.Validate(logger); + + imageServer = container.GetRequiredService>(); } - private string MakeWeakEtag(string cacheKey) => $"W/\"{cacheKey}\""; - // ReSharper disable once UnusedMember.Global + private bool hasPopulatedHttpContextExample = false; public async Task Invoke(HttpContext context) { - // For instrumentation - globalInfoProvider.CopyHttpContextInfo(context); - - var path = context.Request.Path; - - - // Delegate to the diagnostics page if it is requested - if (DiagnosticsPage.MatchesPath(path.Value)) - { - await diagnosticsPage.Invoke(context); - return; - } - // Delegate to licenses page if requested - if (licensePage.MatchesPath(path.Value)) - { - await licensePage.Invoke(context); - return; - } - - // Respond to /imageflow.ready - if ( "/imageflow.ready".Equals(path.Value, StringComparison.Ordinal)) - { - options.Licensing.FireHeartbeat(); - using (new JobContext()) - { - await StringResponseNoCache(context, 200, "Imageflow.Server is ready to accept requests."); - } - return; - } - - // Respond to /imageflow.health - if ( "/imageflow.health".Equals(path.Value, StringComparison.Ordinal)) - { - options.Licensing.FireHeartbeat(); - await StringResponseNoCache(context, 200, "Imageflow.Server is healthy."); - return; - } - - - // We only handle requests with an image extension or if we configured a path prefix for which to handle - // extension-less requests - if (!ImageJobInfo.ShouldHandleRequest(context, options)) + var queryWrapper = new QueryCollectionWrapper(context.Request.Query); + // We can optimize for the path where we know we won't be handling the request + if (!imageServer.MightHandleRequest(context.Request.Path.Value, queryWrapper, context)) { await next.Invoke(context); return; } + // If we likely will be handling it, we can allocate the shims + var requestAdapter = new RequestStreamAdapter(context.Request); + var responseAdapter = new ResponseStreamAdapter(context.Response); - options.Licensing.FireHeartbeat(); - - var imageJobInfo = new ImageJobInfo(context, options, blobProvider); - - if (!imageJobInfo.Authorized) - { - await NotAuthorized(context, imageJobInfo.AuthorizedMessage); - return; - } - - if (imageJobInfo.LicenseError) - { - if (options.EnforcementMethod == EnforceLicenseWith.Http422Error) - { - await StringResponseNoCache(context, 422, options.Licensing.InvalidLicenseMessage); - return; - } - if (options.EnforcementMethod == EnforceLicenseWith.Http402Error) - { - await StringResponseNoCache(context, 402, options.Licensing.InvalidLicenseMessage); - return; - } + //For instrumentation + if (!hasPopulatedHttpContextExample){ + hasPopulatedHttpContextExample = true; + globalInfoProvider.CopyHttpContextInfo(requestAdapter); } - - // If the file is definitely missing hand to the next middleware - // Remote providers will fail late rather than make 2 requests - if (!imageJobInfo.PrimaryBlobMayExist()) - { - await next.Invoke(context); - return; - } - - string cacheKey = null; - var cachingPath = imageJobInfo.NeedsCaching() ? options.ActiveCacheBackend : CacheBackend.NoCache; - if (cachingPath != CacheBackend.NoCache) - { - cacheKey = await imageJobInfo.GetFastCacheKey(); - - // W/"etag" should be used instead, since we might have to regenerate the result non-deterministically while a client is downloading it with If-Range - // If-None-Match is supposed to be weak always - var etagHeader = MakeWeakEtag(cacheKey); - if (context.Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var conditionalEtag) && etagHeader == conditionalEtag) - { - GlobalPerf.Singleton.IncrementCounter("etag_hit"); - context.Response.StatusCode = StatusCodes.Status304NotModified; - context.Response.ContentLength = 0; - context.Response.ContentType = null; - return; - } - GlobalPerf.Singleton.IncrementCounter("etag_miss"); - } - - try - { - switch (cachingPath) - { - case CacheBackend.ClassicDiskCache: - await ProcessWithDiskCache(context, cacheKey, imageJobInfo); - break; - case CacheBackend.NoCache: - await ProcessWithNoCache(context, imageJobInfo); - break; - case CacheBackend.StreamCache: - await ProcessWithStreamCache(context, cacheKey, imageJobInfo); - break; - default: - throw new ArgumentOutOfRangeException(); - } - - GlobalPerf.Singleton.IncrementCounter("middleware_ok"); - } - catch (BlobMissingException e) - { - await NotFound(context, e); - } - catch (Exception e) - { - var errorName = e.GetType().Name; - var errorCounter = "middleware_" + errorName; - GlobalPerf.Singleton.IncrementCounter(errorCounter); - GlobalPerf.Singleton.IncrementCounter("middleware_errors"); - throw; - } - finally - { - // Increment counter for type of file served - var imageExtension = PathHelpers.GetImageExtensionFromContentType(context.Response.ContentType); - if (imageExtension != null) - { - GlobalPerf.Singleton.IncrementCounter("module_response_ext_" + imageExtension); - } - } - } - - private async Task NotAuthorized(HttpContext context, string detail) - { - var s = "You are not authorized to access the given resource."; - if (!string.IsNullOrEmpty(detail)) + if (await imageServer.TryHandleRequestAsync(requestAdapter, responseAdapter, context, context.RequestAborted)) { - s += "\r\n" + detail; + return; // We handled it } - GlobalPerf.Singleton.IncrementCounter("http_403"); - await StringResponseNoCache(context, 403, s); - } - - private async Task NotFound(HttpContext context, BlobMissingException e) - { - GlobalPerf.Singleton.IncrementCounter("http_404"); - // We allow 404s to be cached, but not 403s or license errors - var s = "The specified resource does not exist.\r\n" + e.Message; - context.Response.StatusCode = 404; - context.Response.ContentType = "text/plain; charset=utf-8"; - var bytes = Encoding.UTF8.GetBytes(s); - context.Response.ContentLength = bytes.Length; - await context.Response.Body.WriteAsync(bytes, 0, bytes.Length); - } - private async Task StringResponseNoCache(HttpContext context, int statusCode, string contents) - { - context.Response.StatusCode = statusCode; - context.Response.ContentType = "text/plain; charset=utf-8"; - context.Response.Headers[HeaderNames.CacheControl] = "no-store"; - var bytes = Encoding.UTF8.GetBytes(contents); - context.Response.ContentLength = bytes.Length; - await context.Response.Body.WriteAsync(bytes, 0, bytes.Length); - } - - private async Task ProcessWithStreamCache(HttpContext context, string cacheKey, ImageJobInfo info) - { - var keyBytes = Encoding.UTF8.GetBytes(cacheKey); - var typeName = streamCache.GetType().Name; - var cacheResult = await streamCache.GetOrCreateBytes(keyBytes, async (cancellationToken) => - { - if (info.HasParams) - { - logger?.LogDebug("{CacheName} miss: Processing image {VirtualPath}?{Querystring}", typeName, info.FinalVirtualPath,info.ToString()); - var result = await info.ProcessUncached(); - if (result.ResultBytes.Array == null) - { - throw new InvalidOperationException("Image job returned zero bytes."); - } - return new StreamCacheInput(result.ContentType, result.ResultBytes); - } - - logger?.LogDebug("{CacheName} miss: Proxying image {VirtualPath}",typeName, info.FinalVirtualPath); - var bytes = await info.GetPrimaryBlobBytesAsync(); - return new StreamCacheInput(null, bytes); - - },CancellationToken.None,false); - - if (cacheResult.Status != null) - { - GlobalPerf.Singleton.IncrementCounter($"{typeName}_{cacheResult.Status}"); - } - if (cacheResult.Data != null) - { - await using (cacheResult.Data) - { - if (cacheResult.Data.Length < 1) - { - throw new InvalidOperationException($"{typeName} returned cache entry with zero bytes"); - } - SetCachingHeaders(context, MakeWeakEtag(cacheKey)); - await MagicBytes.ProxyToStream(cacheResult.Data, context.Response); - } - logger?.LogDebug("Serving from {CacheName} {VirtualPath}?{CommandString}", typeName, info.FinalVirtualPath, info.CommandString); - } - else - { - // TODO explore this failure path better - throw new NullReferenceException("Caching failed: " + cacheResult.Status); - } - } - - - private async Task ProcessWithDiskCache(HttpContext context, string cacheKey, ImageJobInfo info) - { - var cacheResult = await diskCache.GetOrCreate(cacheKey, info.EstimatedFileExtension, async (stream) => - { - if (info.HasParams) - { - logger?.LogInformation("DiskCache Miss: Processing image {VirtualPath}{QueryString}", info.FinalVirtualPath,info); - + await next.Invoke(context); - var result = await info.ProcessUncached(); - if (result.ResultBytes.Array == null) - { - throw new InvalidOperationException("Image job returned zero bytes."); - } - await stream.WriteAsync(result.ResultBytes, - CancellationToken.None); - await stream.FlushAsync(); - } - else - { - logger?.LogInformation("DiskCache Miss: Proxying image {VirtualPath}", info.FinalVirtualPath); - await info.CopyPrimaryBlobToAsync(stream); - } - }); - - if (cacheResult.Result == CacheQueryResult.Miss) - { - GlobalPerf.Singleton.IncrementCounter("diskcache_miss"); - } - else if (cacheResult.Result == CacheQueryResult.Hit) - { - GlobalPerf.Singleton.IncrementCounter("diskcache_hit"); - } - else if (cacheResult.Result == CacheQueryResult.Failed) - { - GlobalPerf.Singleton.IncrementCounter("diskcache_timeout"); - } - - // Note that using estimated file extension instead of parsing magic bytes will lead to incorrect content-type - // values when the source file has a mismatched extension. - var etagHeader = MakeWeakEtag(cacheKey); - if (cacheResult.Data != null) - { - if (cacheResult.Data.Length < 1) - { - throw new InvalidOperationException("DiskCache returned cache entry with zero bytes"); - } - SetCachingHeaders(context, etagHeader); - await MagicBytes.ProxyToStream(cacheResult.Data, context.Response); - } - else - { - logger?.LogInformation("Serving {0}?{1} from disk cache {2}", info.FinalVirtualPath, info.CommandString, cacheResult.RelativePath); - await ServeFileFromDisk(context, cacheResult.PhysicalPath, etagHeader); - } - } - - private async Task ServeFileFromDisk(HttpContext context, string path, string etagHeader) - { - await using var readStream = File.OpenRead(path); - if (readStream.Length < 1) - { - throw new InvalidOperationException("DiskCache file entry has zero bytes"); - } - SetCachingHeaders(context, etagHeader); - await MagicBytes.ProxyToStream(readStream, context.Response); - } - private async Task ProcessWithNoCache(HttpContext context, ImageJobInfo info) - { - // If we're not caching, we should always use the modified date from source blobs as part of the etag - var betterCacheKey = await info.GetExactCacheKey(); - // Still use weak since recompression is non-deterministic - - var etagHeader = MakeWeakEtag(betterCacheKey); - if (context.Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var conditionalEtag) && etagHeader == conditionalEtag) - { - GlobalPerf.Singleton.IncrementCounter("etag_hit"); - context.Response.StatusCode = StatusCodes.Status304NotModified; - context.Response.ContentLength = 0; - context.Response.ContentType = null; - return; - } - GlobalPerf.Singleton.IncrementCounter("etag_miss"); - if (info.HasParams) - { - logger?.LogInformation("Processing image {VirtualPath} with params {CommandString}", info.FinalVirtualPath, info.CommandString); - GlobalPerf.Singleton.IncrementCounter("nocache_processed"); - var imageData = await info.ProcessUncached(); - var imageBytes = imageData.ResultBytes; - var contentType = imageData.ContentType; - - // write to stream - context.Response.ContentType = contentType; - context.Response.ContentLength = imageBytes.Count; - SetCachingHeaders(context, etagHeader); - - if (imageBytes.Array == null) - { - throw new InvalidOperationException("Image job returned zero bytes."); - } - await context.Response.Body.WriteAsync(imageBytes); - } - else - { - logger?.LogInformation("Proxying image {VirtualPath} with params {CommandString}", info.FinalVirtualPath, info.CommandString); - GlobalPerf.Singleton.IncrementCounter("nocache_proxied"); - await using var sourceStream = (await info.GetPrimaryBlob()).OpenRead(); - SetCachingHeaders(context, etagHeader); - await MagicBytes.ProxyToStream(sourceStream, context.Response); - } - - - } - - /// - /// - /// - /// - /// Should include W/ - private void SetCachingHeaders(HttpContext context, string etagHeader) - { - context.Response.Headers[HeaderNames.ETag] = etagHeader; - if (options.DefaultCacheControlString != null) - context.Response.Headers[HeaderNames.CacheControl] = options.DefaultCacheControlString; } - } } diff --git a/src/Imageflow.Server/ImageflowMiddlewareExtensions.cs b/src/Imageflow.Server/ImageflowMiddlewareExtensions.cs index 45a29478..da3a07f9 100644 --- a/src/Imageflow.Server/ImageflowMiddlewareExtensions.cs +++ b/src/Imageflow.Server/ImageflowMiddlewareExtensions.cs @@ -1,13 +1,30 @@ -using Microsoft.AspNetCore.Builder; +using Imazen.Abstractions.Logging; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Imageflow.Server { public static class ImageflowMiddlewareExtensions { - + + public static IServiceCollection AddImageflowLoggingSupport(this IServiceCollection services, ReLogStoreOptions? logStorageOptions = null) + { + services.AddSingleton((container) => new ReLogStore(logStorageOptions ?? new ReLogStoreOptions())); + services.AddSingleton((container) => + new ReLoggerFactory(container.GetRequiredService(), container.GetRequiredService())); + return services; + } + + // ReSharper disable once UnusedMethodReturnValue.Global public static IApplicationBuilder UseImageflow(this IApplicationBuilder builder, ImageflowMiddlewareOptions options) { + if (builder.ApplicationServices.GetService() == null) + { + var services = builder.ApplicationServices as IServiceCollection; + services?.AddImageflowLoggingSupport(); + } return builder.UseMiddleware(options); } diff --git a/src/Imageflow.Server/ImageflowMiddlewareOptionsTemp.cs.txt b/src/Imageflow.Server/ImageflowMiddlewareOptionsTemp.cs.txt new file mode 100644 index 00000000..a188682d --- /dev/null +++ b/src/Imageflow.Server/ImageflowMiddlewareOptionsTemp.cs.txt @@ -0,0 +1,219 @@ +using System; +using System.Linq; +using Imageflow.Fluent; +using Microsoft.AspNetCore.Http; +using Compat = Imazen.Routing.Compatibility.ImageflowServer; + +namespace Imageflow.Server +{ + //TODO make [Obsolete("Use ImageflowRouteOptions instead")] + public class ImageflowMiddlewareOptionsTemp + : + Compat.ImageflowMiddlewareOptions + { + internal Licensing? Licensing { get; set; } + + /// + /// Use this to add default command values if they are missing. Does not affect image requests with no querystring. + /// Example: AddCommandDefault("down.colorspace", "srgb") reverts to ImageResizer's legacy behavior in scaling shadows and highlights. + /// + /// + /// + /// + /// + public ImageflowMiddlewareOptionsTemp AddCommandDefault(string key, string value) + { + if (CommandDefaults.ContainsKey(key)) throw new ArgumentOutOfRangeException(nameof(key), "A default has already been added for this key"); + CommandDefaults[key] = value; + return this; + } + + public ImageflowMiddlewareOptionsTemp AddPreset(PresetOptions preset) + { + if (Presets.ContainsKey(preset.Name)) throw new ArgumentOutOfRangeException(nameof(preset), "A preset by this name has already been added"); + Presets[preset.Name] = preset; + return this; + } + + public ImageflowMiddlewareOptionsTemp HandleExtensionlessRequestsUnder(string prefix, StringComparison prefixComparison = StringComparison.Ordinal) + { + AddHandleExtensionlessRequestsUnder(prefix, prefixComparison); + return this; + } + + /// + /// Control when and where the diagnostics page is accessible when no password is used + /// + /// + /// + public ImageflowMiddlewareOptionsTemp SetDiagnosticsPageAccess(AccessDiagnosticsFrom accessDiagnosticsFrom) + { + DiagnosticsAccess = (Compat.AccessDiagnosticsFrom)accessDiagnosticsFrom; + return this; + } + + /// + /// When set, the diagnostics page will be accessible form anywhere by adding ?password=[password] + /// + /// + /// + public ImageflowMiddlewareOptionsTemp SetDiagnosticsPagePassword(string password) + { + DiagnosticsPassword = password; + return this; + } + + /// + /// Use this if you are complying with the AGPL v3 and open-sourcing your project. + /// Provide the URL to your version control system or source code download page. + /// Use .SetLicenseKey() instead if you are not open-sourcing your project. + /// + /// Provide the URL to your version control + /// system or source code download page. + /// + public ImageflowMiddlewareOptionsTemp SetMyOpenSourceProjectUrl(string myOpenSourceProjectUrl) + { + MyOpenSourceProjectUrl = myOpenSourceProjectUrl; + return this; + } + + /// + /// If you do not call this, Imageflow.Server will watermark image requests with a red dot. + /// + /// If you are open-sourcing your project and complying with the AGPL v3, you can call + /// .SetMyOpenSourceProjectUrl() instead. + /// + /// + /// + /// + public ImageflowMiddlewareOptionsTemp SetLicenseKey(EnforceLicenseWith enforcementMethod, string licenseKey) + { + EnforcementMethod = (Compat.EnforceLicenseWith)enforcementMethod; + LicenseKey = licenseKey; + return this; + } + + public ImageflowMiddlewareOptionsTemp SetRequestSignatureOptions(RequestSignatureOptions options) + { + RequestSignatureOptions = options; + return this; + } + + public ImageflowMiddlewareOptionsTemp AddRewriteHandler(string pathPrefix, Action handler) + { + Rewrite.Add(new Compat.UrlHandler> + (pathPrefix, (e) => + { + handler(new UrlEventArgs(e)); + return true; + })); + return this; + } + public ImageflowMiddlewareOptionsTemp AddPreRewriteAuthorizationHandler(string pathPrefix, Func handler) + { + PreRewriteAuthorization.Add(new Compat.UrlHandler> + (pathPrefix, (e) => handler(new UrlEventArgs(e)))); + return this; + } + public ImageflowMiddlewareOptionsTemp AddPostRewriteAuthorizationHandler(string pathPrefix, Func handler) + { + PostRewriteAuthorization.Add(new Compat.UrlHandler> + (pathPrefix, (e) => handler(new UrlEventArgs(e)))); + return this; + } + + public ImageflowMiddlewareOptionsTemp AddWatermarkingHandler(string pathPrefix, Action handler) + { + Watermarking.Add(new Compat.UrlHandler>> + (pathPrefix, (e) => handler(new WatermarkingEventArgs(e)))); + return this; + } + + public ImageflowMiddlewareOptionsTemp SetMapWebRoot(bool value) + { + MapWebRoot = value; + return this; + } + + public ImageflowMiddlewareOptionsTemp SetJobSecurityOptions(SecurityOptions securityOptions) + { + JobSecurityOptions = securityOptions; + return this; + } + + /// + /// If true, query strings will be discarded except for their preset key/value. Query strings without a preset key will throw an error. + /// + /// + /// + public ImageflowMiddlewareOptionsTemp SetUsePresetsExclusively(bool value) + { + UsePresetsExclusively = value; + return this; + } + + + public ImageflowMiddlewareOptionsTemp MapPath(string virtualPath, string physicalPath) + => MapPath(virtualPath, physicalPath, false); + public ImageflowMiddlewareOptionsTemp MapPath(string virtualPath, string physicalPath, bool ignorePrefixCase) + { + mappedPaths.Add(new Compat.PathMapping(virtualPath, physicalPath, ignorePrefixCase)); + return this; + } + + public ImageflowMiddlewareOptionsTemp AddWatermark(NamedWatermark watermark) + { + if (namedWatermarks.Any(w => w.Name.Equals(watermark.Name, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"A watermark already exists by the name {watermark.Name}"); + } + namedWatermarks.Add(watermark); + return this; + } + + /// + /// Set to true to allow any registered IStreamCache plugin (such as Imageflow.Server.HybridCache) to be used. + /// + /// + /// + public ImageflowMiddlewareOptionsTemp SetAllowCaching(bool value) + { + this.AllowCaching = value; + return this; + } + + /// + /// Set to true to allow the legacy disk caching system (Imageflow.Server.DiskCache) to be used + /// + /// + /// + public ImageflowMiddlewareOptionsTemp SetAllowDiskCaching(bool value) + { + this.AllowDiskCaching = value; + return this; + } + + /// + /// Use "public, max-age=2592000" to cache for 30 days and cache on CDNs and proxies. + /// + /// + /// + /// + public ImageflowMiddlewareOptionsTemp SetDefaultCacheControlString(string cacheControlString) + { + DefaultCacheControlString = cacheControlString; + return this; + } + + /// + /// Set to true have the command defaults to all urls, not just ones containing a valid query command. + /// + /// + /// + public ImageflowMiddlewareOptionsTemp SetApplyDefaultCommandsToQuerylessUrls(bool value) + { + this.ApplyDefaultCommandsToQuerylessUrls = value; + return this; + } + } +} \ No newline at end of file diff --git a/src/Imageflow.Server/Internal/GlobalInfoProvider.cs b/src/Imageflow.Server/Internal/GlobalInfoProvider.cs new file mode 100644 index 00000000..2d9e455b --- /dev/null +++ b/src/Imageflow.Server/Internal/GlobalInfoProvider.cs @@ -0,0 +1,98 @@ +using System.Reflection; +using Imazen.Abstractions.DependencyInjection; +using Imazen.Common.Helpers; +using Imazen.Common.Instrumentation.Support; +using Imazen.Common.Instrumentation.Support.InfoAccumulators; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Serving; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Imageflow.Server +{ + internal class GlobalInfoProvider(IImageServerContainer serviceProvider): IInfoProvider + { + + private string? iisVersion; + internal void CopyHttpContextInfo(T request) where T : IHttpRequestStreamAdapter + { + iisVersion ??= request.TryGetServerVariable("SERVER_SOFTWARE"); + } + + private static string GetNetCoreVersion() + { + return System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; + } + + public void Add(IInfoAccumulator query) + { + var env = serviceProvider.GetService(); + + var everything = serviceProvider.GetInstanceOfEverythingLocal().ToList(); + var registeredObjectNames = everything + .Select(p => + { + var t = p.GetType(); + if (t.Namespace != null && + (t.Namespace.StartsWith("Imazen") || + t.Namespace.StartsWith("Imageflow") || + t.Namespace.StartsWith("Microsoft.Extensions.Logging") || + t.Namespace.StartsWith("Microsoft.Extensions.Caching"))) + { + return t.Name; + } + else + { + return t.FullName; + } + }).ToList(); + + var q = query.WithPrefix("proc_"); + if (iisVersion != null) + q.Add("iis", iisVersion); + + + var thisAssembly = Assembly.GetAssembly(this.GetType()); + if (thisAssembly != null) + { + string? gitCommit = Imazen.Routing.Helpers.AssemblyHelpers.GetCommit(thisAssembly); + var a = thisAssembly.GetInformationalVersion(); + gitCommit ??= a?.LastIndexOf('+') >= 0 ? a?[(a.LastIndexOf('+') + 1)..] : null; + + q.Add("git_commit", gitCommit); + q.Add("info_version", thisAssembly.GetInformationalVersion()); + q.Add("file_version", thisAssembly.GetFileVersion()); + } + + + if (env?.ContentRootPath != null) + { + // ReSharper disable once StringLiteralTypo + q.Add("apppath_hash", Sha256TruncatedBase64(env.ContentRootPath, 6)); + } + + query.Add("imageflow",1); + // query.AddString("enabled_cache", options.ActiveCacheBackend.ToString()); + // if (streamCache != null) query.AddString("stream_cache", streamCache.GetType().Name); + + + foreach (var s in registeredObjectNames) + { + query.Add("p",s); + } + foreach (var p in everything.OfType()) + { + if (p != this) + p?.Add(query); + } + + } + + private static string Sha256TruncatedBase64(string input, int bytes) + { + var hash = System.Security.Cryptography.SHA256.Create().ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); + return EncodingUtils.ToBase64U(hash.Take(bytes).ToArray()); + } + } +} \ No newline at end of file diff --git a/src/Imageflow.Server/Internal/MiddlewareOptionsServerBuilder.cs b/src/Imageflow.Server/Internal/MiddlewareOptionsServerBuilder.cs new file mode 100644 index 00000000..98fdc209 --- /dev/null +++ b/src/Imageflow.Server/Internal/MiddlewareOptionsServerBuilder.cs @@ -0,0 +1,276 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text; +using Imageflow.Bindings; +using Imageflow.Server.Internal; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.DependencyInjection; +using Imazen.Abstractions.Logging; +using Imazen.Common.Storage; +using Imazen.Routing.Engine; +using Imazen.Routing.Health; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Layers; +using Imazen.Routing.Promises.Pipelines; +using Imazen.Routing.Promises.Pipelines.Watermarking; +using Imazen.Routing.Requests; +using Imazen.Routing.Serving; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Imageflow.Server.LegacyOptions; + +internal class MiddlewareOptionsServerBuilder( + ImageServerContainer serverContainer, + IReLogger logger, + IReLogStore logStore, + ImageflowMiddlewareOptions options, + IWebHostEnvironment env) +{ + + private DiagnosticsPage diagnosticsPage = null!; + public void PopulateServices() + { + + var mappedPaths = options.MappedPaths.Cast().ToList(); + if (options.MapWebRoot) + { + if (env?.WebRootPath == null) + throw new InvalidOperationException("Cannot call MapWebRoot if env.WebRootPath is null"); + mappedPaths.Add(new PathMapping("/", env.WebRootPath)); + } + + serverContainer.Register(logger); + serverContainer.Register(logStore); + serverContainer.Register(env); + serverContainer.Register(options); + serverContainer.CopyFromOuter(); + serverContainer.CopyFromOuter(); +#pragma warning disable CS0618 // Type or member is obsolete + serverContainer.CopyFromOuter(); +#pragma warning restore CS0618 // Type or member is obsolete + serverContainer.CopyFromOuter(); + var perfTracker = new NullPerformanceTracker(); + serverContainer.Register(perfTracker); + + + var licensingOptions = new LicenseOptions + { + LicenseKey = options.LicenseKey, + MyOpenSourceProjectUrl = options.MyOpenSourceProjectUrl, + KeyPrefix = "imageflow_", + CandidateCacheFolders = new[] + { + env.ContentRootPath, + Path.GetTempPath() + }, + EnforcementMethod = Imazen.Routing.Layers.EnforceLicenseWith.RedDotWatermark, + + }; + serverContainer.Register(licensingOptions); + + var diagPageOptions = new DiagnosticsPageOptions( + options.DiagnosticsPassword, + (Imazen.Routing.Layers.DiagnosticsPageOptions.AccessDiagnosticsFrom)options.DiagnosticsAccess); + serverContainer.Register(diagPageOptions); + + diagnosticsPage = new DiagnosticsPage(serverContainer, logger, logStore,diagPageOptions); + + // Do watermark settings mappings + WatermarkingLogicOptions? watermarkingLogicOptions = null; + + + watermarkingLogicOptions = new WatermarkingLogicOptions( + (name) => + { + var match = options.NamedWatermarks.FirstOrDefault(a => + name.Equals(a.Name, StringComparison.OrdinalIgnoreCase)); + if (match == null) return null; + return new Imazen.Routing.Promises.Pipelines.Watermarking.WatermarkWithPath( + match.Name, + match.VirtualPath, + match.Watermark); + }, + + (IRequestSnapshot request, IList? list) => + { + if (options.Watermarking.Count == 0) return list; + var startingList = list?.Select(NamedWatermark.From).ToList() ?? []; + + var args = new WatermarkingEventArgs( + request.OriginatingRequest?.GetHttpContextUnreliable(), + request.Path, request.QueryString?.ToStringDictionary() ?? new Dictionary(), + startingList); + foreach (var handler in options.Watermarking) + { + handler.Handler(args); + } + // We're ignoring any changes to the query or path. + list = args.AppliedWatermarks.Select(WatermarkWithPath.FromIWatermark).ToList(); + return list; + }); + serverContainer.Register(watermarkingLogicOptions); + + var routingBuilder = new RoutingBuilder(); + var router = CreateRoutes(routingBuilder,mappedPaths, options.ExtensionlessPaths); + + var routingEngine = router.Build(logger); + + serverContainer.Register(routingEngine); + + + //TODO: Add a way to get the current ILicenseChecker + var imageServer = new ImageServer( + serverContainer, + licensingOptions, + routingEngine, perfTracker, logger); + + serverContainer.Register>(imageServer); + + // Log any issues with the startup configuration + new StartupDiagnostics(serverContainer).LogIssues(logger); + } + private PathPrefixHandler> WrapUrlEventArgs(string pathPrefix, + Func handler, bool readOnly) + { + return new PathPrefixHandler>(pathPrefix, (args) => + { + var httpContext = args.Request.OriginatingRequest?.GetHttpContextUnreliable(); + var dict = args.Request.MutableQueryString.ToStringDictionary(); + var e = new UrlEventArgs(httpContext, args.VirtualPath, dict); + var result = handler(e); + // We discard any changes to the query string or path. + if (readOnly) + return result; + args.Request.MutablePath = e.VirtualPath; + // Parse StringValues into a dictionary + args.Request.MutableQueryString = + e.Query.ToStringValuesDictionary(); + + return result; + }); + } + private RoutingBuilder CreateRoutes(RoutingBuilder builder, IReadOnlyCollection mappedPaths, + List optionsExtensionlessPaths) + { + var precondition = builder.CreatePreconditionToRequireImageExtensionOrExtensionlessPathPrefixes(optionsExtensionlessPaths); + + builder.SetGlobalPreconditions(precondition); + + // signature layer + var signatureOptions = options.RequestSignatureOptions; + if (signatureOptions is { IsEmpty: false }) + { + var newOpts = new Imazen.Routing.Layers.RequestSignatureOptions( + (Imazen.Routing.Layers.SignatureRequired)signatureOptions.DefaultRequirement, signatureOptions.DefaultSigningKeys) + .AddAllPrefixes(signatureOptions.Prefixes); + builder.AddLayer(new SignatureVerificationLayer(new SignatureVerificationLayerOptions(newOpts))); + } + + //GlobalPerf.Singleton.PreRewriteQuery(request.GetQuery().Keys); + + // MutableRequestEventLayer (PreRewriteAuthorization), use lambdas to inject Context, and possibly also copy/restore dictionary. + if (options.PreRewriteAuthorization.Count > 0) + { + builder.AddLayer(new MutableRequestEventLayer("PreRewriteAuthorization", + options.PreRewriteAuthorization.Select( + h => WrapUrlEventArgs(h.PathPrefix, h.Handler, true)).ToList())); + } + + // Preset expansion layer + if (options.Presets.Count > 0) + { + builder.AddLayer(new PresetsLayer(new PresetsLayerOptions() + { + Presets = options.Presets.Values + .Select(a => new + Imazen.Routing.Layers.PresetOptions(a.Name, (Imazen.Routing.Layers.PresetPriority)a.Priority, a.Pairs)) + .ToDictionary(a => a.Name, a => a), + UsePresetsExclusively = options.UsePresetsExclusively, + })); + } + + // MutableRequestEventLayer (Rewrites), use lambdas to inject Context, and possibly also copy/restore dictionary. + if (options.Rewrite.Count > 0) + { + builder.AddLayer(new MutableRequestEventLayer("Rewrites", options.Rewrite.Select( + h => WrapUrlEventArgs(h.PathPrefix, (urlArgs) => + { + h.Handler(urlArgs); return true; + }, false)).ToList())); + } + + // Apply command defaults + // TODO: only to already processing images? + if (options.CommandDefaults.Count > 0) + { + builder.AddLayer(new CommandDefaultsLayer(new CommandDefaultsLayerOptions() + { + CommandDefaults = options.CommandDefaults, + })); + } + + // MutableRequestEventLayer (PostRewriteAuthorization), use lambdas to inject Context, and possibly also copy/restore dictionary. + if (options.PostRewriteAuthorization.Count > 0) + { + builder.AddLayer(new MutableRequestEventLayer("PostRewriteAuthorization", + options.PostRewriteAuthorization.Select( + h => WrapUrlEventArgs(h.PathPrefix, h.Handler, true)).ToList())); + } + + //TODO: Add a layer that can be used to set the cache key basis + //builder.AddLayer(new LicensingLayer(options.Licensing, options.EnforcementMethod)); + + + if (mappedPaths.Count > 0) + { + builder.AddLayer(new LocalFilesLayer(mappedPaths.Select(a => + (IPathMapping)new PathMapping(a.VirtualPath, a.PhysicalPath, a.IgnorePrefixCase)).ToList())); + } + +#pragma warning disable CS0618 // Type or member is obsolete + var blobProviders = serverContainer.GetService>()?.ToList(); +#pragma warning restore CS0618 // Type or member is obsolete + var blobWrapperProviders = serverContainer.GetService>()?.ToList(); + if (blobProviders?.Count > 0 || blobWrapperProviders?.Count > 0) + { + builder.AddLayer(new BlobProvidersLayer(blobProviders, blobWrapperProviders)); + } + + builder.AddGlobalLayer(diagnosticsPage); + + // We don't want signature requirements and the like applying to these endpoints. + // Media delivery endpoints should be a separate thing... + + builder.AddGlobalEndpoint(Conditions.HasPathSuffix("/imageflow.ready"), + (_) => + { + using (new JobContext()) + { + return SmallHttpResponse.NoStore(200, "Imageflow.Server is ready to accept requests."); + } + }); + + builder.AddGlobalEndpoint(Conditions.HasPathSuffix("/imageflow.health"), + (_) => + { + return SmallHttpResponse.NoStore(200, "Imageflow.Server is healthy."); + }); + + builder.AddGlobalEndpoint(Conditions.HasPathSuffix("/imageflow.license"), + (req) => + { + var s = new StringBuilder(8096); + var now = DateTime.UtcNow.ToString(NumberFormatInfo.InvariantInfo); + s.AppendLine($"License page for Imageflow at {req.OriginatingRequest?.GetHost().Value} generated {now} UTC"); + var licenser = serverContainer.GetRequiredService(); + s.Append(licenser.GetLicensePageContents()); + return SmallHttpResponse.NoStoreNoRobots((200, s.ToString())); + }); + + + return builder; + } +} \ No newline at end of file diff --git a/src/Imageflow.Server/Internal/QueryCollectionWrapper.cs b/src/Imageflow.Server/Internal/QueryCollectionWrapper.cs new file mode 100644 index 00000000..443ceafa --- /dev/null +++ b/src/Imageflow.Server/Internal/QueryCollectionWrapper.cs @@ -0,0 +1,47 @@ +using System.Collections; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Serving; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Imageflow.Server.Internal; + + +public readonly struct QueryCollectionWrapper : IReadOnlyQueryWrapper +{ + public QueryCollectionWrapper(IQueryCollection collection) + { + c = collection; + } + + private readonly IQueryCollection c; + + public bool TryGetValue(string key, out string? value) + { + if (c.TryGetValue(key, out var values)) + { + value = values.ToString(); + return true; + } + value = null; + return false; + } + + public bool TryGetValue(string key, out StringValues value) => c.TryGetValue(key, out value); + + public bool ContainsKey(string key) => c.ContainsKey(key); + + public StringValues this[string key] => c[key]; + + public IEnumerable Keys => c.Keys; + + + public int Count => c.Count; + + public IEnumerator> GetEnumerator() => c.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + + + \ No newline at end of file diff --git a/src/Imageflow.Server/Internal/RequestStreamAdapter.cs b/src/Imageflow.Server/Internal/RequestStreamAdapter.cs new file mode 100644 index 00000000..2685f22e --- /dev/null +++ b/src/Imageflow.Server/Internal/RequestStreamAdapter.cs @@ -0,0 +1,79 @@ +using System.IO.Pipelines; +using System.Net; +using Imazen.Abstractions.HttpStrings; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Serving; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Imageflow.Server.Internal; + +internal readonly record struct RequestStreamAdapter(HttpRequest Request) : IHttpRequestStreamAdapter +{ + + public UrlPathString GetPath() => new UrlPathString(Request.Path.Value); + + public Uri GetUri() + { + return new Uri(Request.GetEncodedUrl()); + } + + public string? TryGetServerVariable(string key) + { + return Request.HttpContext.Features.Get()?[key]; + } + + public UrlPathString GetPathBase() => new UrlPathString(Request.PathBase.Value); + + public IEnumerable>? GetCookiePairs() => Request.Cookies; + + public IDictionary GetHeaderPairs() => Request.Headers; + + public bool TryGetHeader(string key, out StringValues value) + { + return Request.Headers.TryGetValue(key, out value); + } + public UrlQueryString GetQueryString() => new UrlQueryString(Request.QueryString.Value); + public UrlHostString GetHost() => new UrlHostString(Request.Host.Value); + public IReadOnlyQueryWrapper GetQuery() => new QueryCollectionWrapper(Request.Query); + public bool TryGetQueryValues(string key, out StringValues value) + { + return Request.Query.TryGetValue(key, out value); + } + + public T? GetHttpContextUnreliable() where T : class + { + return Request.HttpContext as T; + } + + public string? ContentType => Request.ContentType; + public long? ContentLength => Request.ContentLength; + public string Protocol => Request.Protocol; + public string Scheme => Request.Scheme; + public string Method => Request.Method; + public bool IsHttps => Request.IsHttps; + public bool SupportsStream => true; + + public Stream GetBodyStream() + { + return Request.Body; + } + + public IPAddress? GetRemoteIpAddress() + { + return Request.HttpContext.Connection.RemoteIpAddress; + } + + public IPAddress? GetLocalIpAddress() + { + return Request.HttpContext.Connection.LocalIpAddress; + } + + public bool SupportsPipelines => true; + public PipeReader GetBodyPipeReader() + { + return Request.BodyReader; + } +} \ No newline at end of file diff --git a/src/Imageflow.Server/Internal/ResponseStreamAdapter.cs b/src/Imageflow.Server/Internal/ResponseStreamAdapter.cs new file mode 100644 index 00000000..09ba0766 --- /dev/null +++ b/src/Imageflow.Server/Internal/ResponseStreamAdapter.cs @@ -0,0 +1,53 @@ +using System.Buffers; +using System.IO.Pipelines; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Serving; +using Microsoft.AspNetCore.Http; + +namespace Imageflow.Server.Internal; + +internal readonly record struct ResponseStreamAdapter(HttpResponse Response) : IHttpResponseStreamAdapter +{ + public void SetHeader(string name, string value) + { + Response.Headers[name] = value; + } + + public void SetStatusCode(int statusCode) + { + Response.StatusCode = statusCode; + } + + public int StatusCode => Response.StatusCode; + + public void SetContentType(string contentType) + { + Response.ContentType = contentType; + } + + public string? ContentType => Response.ContentType; + + public void SetContentLength(long contentLength) + { + Response.ContentLength = contentLength; + } + + public bool SupportsStream => true; + + public Stream GetBodyWriteStream() + { + return Response.Body; + } + + public bool SupportsPipelines => true; + public PipeWriter GetBodyPipeWriter() + { + return Response.BodyWriter; + } + + public bool SupportsBufferWriter => true; + public IBufferWriter GetBodyBufferWriter() + { + return Response.BodyWriter; + } +} \ No newline at end of file diff --git a/src/Imageflow.Server/Internal/VisibleToTests.cs b/src/Imageflow.Server/Internal/VisibleToTests.cs new file mode 100644 index 00000000..d97625f8 --- /dev/null +++ b/src/Imageflow.Server/Internal/VisibleToTests.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Imageflow.Server.Tests")] +[assembly: InternalsVisibleTo("Imageflow.Server.Storage.AzureBlob")] +[assembly: InternalsVisibleTo("Imageflow.Server.Storage.S3")] \ No newline at end of file diff --git a/src/Imageflow.Server/AccessDiagnosticsFrom.cs b/src/Imageflow.Server/LegacyOptions/AccessDiagnosticsFrom.cs similarity index 100% rename from src/Imageflow.Server/AccessDiagnosticsFrom.cs rename to src/Imageflow.Server/LegacyOptions/AccessDiagnosticsFrom.cs diff --git a/src/Imageflow.Server/EnforceLicenseWith.cs b/src/Imageflow.Server/LegacyOptions/EnforceLicenseWith.cs similarity index 100% rename from src/Imageflow.Server/EnforceLicenseWith.cs rename to src/Imageflow.Server/LegacyOptions/EnforceLicenseWith.cs diff --git a/src/Imageflow.Server/LegacyOptions/Imageflow.Server.csproj b/src/Imageflow.Server/LegacyOptions/Imageflow.Server.csproj new file mode 100644 index 00000000..ece554c9 --- /dev/null +++ b/src/Imageflow.Server/LegacyOptions/Imageflow.Server.csproj @@ -0,0 +1,25 @@ + + + + net6.0 + Imageflow.Server + Imageflow .NET Server - Middleware for fetching, processing, and caching images on-demand. + Imageflow.Server - Middleware for fetching, processing, and caching images on-demand. Commercial licenses available. + true + + + + + + + + + + + + + + + + + diff --git a/src/Imageflow.Server/ImageflowMiddlewareOptions.cs b/src/Imageflow.Server/LegacyOptions/ImageflowMiddlewareOptions.cs similarity index 78% rename from src/Imageflow.Server/ImageflowMiddlewareOptions.cs rename to src/Imageflow.Server/LegacyOptions/ImageflowMiddlewareOptions.cs index c8fb7420..f43578f8 100644 --- a/src/Imageflow.Server/ImageflowMiddlewareOptions.cs +++ b/src/Imageflow.Server/LegacyOptions/ImageflowMiddlewareOptions.cs @@ -1,18 +1,20 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Imageflow.Fluent; +using Imazen.Common.Instrumentation.Support.InfoAccumulators; +using Imazen.Routing.Layers; namespace Imageflow.Server { - public class ImageflowMiddlewareOptions + public class ImageflowMiddlewareOptions: IInfoProvider { - internal string LicenseKey { get; set; } + internal record struct ExtensionlessPath(string StringToCompare, StringComparison StringComparison) + : IStringAndComparison; + + internal string? LicenseKey { get; set; } internal EnforceLicenseWith EnforcementMethod { get; set; } = EnforceLicenseWith.Http422Error; - internal Licensing Licensing { get; set; } + //internal Licensing? Licensing { get; set; } - public string MyOpenSourceProjectUrl { get; set; } = "https://i-need-a-license.com"; + public string? MyOpenSourceProjectUrl { get; set; } = "https://i-need-a-license.com"; /// /// Set to true to allow any registered IStreamCache plugin (such as Imageflow.Server.HybridCache) to be used. @@ -23,7 +25,8 @@ public class ImageflowMiddlewareOptions /// public bool AllowDiskCaching { get; set; } = true; - internal CacheBackend ActiveCacheBackend { get; set; } + // Removing + // internal CacheBackend ActiveCacheBackend { get; set; } private readonly List namedWatermarks = new List(); public IReadOnlyCollection NamedWatermarks => namedWatermarks; @@ -36,30 +39,30 @@ public class ImageflowMiddlewareOptions internal AccessDiagnosticsFrom DiagnosticsAccess { get; set; } = AccessDiagnosticsFrom.LocalHost; - internal string DiagnosticsPassword { get; set; } + internal string? DiagnosticsPassword { get; set; } public bool UsePresetsExclusively { get; set; } + + public string? DefaultCacheControlString { get; set; } = ""; - public string DefaultCacheControlString { get; set; } - - public RequestSignatureOptions RequestSignatureOptions { get; set; } - public SecurityOptions JobSecurityOptions { get; set; } + public RequestSignatureOptions RequestSignatureOptions { get; set; } = RequestSignatureOptions.Empty; + public SecurityOptions JobSecurityOptions { get; set; } = new(); - internal readonly List>> Rewrite = new List>>(); + internal readonly List>> Rewrite = []; - internal readonly List>> PreRewriteAuthorization = new List>>(); + internal readonly List>> PreRewriteAuthorization = []; - internal readonly List>> PostRewriteAuthorization = new List>>(); + internal readonly List>> PostRewriteAuthorization = []; - internal readonly List>> Watermarking = new List>>(); + internal readonly List>> Watermarking = []; - internal readonly Dictionary CommandDefaults = new Dictionary(StringComparer.OrdinalIgnoreCase); + internal readonly Dictionary CommandDefaults = new(StringComparer.OrdinalIgnoreCase); public bool ApplyDefaultCommandsToQuerylessUrls { get; set; } = false; - internal readonly Dictionary Presets = new Dictionary(StringComparer.OrdinalIgnoreCase); + internal readonly Dictionary Presets = new(StringComparer.OrdinalIgnoreCase); - internal readonly List ExtensionlessPaths = new List(); + internal readonly List ExtensionlessPaths = []; /// /// Use this to add default command values if they are missing. Does not affect image requests with no querystring. /// Example: AddCommandDefault("down.colorspace", "srgb") reverts to ImageResizer's legacy behavior in scaling shadows and highlights. @@ -84,7 +87,7 @@ public ImageflowMiddlewareOptions AddPreset(PresetOptions preset) public ImageflowMiddlewareOptions HandleExtensionlessRequestsUnder(string prefix, StringComparison prefixComparison = StringComparison.Ordinal) { - ExtensionlessPaths.Add(new ExtensionlessPath() { Prefix = prefix, PrefixComparison = prefixComparison}); + ExtensionlessPaths.Add(new ExtensionlessPath() { StringToCompare = prefix, StringComparison = prefixComparison}); return this; } @@ -148,23 +151,23 @@ public ImageflowMiddlewareOptions SetRequestSignatureOptions(RequestSignatureOpt public ImageflowMiddlewareOptions AddRewriteHandler(string pathPrefix, Action handler) { - Rewrite.Add(new UrlHandler>(pathPrefix, handler)); + Rewrite.Add(new PathPrefixHandler>(pathPrefix, handler)); return this; } public ImageflowMiddlewareOptions AddPreRewriteAuthorizationHandler(string pathPrefix, Func handler) { - PreRewriteAuthorization.Add(new UrlHandler>(pathPrefix, handler)); + PreRewriteAuthorization.Add(new PathPrefixHandler>(pathPrefix, handler)); return this; } public ImageflowMiddlewareOptions AddPostRewriteAuthorizationHandler(string pathPrefix, Func handler) { - PostRewriteAuthorization.Add(new UrlHandler>(pathPrefix, handler)); + PostRewriteAuthorization.Add(new PathPrefixHandler>(pathPrefix, handler)); return this; } public ImageflowMiddlewareOptions AddWatermarkingHandler(string pathPrefix, Action handler) { - Watermarking.Add(new UrlHandler>(pathPrefix, handler)); + Watermarking.Add(new PathPrefixHandler>(pathPrefix, handler)); return this; } @@ -202,7 +205,7 @@ public ImageflowMiddlewareOptions MapPath(string virtualPath, string physicalPat public ImageflowMiddlewareOptions AddWatermark(NamedWatermark watermark) { - if (namedWatermarks.Any(w => w.Name.Equals(watermark.Name, StringComparison.OrdinalIgnoreCase))) + if (namedWatermarks.Any(w => w.Name?.Equals(watermark.Name, StringComparison.OrdinalIgnoreCase) ?? false)) { throw new InvalidOperationException($"A watermark already exists by the name {watermark.Name}"); } @@ -238,7 +241,7 @@ public ImageflowMiddlewareOptions SetAllowDiskCaching(bool value) /// /// /// - public ImageflowMiddlewareOptions SetDefaultCacheControlString(string cacheControlString) + public ImageflowMiddlewareOptions SetDefaultCacheControlString(string? cacheControlString) { DefaultCacheControlString = cacheControlString; return this; @@ -254,5 +257,15 @@ public ImageflowMiddlewareOptions SetApplyDefaultCommandsToQuerylessUrls(bool va this.ApplyDefaultCommandsToQuerylessUrls = value; return this; } + + public void Add(IInfoAccumulator query) + { + query.Add("map_web_root", MapWebRoot); + query.Add("use_presets_exclusively", UsePresetsExclusively); + query.Add("request_signing_default", RequestSignatureOptions?.DefaultRequirement.ToString() ?? "never"); + query.Add("default_cache_control", DefaultCacheControlString ?? ""); + query.Add("default_commands", Imazen.Routing.Helpers.PathHelpers.SerializeCommandString(CommandDefaults)); + + } } } \ No newline at end of file diff --git a/src/Imageflow.Server/LegacyOptions/NamedWatermark.cs b/src/Imageflow.Server/LegacyOptions/NamedWatermark.cs new file mode 100644 index 00000000..23bdaf0f --- /dev/null +++ b/src/Imageflow.Server/LegacyOptions/NamedWatermark.cs @@ -0,0 +1,20 @@ +using Imageflow.Fluent; +using Imazen.Routing.Promises.Pipelines.Watermarking; + +namespace Imageflow.Server +{ + public class NamedWatermark(string? name, string virtualPath, WatermarkOptions watermark) + : IWatermark + { + public string? Name { get; } = name; + public string VirtualPath { get; } = virtualPath; + public WatermarkOptions Watermark { get; } = watermark; + + // convert from IWatermark to NamedWatermark + public static NamedWatermark From(IWatermark watermark) + { + return new NamedWatermark(watermark.Name, watermark.VirtualPath, watermark.Watermark); + } + + } +} \ No newline at end of file diff --git a/src/Imageflow.Server/PathMapping.cs b/src/Imageflow.Server/LegacyOptions/PathMapping.cs similarity index 56% rename from src/Imageflow.Server/PathMapping.cs rename to src/Imageflow.Server/LegacyOptions/PathMapping.cs index 6ddb8bec..25a505b8 100644 --- a/src/Imageflow.Server/PathMapping.cs +++ b/src/Imageflow.Server/LegacyOptions/PathMapping.cs @@ -1,6 +1,8 @@ +using Imazen.Routing.Layers; + namespace Imageflow.Server { - public readonly struct PathMapping + public readonly struct PathMapping : IPathMapping { public PathMapping(string virtualPath, string physicalPath) { @@ -18,5 +20,13 @@ public PathMapping(string virtualPath, string physicalPath, bool ignorePrefixCas public string VirtualPath { get; } public string PhysicalPath { get; } public bool IgnorePrefixCase { get; } + /// + /// Duplicate of VirtualPath for IStringAndComparison + /// + public string StringToCompare => VirtualPath; + /// + /// If IgnorePrefixCase is true, returns OrdinalIgnoreCase, otherwise Ordinal + /// + public StringComparison StringComparison => IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; } } \ No newline at end of file diff --git a/src/Imageflow.Server/PresetOptions.cs b/src/Imageflow.Server/LegacyOptions/PresetOptions.cs similarity index 93% rename from src/Imageflow.Server/PresetOptions.cs rename to src/Imageflow.Server/LegacyOptions/PresetOptions.cs index e07eff83..581e4099 100644 --- a/src/Imageflow.Server/PresetOptions.cs +++ b/src/Imageflow.Server/LegacyOptions/PresetOptions.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace Imageflow.Server { public enum PresetPriority diff --git a/src/Imageflow.Server/LegacyOptions/RequestSignatureOptions.cs b/src/Imageflow.Server/LegacyOptions/RequestSignatureOptions.cs new file mode 100644 index 00000000..f62f53d8 --- /dev/null +++ b/src/Imageflow.Server/LegacyOptions/RequestSignatureOptions.cs @@ -0,0 +1,44 @@ +using Imazen.Routing.Layers; + +namespace Imageflow.Server +{ + public enum SignatureRequired + { + ForAllRequests = 0, + ForQuerystringRequests = 1, + Never = 2 + } + + + public class RequestSignatureOptions + { + internal SignatureRequired DefaultRequirement { get; } + internal List DefaultSigningKeys { get; } + + internal List Prefixes { get; } = new List(); + public bool IsEmpty => Prefixes.Count == 0 && DefaultSigningKeys.Count == 0 && DefaultRequirement == SignatureRequired.Never; + public static RequestSignatureOptions Empty => new RequestSignatureOptions(SignatureRequired.Never, new List()); + + public RequestSignatureOptions(SignatureRequired defaultRequirement, IEnumerable defaultSigningKeys) + { + DefaultRequirement = defaultRequirement; + DefaultSigningKeys = defaultSigningKeys.ToList(); + } + + public RequestSignatureOptions ForPrefix(string prefix, StringComparison prefixComparison, + SignatureRequired requirement, IEnumerable signingKeys) + { + Prefixes.Add(new SignaturePrefix() + { + Prefix = prefix, + PrefixComparison = prefixComparison, + Requirement =(Imazen.Routing.Layers.SignatureRequired) requirement, + SigningKeys = signingKeys.ToList() + }); + return this; + } + + + + } +} \ No newline at end of file diff --git a/src/Imageflow.Server/UrlEventArgs.cs b/src/Imageflow.Server/LegacyOptions/UrlEventArgs.cs similarity index 66% rename from src/Imageflow.Server/UrlEventArgs.cs rename to src/Imageflow.Server/LegacyOptions/UrlEventArgs.cs index cfa984ce..581978e4 100644 --- a/src/Imageflow.Server/UrlEventArgs.cs +++ b/src/Imageflow.Server/LegacyOptions/UrlEventArgs.cs @@ -1,11 +1,10 @@ -using System.Collections.Generic; using Microsoft.AspNetCore.Http; namespace Imageflow.Server { public class UrlEventArgs { - internal UrlEventArgs(HttpContext context, string virtualPath, Dictionary query) + internal UrlEventArgs(HttpContext? context, string virtualPath, Dictionary query) { VirtualPath = virtualPath; Context = context; @@ -16,6 +15,6 @@ internal UrlEventArgs(HttpContext context, string virtualPath, Dictionary Query { get; set; } - public HttpContext Context { get; } + public HttpContext? Context { get; } } } \ No newline at end of file diff --git a/src/Imageflow.Server/WatermarkingEventArgs.cs b/src/Imageflow.Server/LegacyOptions/WatermarkingEventArgs.cs similarity index 75% rename from src/Imageflow.Server/WatermarkingEventArgs.cs rename to src/Imageflow.Server/LegacyOptions/WatermarkingEventArgs.cs index a404e31d..727628ce 100644 --- a/src/Imageflow.Server/WatermarkingEventArgs.cs +++ b/src/Imageflow.Server/LegacyOptions/WatermarkingEventArgs.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Collections.ObjectModel; using Microsoft.AspNetCore.Http; @@ -7,7 +6,7 @@ namespace Imageflow.Server public class WatermarkingEventArgs { - internal WatermarkingEventArgs(HttpContext context, string virtualPath, Dictionary query, + internal WatermarkingEventArgs(HttpContext? context, string virtualPath, Dictionary query, List watermarks) { VirtualPath = virtualPath; @@ -22,7 +21,7 @@ internal WatermarkingEventArgs(HttpContext context, string virtualPath, Dictiona public List AppliedWatermarks { get; set; } - public HttpContext Context { get; } + public HttpContext? Context { get; } } } \ No newline at end of file diff --git a/src/Imageflow.Server/LicensePage.cs b/src/Imageflow.Server/LicensePage.cs deleted file mode 100644 index 3a476239..00000000 --- a/src/Imageflow.Server/LicensePage.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Globalization; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Net.Http.Headers; - -namespace Imageflow.Server -{ - internal class LicensePage - { - private readonly ImageflowMiddlewareOptions options; - - internal LicensePage(ImageflowMiddlewareOptions options) - { - this.options = options; - } - - public bool MatchesPath(string path) => "/imageflow.license".Equals(path, StringComparison.Ordinal); - - public async Task Invoke(HttpContext context) - { - - var s = await GeneratePage(context); - context.Response.StatusCode = 200; - context.Response.ContentType = "text/plain; charset=utf-8"; - context.Response.Headers.Add("X-Robots-Tag", "none"); - context.Response.Headers[HeaderNames.CacheControl] = "no-store"; - var bytes = Encoding.UTF8.GetBytes(s); - await context.Response.Body.WriteAsync(bytes, 0, bytes.Length); - } - - private Task GeneratePage(HttpContext context) - { - var s = new StringBuilder(8096); - var now = DateTime.UtcNow.ToString(NumberFormatInfo.InvariantInfo); - s.AppendLine($"License page for Imageflow at {context?.Request.Host.Value} generated {now} UTC"); - - s.Append(options.Licensing.GetLicensePageContents()); - return Task.FromResult(s.ToString()); - } - - } - -} \ No newline at end of file diff --git a/src/Imageflow.Server/Licensing.cs b/src/Imageflow.Server/Licensing.cs deleted file mode 100644 index c732259f..00000000 --- a/src/Imageflow.Server/Licensing.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Imazen.Common.Licensing; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; - -namespace Imageflow.Server -{ - internal class Licensing : ILicenseConfig - { - private ImageflowMiddlewareOptions options; - - private readonly Func getCurrentRequestUrl; - - private readonly LicenseManagerSingleton mgr; - - private Computation cachedResult; - internal Licensing(LicenseManagerSingleton mgr, Func getCurrentRequestUrl = null) - { - this.mgr = mgr; - this.getCurrentRequestUrl = getCurrentRequestUrl; - } - - internal void Initialize(ImageflowMiddlewareOptions middlewareOptions) - { - options = middlewareOptions; - mgr.MonitorLicenses(this); - mgr.MonitorHeartbeat(this); - - // Ensure our cache is appropriately invalidated - cachedResult = null; - mgr.AddLicenseChangeHandler(this, (me, manager) => me.cachedResult = null); - - // And repopulated, so that errors show up. - if (Result == null) { - throw new ApplicationException("Failed to populate license result"); - } - } - - private bool EnforcementEnabled() - { - return !string.IsNullOrEmpty(options.LicenseKey) - || string.IsNullOrEmpty(options.MyOpenSourceProjectUrl); - } - public IEnumerable> GetDomainMappings() - { - return Enumerable.Empty>(); - } - - public IEnumerable> GetFeaturesUsed() - { - return new [] {new [] {"Imageflow"}}; - } - - public IEnumerable GetLicenses() - { - return !string.IsNullOrEmpty(options?.LicenseKey) ? Enumerable.Repeat(options.LicenseKey, 1) : Enumerable.Empty(); - } - - public LicenseAccess LicenseScope => LicenseAccess.Local; - - public LicenseErrorAction LicenseEnforcement - { - get - { - return options.EnforcementMethod switch - { - EnforceLicenseWith.RedDotWatermark => LicenseErrorAction.Watermark, - EnforceLicenseWith.Http422Error => LicenseErrorAction.Http422, - EnforceLicenseWith.Http402Error => LicenseErrorAction.Http402, - _ => throw new ArgumentOutOfRangeException() - }; - } - } - - public string EnforcementMethodMessage - { - get - { - return options.EnforcementMethod switch - { - EnforceLicenseWith.RedDotWatermark => - "You are using EnforceLicenseWith.RedDotWatermark. If there is a licensing error, an red dot will be drawn on the bottom-right corner of each image. This can be set to EnforceLicenseWith.Http402Error instead (valuable if you are externally caching or storing result images.)", - EnforceLicenseWith.Http422Error => - "You are using EnforceLicenseWith.Http422Error. If there is a licensing error, HTTP status code 422 will be returned instead of serving the image. This can also be set to EnforceLicenseWith.RedDotWatermark.", - EnforceLicenseWith.Http402Error => - "You are using EnforceLicenseWith.Http402Error. If there is a licensing error, HTTP status code 402 will be returned instead of serving the image. This can also be set to EnforceLicenseWith.RedDotWatermark.", - _ => throw new ArgumentOutOfRangeException() - }; - } - } - -#pragma warning disable CS0067 - public event LicenseConfigEvent LicensingChange; -#pragma warning restore CS0067 - - public event LicenseConfigEvent Heartbeat; - public bool IsImageflow => true; - public bool IsImageResizer => false; - public string LicensePurchaseUrl => "https://imageresizing.net/licenses"; - - public string AgplCompliantMessage - { - get - { - if (!string.IsNullOrWhiteSpace(options.MyOpenSourceProjectUrl)) - { - return "You have certified that you are complying with the AGPLv3 and have open-sourced your project at the following url:\r\n" - + options.MyOpenSourceProjectUrl; - } - else - { - return ""; - } - } - } - - internal Computation Result - { - get { - if (cachedResult?.ComputationExpires != null && - cachedResult.ComputationExpires.Value < mgr.Clock.GetUtcNow()) { - cachedResult = null; - } - return cachedResult ??= new Computation(this, mgr.TrustedKeys,mgr, mgr, - mgr.Clock, EnforcementEnabled()); - } - } - - public string InvalidLicenseMessage => - "Imageflow cannot validate your license; visit /imageflow.debug or /imageflow.license to troubleshoot."; - - internal bool RequestNeedsEnforcementAction(HttpRequest request) - { - if (!EnforcementEnabled()) { - return false; - } - - var requestUrl = getCurrentRequestUrl != null ? getCurrentRequestUrl() : - new Uri(request.GetEncodedUrl()); - - var isLicensed = Result.LicensedForRequestUrl(requestUrl); - if (isLicensed) { - return false; - } - - if (requestUrl == null && Result.LicensedForSomething()) { - return false; - } - - return true; - } - - - public string GetLicensePageContents() - { - return Result.ProvidePublicLicensesPage(); - } - - public void FireHeartbeat() - { - Heartbeat?.Invoke(this, this); - } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/MagicBytes.cs b/src/Imageflow.Server/MagicBytes.cs deleted file mode 100644 index c32d4313..00000000 --- a/src/Imageflow.Server/MagicBytes.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Buffers; -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; - -namespace Imageflow.Server -{ - internal static class MagicBytes - { - internal static string GetContentTypeFromBytes(byte[] data) - { - if (data.Length < 12) - { - return "application/octet-stream"; - } - return new Imazen.Common.FileTypeDetection.FileTypeDetector().GuessMimeType(data) ?? "application/octet-stream"; - } - - - /// - /// Proxies the given stream to the HTTP response, while also setting the content length - /// and the content type based off the magic bytes of the image - /// - /// - /// - /// - internal static async Task ProxyToStream(Stream sourceStream, HttpResponse response) - { - if (sourceStream.CanSeek) - { - response.ContentLength = sourceStream.Length - sourceStream.Position; - } - - // We really only need 12 bytes but it would be a waste to only read that many. - const int bufferSize = 4096; - var buffer = ArrayPool.Shared.Rent(bufferSize); - try - { - var bytesRead = await sourceStream.ReadAsync(new Memory(buffer)).ConfigureAwait(false); - if (bytesRead == 0) - { - throw new InvalidOperationException("Source blob has zero bytes."); - } - - response.ContentType = bytesRead >= 12 ? GetContentTypeFromBytes(buffer) : "application/octet-stream"; - await response.Body.WriteAsync(new ReadOnlyMemory(buffer, 0, bytesRead)).ConfigureAwait(false); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - await sourceStream.CopyToAsync(response.Body).ConfigureAwait(false); - } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/NamedWatermark.cs b/src/Imageflow.Server/NamedWatermark.cs deleted file mode 100644 index 5a3fa1ba..00000000 --- a/src/Imageflow.Server/NamedWatermark.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.IO; -using Imageflow.Fluent; -using Newtonsoft.Json; - -namespace Imageflow.Server -{ - public class NamedWatermark - { - public NamedWatermark(string name, string virtualPath, WatermarkOptions watermark) - { - Name = name; - VirtualPath = virtualPath; - Watermark = watermark; - serialized = null; - } - public string Name { get; } - public string VirtualPath { get; } - public WatermarkOptions Watermark { get; } - - - private string serialized; - - internal string Serialized() - { - if (serialized != null) return serialized; - using var writer = new StringWriter(); - writer.WriteLine(Name); - writer.WriteLine(VirtualPath); - JsonSerializer.Create().Serialize(writer, Watermark.ToImageflowDynamic(0)); - - - writer.Flush(); //Required or no bytes appear - serialized = writer.ToString(); - return serialized; - } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/PathHelpers.cs b/src/Imageflow.Server/PathHelpers.cs index e3d41474..52e5965c 100644 --- a/src/Imageflow.Server/PathHelpers.cs +++ b/src/Imageflow.Server/PathHelpers.cs @@ -1,107 +1,28 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; - namespace Imageflow.Server { public static class PathHelpers { - - private static readonly string[] Suffixes = new string[] { - ".png", - ".jpg", - ".jpeg", - ".jfif", - ".jif", - ".jfi", - ".jpe", - ".gif", - ".webp" - }; - - //TODO: Some of these are only modifier keys, and as such, should not trigger processing - private static readonly string[] QuerystringKeys = new string[] - { - "mode", "anchor", "flip", "sflip", "scale", "cache", "process", - "quality", "zoom", "dpr", "crop", "cropxunits", "cropyunits", - "w", "h", "width", "height", "maxwidth", "maxheight", "format", "thumbnail", - "autorotate", "srotate", "rotate", "ignoreicc", - "stretch", "webp.lossless", "webp.quality", - "frame", "page", "subsampling", "colors", "f.sharpen", "f.sharpen_when", "down.colorspace", - "404", "bgcolor", "paddingcolor", "bordercolor", "preset", "floatspace", "jpeg_idct_downscale_linear", "watermark", - "s.invert", "s.sepia", "s.grayscale", "s.alpha", "s.brightness", "s.contrast", "s.saturation", "trim.threshold", - "trim.percentpadding", "a.blur", "a.sharpen", "a.removenoise", "a.balancewhite", "dither", "jpeg.progressive", - "encoder", "decoder", "builder", "s.roundcorners.", "paddingwidth", "paddingheight", "margin", "borderwidth", "decoder.min_precise_scaling_ratio" - }; - - public static IEnumerable AcceptedImageExtensions => Suffixes; - public static IEnumerable SupportedQuerystringKeys => QuerystringKeys; - - internal static bool IsImagePath(string path) - { - return Suffixes.Any(suffix => path.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); - } + public static IEnumerable AcceptedImageExtensions => Imazen.Routing.Helpers.PathHelpers.AcceptedImageExtensions; + public static IEnumerable SupportedQuerystringKeys => Imazen.Routing.Helpers.PathHelpers.SupportedQuerystringKeys; + internal static bool IsImagePath(string path) => Imazen.Routing.Helpers.PathHelpers.IsImagePath(path); + public static string? SanitizeImageExtension(string extension) => Imazen.Routing.Helpers.PathHelpers.SanitizeImageExtension(extension); + public static string? GetImageExtensionFromContentType(string contentType) => Imazen.Routing.Helpers.PathHelpers.GetImageExtensionFromContentType(contentType); - public static string SanitizeImageExtension(string extension) - { - extension = extension.ToLowerInvariant().TrimStart('.'); - return extension switch - { - "png" => "png", - "gif" => "gif", - "webp" => "webp", - "jpeg" => "jpg", - "jfif" => "jpg", - "jif" => "jpg", - "jfi" => "jpg", - "jpe" => "jpg", - _ => null - }; - } - public static string GetImageExtensionFromContentType(string contentType) - { - return contentType switch - { - "image/png" => "png", - "image/gif" => "gif", - "image/webp" => "webp", - "image/jpeg" => "jpg", - _ => null - }; - } - internal static string Base64Hash(string data) - { - using var sha2 = SHA256.Create(); - var stringBytes = Encoding.UTF8.GetBytes(data); - // check cache and return if cached - var hashBytes = - sha2.ComputeHash(stringBytes); - return Convert.ToBase64String(hashBytes) - .Replace("=", string.Empty) - .Replace('+', '-') - .Replace('/', '_'); - } + /// + /// Creates a 43-character URL-safe hash of arbitrary data. Good for implementing caches. SHA256 is used, and the result is base64 encoded with no padding, and the + and / characters replaced with - and _. + /// + /// + /// + public static string CreateBase64UrlHash(string data) => Imazen.Routing.Helpers.PathHelpers.CreateBase64UrlHash(data); - internal static Dictionary ToQueryDictionary(IQueryCollection requestQuery) - { - var dict = new Dictionary(requestQuery.Count, StringComparer.OrdinalIgnoreCase); - foreach (var pair in requestQuery) - { - dict.Add(pair.Key, pair.Value.ToString()); - } + /// + /// Creates a 43-character URL-safe hash of arbitrary data. Good for implementing caches. SHA256 is used, and the result is base64 encoded with no padding, and the + and / characters replaced with - and _. + /// + /// + /// + public static string CreateBase64UrlHash(byte[] data) => Imazen.Routing.Helpers.PathHelpers.CreateBase64UrlHash(data); - return dict; - } - internal static string SerializeCommandString(Dictionary finalQuery) - { - var qs = QueryString.Create(finalQuery.Select(p => new KeyValuePair(p.Key, p.Value))); - return qs.ToString()?.TrimStart('?') ?? ""; - } } } \ No newline at end of file diff --git a/src/Imageflow.Server/RequestSignatureOptions.cs b/src/Imageflow.Server/RequestSignatureOptions.cs deleted file mode 100644 index 3fbff4c6..00000000 --- a/src/Imageflow.Server/RequestSignatureOptions.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Imageflow.Server -{ - public enum SignatureRequired - { - ForAllRequests, - ForQuerystringRequests, - Never - } - - internal struct SignaturePrefix - { - internal string Prefix { get; set; } - internal StringComparison PrefixComparison { get; set; } - internal SignatureRequired Requirement { get; set; } - internal List SigningKeys { get; set; } - - - } - public class RequestSignatureOptions - { - internal SignatureRequired DefaultRequirement { get; } - private List DefaultSigningKeys { get; } - - private List Prefixes { get; } = new List(); - - - public RequestSignatureOptions(SignatureRequired defaultRequirement, IEnumerable defaultSigningKeys) - { - DefaultRequirement = defaultRequirement; - DefaultSigningKeys = defaultSigningKeys.ToList(); - } - - public RequestSignatureOptions ForPrefix(string prefix, StringComparison prefixComparison, - SignatureRequired requirement, IEnumerable signingKeys) - { - Prefixes.Add(new SignaturePrefix() - { - Prefix = prefix, - PrefixComparison = prefixComparison, - Requirement = requirement, - SigningKeys = signingKeys.ToList() - }); - return this; - } - - internal Tuple> GetRequirementForPath(string path) - { - if (path != null) - { - foreach (var p in Prefixes) - { - if (path.StartsWith(p.Prefix, p.PrefixComparison)) - { - return new Tuple>(p.Requirement, p.SigningKeys); - } - } - } - return new Tuple>(DefaultRequirement, DefaultSigningKeys); - } - - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/UrlHandler.cs b/src/Imageflow.Server/UrlHandler.cs deleted file mode 100644 index 0e498ba3..00000000 --- a/src/Imageflow.Server/UrlHandler.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Imageflow.Server -{ - internal class UrlHandler - { - internal UrlHandler(string prefix, T handler) - { - PathPrefix = prefix; - Handler = handler; - } - public string PathPrefix { get; } - - public T Handler { get; } - } -} \ No newline at end of file diff --git a/src/Imageflow.Server/packages.lock.json b/src/Imageflow.Server/packages.lock.json index 965b0830..a1c154a3 100644 --- a/src/Imageflow.Server/packages.lock.json +++ b/src/Imageflow.Server/packages.lock.json @@ -4,15 +4,15 @@ "net6.0": { "Imageflow.AllPlatforms": { "type": "Direct", - "requested": "[0.10.2, )", - "resolved": "0.10.2", - "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", + "requested": "[0.12.0, )", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", "dependencies": { "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", - "Imageflow.Net": "0.10.2" + "Imageflow.Net": "0.12.0" } }, "Microsoft.CodeAnalysis.PublicApiAnalyzers": { @@ -21,16 +21,27 @@ "resolved": "3.3.4", "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Direct", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" + }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, "Imageflow.NativeRuntime.osx-x86_64": { "type": "Transitive", "resolved": "2.0.0-preview8", @@ -53,23 +64,17 @@ }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", "dependencies": { - "Microsoft.CSharp": "4.7.0", - "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", - "Newtonsoft.Json": "[13.0.3, 14.0.0)" + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" } }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" - }, - "Microsoft.CSharp": { - "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -117,20 +122,221 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" + } + } + }, + "net8.0": { + "Imageflow.AllPlatforms": { + "type": "Direct", + "requested": "[0.12.0, )", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", + "dependencies": { + "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", + "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", + "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", + "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", + "Imageflow.Net": "0.12.0" + } + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, "Microsoft.IO.RecyclableMemoryStream": { + "type": "Direct", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "Imageflow.NativeRuntime.osx-x86_64": { "type": "Transitive", - "resolved": "1.2.2", - "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + "resolved": "2.0.0-preview8", + "contentHash": "3wEglrMqVzlnAVBTdK6qcRySdo/4ajBP5ASRuK3yHfBqPp3ld4ke6guxuSZbgDTObIxai7KTLlsIvQZhusymUA==" + }, + "Imageflow.NativeRuntime.ubuntu-x86_64": { + "type": "Transitive", + "resolved": "2.0.0-preview8", + "contentHash": "H8K5kZqcM3IliDRZD3H8BN6TbeLgcW+6FsDZ3EvlqBvu41s+Lv9vxE+c3m1cUQhsYBs76udUhgJFNR1D6x3U5g==" + }, + "Imageflow.NativeRuntime.win-x86": { + "type": "Transitive", + "resolved": "2.0.0-preview8", + "contentHash": "WunIva5NZ2iMPKCyz8ZTkN7SRaW3szBijMg5YK7jaSFZHw8Xiky/GFfghc0XgWTuILxwO4YbY86e8QvW8CBigQ==" + }, + "Imageflow.NativeRuntime.win-x86_64": { + "type": "Transitive", + "resolved": "2.0.0-preview8", + "contentHash": "1rY6C9Hjj7U9toa7FlnveiSBKccZlvCaHwdxPRQS0vDpAZZCJrTA/H7VYdreifpnIDInYcf0i/3oEKzEnj884w==" + }, + "Imageflow.Net": { + "type": "Transitive", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, - "Newtonsoft.Json": { + "System.Collections.Immutable": { "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" }, "System.Memory": { "type": "Transitive", @@ -139,14 +345,50 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" + } } } } diff --git a/src/Imazen.Abstractions/AssemblyAttributes/BuildDateAttribute.cs b/src/Imazen.Abstractions/AssemblyAttributes/BuildDateAttribute.cs new file mode 100644 index 00000000..717c5dd2 --- /dev/null +++ b/src/Imazen.Abstractions/AssemblyAttributes/BuildDateAttribute.cs @@ -0,0 +1,32 @@ +namespace Imazen.Abstractions.AssemblyAttributes +{ + [AttributeUsage(AttributeTargets.Assembly)] + public class BuildDateAttribute : Attribute + { + public BuildDateAttribute() { Value = string.Empty; } + public BuildDateAttribute(string buildDateStringRoundTrip) { Value = buildDateStringRoundTrip; } + + public string Value { get; } + + public DateTimeOffset? ValueDate + { + get + { + DateTimeOffset v; + if (DateTimeOffset.TryParse(Value, null, System.Globalization.DateTimeStyles.RoundtripKind, out v)) + { + return v; + }else + { + return null; + } + } + } + + public override string ToString() + { + return Value; + } + + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/AssemblyAttributes/CommitAttribute.cs b/src/Imazen.Abstractions/AssemblyAttributes/CommitAttribute.cs new file mode 100644 index 00000000..6b66e0bf --- /dev/null +++ b/src/Imazen.Abstractions/AssemblyAttributes/CommitAttribute.cs @@ -0,0 +1,26 @@ +namespace Imazen.Abstractions.AssemblyAttributes +{ + + [AttributeUsage(AttributeTargets.Assembly)] + public class CommitAttribute : Attribute + { + private readonly string commitId; + + public CommitAttribute() + { + commitId = string.Empty; + } + + public CommitAttribute(string commitId) + { + this.commitId = commitId; + } + + public string Value => commitId; + + public override string ToString() + { + return commitId; + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/AssemblyAttributes/NugetPackageAttribute.cs b/src/Imazen.Abstractions/AssemblyAttributes/NugetPackageAttribute.cs new file mode 100644 index 00000000..0d2bcb51 --- /dev/null +++ b/src/Imazen.Abstractions/AssemblyAttributes/NugetPackageAttribute.cs @@ -0,0 +1,26 @@ +namespace Imazen.Abstractions.AssemblyAttributes; + +/// An assembly attribute that indicates the nuget package name the assembly is primarily distributed with +/// It can alternately be "{Assembly.Name}" to just inherit that value. +[AttributeUsage(AttributeTargets.Assembly)] +public class NugetPackageAttribute : Attribute +{ + public string PackageName { get; } + + /// + /// + /// + /// + /// {Assembly.Name} to inherit the assembly name, or the name of the nuget package + /// + public NugetPackageAttribute(string packageName) + { + PackageName = packageName; + } + + public override string ToString() + { + return $"This assembly is primarily distributed with the nuget package '{PackageName}'"; + } +} + \ No newline at end of file diff --git a/src/Imazen.Abstractions/AssemblyAttributes/VersionedWithAssembliesAttribute.cs b/src/Imazen.Abstractions/AssemblyAttributes/VersionedWithAssembliesAttribute.cs new file mode 100644 index 00000000..63c35765 --- /dev/null +++ b/src/Imazen.Abstractions/AssemblyAttributes/VersionedWithAssembliesAttribute.cs @@ -0,0 +1,16 @@ +namespace Imazen.Abstractions.AssemblyAttributes; + +/// +/// Indicates that the assembly should match the version of one or more other assemblies. +/// +/// +[AttributeUsage(AttributeTargets.Assembly)] +public class VersionedWithAssembliesAttribute(params string[] assemblyPatterns) : Attribute +{ + public string[] AssemblyPatterns { get; } = assemblyPatterns; + + public override string ToString() + { + return string.Join("This assembly should match the version of , ", AssemblyPatterns); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/BlobCache/BlobCacheCapabilities.cs b/src/Imazen.Abstractions/BlobCache/BlobCacheCapabilities.cs new file mode 100644 index 00000000..e8603760 --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/BlobCacheCapabilities.cs @@ -0,0 +1,109 @@ +using System.Text; + +namespace Imazen.Abstractions.BlobCache; + +/// +/// We don't use this and can delete it. Interfaces over records make it very clumsy to use. +/// +internal interface IBlobCacheCapabilities +{ + bool CanFetchMetadata { get; } + bool CanFetchData { get; } + bool CanConditionalFetch { get; } + bool CanPut { get; } + bool CanConditionalPut { get; } + bool CanDelete { get; } + bool CanSearchByTag { get; } + bool CanPurgeByTag { get; } + bool CanReceiveEvents { get; } + bool SupportsHealthCheck { get; } + bool SubscribesToRecentRequest { get; } + bool SubscribesToExternalHits { get; } + bool SubscribesToFreshResults { get; } + + bool RequiresInlineExecution { get;} + bool FixedSize { get; } +} + +public record BlobCacheCapabilities: IBlobCacheCapabilities +{ + public required bool CanFetchMetadata { get; init; } + public required bool CanFetchData { get; init; } + public required bool CanConditionalFetch { get; init; } + public required bool CanPut { get; init; } + public required bool CanConditionalPut { get; init; } + public required bool CanDelete { get; init; } + public required bool CanSearchByTag { get; init; } + public required bool CanPurgeByTag { get; init; } + public required bool CanReceiveEvents { get; init; } + public required bool SupportsHealthCheck { get; init; } + public required bool SubscribesToRecentRequest { get; init; } + public required bool SubscribesToExternalHits { get; init; } + + public required bool SubscribesToFreshResults { get; init; } + + public required bool RequiresInlineExecution { get; init; } + public required bool FixedSize { get; init; } + + public void WriteTruePairs(StringBuilder sb) + { + if (CanFetchData) sb.Append("CanFetchData, "); + if (CanFetchMetadata) sb.Append("CanFetchMetadata, "); + if (CanConditionalFetch) sb.Append("CanConditionalFetch, "); + if (CanPut) sb.Append("CanPut, "); + if (CanConditionalPut) sb.Append("CanConditionalPut, "); + if (CanDelete) sb.Append("CanDelete, "); + if (CanSearchByTag) sb.Append("CanSearchByTag, "); + if (CanPurgeByTag) sb.Append("CanPurgeByTag, "); + if (CanReceiveEvents) sb.Append("CanReceiveEvents, "); + if (SupportsHealthCheck) sb.Append("SupportsHealthCheck, "); + if (SubscribesToRecentRequest) sb.Append("SubscribesToRecentRequest, "); + if (SubscribesToExternalHits) sb.Append("SubscribesToExternalHits, "); + if (SubscribesToFreshResults) sb.Append("SubscribesToFreshResults, "); + if (RequiresInlineExecution) sb.Append("RequiresInlineExecution, "); + if (FixedSize) sb.Append("FixedSize, "); + } + + public void WriteReducedPairs(StringBuilder sb, BlobCacheCapabilities subtract) + { + if (subtract.CanFetchData && !CanFetchData) sb.Append("-CanFetchData, "); + if (subtract.CanFetchMetadata && !CanFetchMetadata) sb.Append("-CanFetchMetadata, "); + if (subtract.CanConditionalFetch && !CanConditionalFetch) sb.Append("-CanConditionalFetch, "); + if (subtract.CanPut && !CanPut) sb.Append("-CanPut, "); + if (subtract.CanConditionalPut && !CanConditionalPut) sb.Append("-CanConditionalPut, "); + if (subtract.CanDelete && !CanDelete) sb.Append("-CanDelete, "); + if (subtract.CanSearchByTag && !CanSearchByTag) sb.Append("-CanSearchByTag, "); + if (subtract.CanPurgeByTag && !CanPurgeByTag) sb.Append("-CanPurgeByTag, "); + if (subtract.CanReceiveEvents && !CanReceiveEvents) sb.Append("-CanReceiveEvents, "); + if (subtract.SupportsHealthCheck && !SupportsHealthCheck) sb.Append("-SupportsHealthCheck, "); + if (subtract.SubscribesToRecentRequest && !SubscribesToRecentRequest) sb.Append("-SubscribesToRecentRequest, "); + if (subtract.SubscribesToExternalHits && !SubscribesToExternalHits) sb.Append("-SubscribesToExternalHits, "); + if (subtract.SubscribesToFreshResults && !SubscribesToFreshResults) sb.Append("-SubscribesToFreshResults, "); + if (subtract.RequiresInlineExecution && !RequiresInlineExecution) sb.Append("-RequiresInlineExecution, "); + if (subtract.FixedSize && !FixedSize) sb.Append("-FixedSize, "); + + } + + + public static readonly BlobCacheCapabilities None = new BlobCacheCapabilities + { + CanFetchMetadata = false, + CanFetchData = false, + CanConditionalFetch = false, + CanPut = false, + CanConditionalPut = false, + CanDelete = false, + CanSearchByTag = false, + CanPurgeByTag = false, + CanReceiveEvents = false, + SupportsHealthCheck = false, + SubscribesToRecentRequest = false, + SubscribesToExternalHits = false, + FixedSize = false, + RequiresInlineExecution = false, + SubscribesToFreshResults = false + }; + + public static readonly BlobCacheCapabilities OnlyHealthCheck = None with {SupportsHealthCheck = true}; + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/BlobCache/BlobCacheFetchFailure.cs b/src/Imazen.Abstractions/BlobCache/BlobCacheFetchFailure.cs new file mode 100644 index 00000000..f399be11 --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/BlobCacheFetchFailure.cs @@ -0,0 +1,31 @@ +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; + +namespace Imazen.Abstractions.BlobCache; + +public interface IBlobCacheFetchFailure +{ + IBlobCache? NotifyOfResult { get; } + IBlobCache? NotifyOfExternalHit { get; } + HttpStatus Status { get; } + + +} +public record BlobCacheFetchFailure : IBlobCacheFetchFailure +{ + public IBlobCache? NotifyOfResult { get; init; } + public IBlobCache? NotifyOfExternalHit { get; init;} + public HttpStatus Status { get; init;} + + + public static IResult ErrorResult(HttpStatus status, IBlobCache? notifyOfResult, IBlobCache? notifyOfExternalHit) => Result.Err(new BlobCacheFetchFailure {Status = status, NotifyOfResult = notifyOfResult, NotifyOfExternalHit = notifyOfExternalHit}); + + public static IResult ErrorResult(HttpStatus status) => Result.Err(new BlobCacheFetchFailure {Status = status}); + + public static IResult MissResult() => Result.Err(new BlobCacheFetchFailure {Status = HttpStatus.NotFound}); + + public static IResult MissResult(IBlobCache? notifyOfResult, IBlobCache? notifyOfExternalHit) => Result.Err(new BlobCacheFetchFailure {Status = HttpStatus.NotFound, NotifyOfResult = notifyOfResult, NotifyOfExternalHit = notifyOfExternalHit}); + public static IResult OkResult(BlobWrapper blob) => Result.Ok(blob); + public static IResult OkResult(IBlobWrapper blob) => Result.Ok(blob); + +} \ No newline at end of file diff --git a/src/Imazen.Common/Storage/Caching/BlobGroup.cs b/src/Imazen.Abstractions/BlobCache/BlobGroup.cs similarity index 66% rename from src/Imazen.Common/Storage/Caching/BlobGroup.cs rename to src/Imazen.Abstractions/BlobCache/BlobGroup.cs index 6992322a..4ff6f0b3 100644 --- a/src/Imazen.Common/Storage/Caching/BlobGroup.cs +++ b/src/Imazen.Abstractions/BlobCache/BlobGroup.cs @@ -1,18 +1,25 @@ -namespace Imazen.Common.Storage.Caching +namespace Imazen.Abstractions.BlobCache { /// /// Which container or sub-container to store or fetch a blob from. Different groups may have different eviction policies. /// - public enum BlobGroup{ + public enum BlobGroup : byte{ /// /// For the image data contents of a cache entry (may also contain associated metadata) /// - CacheEntry, + GeneratedCacheEntry, + + /// + /// For files that are simply proxied from another source, such as a remote URL or a file on disk. + /// These may utilize a lower eviction policy than GeneratedCacheEntry. + /// + ProxiedCacheEntry, + /// /// For small, non-image-data blobs that contain metadata or other information about a cache entry that should not be evicted as eagerly as the image data. /// - CacheMetadata, + CacheEntryMetadata, /// /// For small blobs that contain metadata or other information about a source image that should generally not be evicted. @@ -20,7 +27,7 @@ public enum BlobGroup{ SourceMetadata, /// - /// For essential blobs that should never be evicted, such as index files. + /// For essential blobs that should never be evicted, such as index files and tag data. /// Essential, } diff --git a/src/Imazen.Abstractions/BlobCache/CacheEventDetails.cs b/src/Imazen.Abstractions/BlobCache/CacheEventDetails.cs new file mode 100644 index 00000000..97fb38a8 --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/CacheEventDetails.cs @@ -0,0 +1,103 @@ +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; + +namespace Imazen.Abstractions.BlobCache +{ + /// + /// TODO: clean this up and minimize it once we have implementations of the 3 caches + /// Currently no usage to drive refinement + /// + public interface ICacheEventDetails + { + BlobGroup BlobCategory { get; } + IReusableBlobFactory BlobFactory { get; } + /// + /// The following cache responded with a hit + /// + IBlobCache? ExternalCacheHit { get; } + /// + /// A fresh result was generated and was stored in Result + /// + bool FreshResultGenerated { get; } + + /// + /// Generation of this result failed. Brief failure caching may be appropriate to conserve server resources. + /// But is best left to the main cache engine. + /// + bool GenerationFailed { get; } + IBlobCacheRequest OriginalRequest { get; } + IResult? Result { get; } + DateTimeOffset AsyncWriteJobCreatedAt { get; set; } + TimeSpan FreshResultGenerationTime { get; set; } + + /// + /// If true, we are holding up image delivery. + /// + bool InServerRequest { get; set; } + } + + public record CacheEventDetails : ICacheEventDetails + { + public BlobGroup BlobCategory => OriginalRequest.BlobCategory; + public required IReusableBlobFactory BlobFactory { get; init; } + public IBlobCache? ExternalCacheHit { get; init; } + public bool FreshResultGenerated { get; init; } + public bool GenerationFailed { get; init; } + public required IBlobCacheRequest OriginalRequest { get; init; } + public CacheFetchResult? Result { get; init; } + public DateTimeOffset AsyncWriteJobCreatedAt { get; set; } + public TimeSpan FreshResultGenerationTime { get; set; } + public bool InServerRequest { get; set; } + + public static CacheEventDetails Create(IBlobCacheRequest request, + IReusableBlobFactory blobFactory, + IBlobCache? externalCacheHit, + bool freshResultGenerated, bool generationFailed, CacheFetchResult? result) + { + return new CacheEventDetails + { + OriginalRequest = request, + BlobFactory = blobFactory, + ExternalCacheHit = externalCacheHit, + FreshResultGenerated = freshResultGenerated, + GenerationFailed = generationFailed, + Result = result, + AsyncWriteJobCreatedAt = DateTimeOffset.UtcNow + }; + } + + public static CacheEventDetails CreateFreshResultGeneratedEvent(IBlobCacheRequest request, + IReusableBlobFactory blobFactory, + CacheFetchResult? result) + { + return new CacheEventDetails + { + OriginalRequest = request, + BlobFactory = blobFactory, + ExternalCacheHit = null, + FreshResultGenerated = true, + GenerationFailed = result?.IsError ?? false, + Result = result, + //TODO - this may not be correct + AsyncWriteJobCreatedAt = DateTimeOffset.UtcNow + }; + } + + public static CacheEventDetails CreateExternalHitEvent(IBlobCacheRequest request, IBlobCache externalCache, + IReusableBlobFactory blobFactory, + CacheFetchResult? result) + { + return new CacheEventDetails + { + OriginalRequest = request, + BlobFactory = blobFactory, + ExternalCacheHit = externalCache, + FreshResultGenerated = false, + GenerationFailed = result?.IsError ?? false, + Result = result, + //TODO - this may not be correct + AsyncWriteJobCreatedAt = DateTimeOffset.UtcNow + }; + } + } +} diff --git a/src/Imazen.Abstractions/BlobCache/CacheEventType.cs b/src/Imazen.Abstractions/BlobCache/CacheEventType.cs new file mode 100644 index 00000000..8b17ba09 --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/CacheEventType.cs @@ -0,0 +1,15 @@ +namespace Imazen.Abstractions.BlobCache; + +internal enum CacheEventType +{ + YourCacheEntryExpiresSoon, + ExternalHitYouSkipped, + ExternalHitYouMissed, + FreshResultGenerated, + FreshFileProxied, + ResultGenerationFailed, + ResultSourcesNotFound, + ResultSourcesFailed + + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/BlobCache/IBlobCache.cs b/src/Imazen.Abstractions/BlobCache/IBlobCache.cs new file mode 100644 index 00000000..c59c5b00 --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/IBlobCache.cs @@ -0,0 +1,74 @@ +global using CacheFetchResult = Imazen.Abstractions.Resulting.IResult; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; + +namespace Imazen.Abstractions.BlobCache +{ + public interface IBlobCache: IUniqueNamed + { + + + /// + /// The cache should attempt to fetch the blob, and return a result indicating whether it was found or not. + /// + /// + /// + /// + Task CacheFetch(IBlobCacheRequest request, CancellationToken cancellationToken = default); + + /// + /// The cache should attempt to store the blob, if the conditions are met. + /// + /// + /// + /// + Task CachePut(ICacheEventDetails e, CancellationToken cancellationToken = default); + + + /// + /// Return references to all entries with the given tag + /// + /// + /// + /// + Task>> CacheSearchByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default); + + /// + /// Attempt to delete all entries with the given tag, and let us know the result for each attempt. + /// + /// + /// + /// + Task>>> CachePurgeByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default); + + + /// + /// Try to delete a given cache entry. + /// + /// + /// + /// + Task CacheDelete(IBlobStorageReference reference, CancellationToken cancellationToken = default); + + /// + /// Called for many different events, including external cache hits, misses, and errors. + /// + /// + /// + /// + Task OnCacheEvent(ICacheEventDetails e, CancellationToken cancellationToken = default); + + /// + /// Returns the expected cache capabilities expected with the current settings, assuming no errors. + /// + BlobCacheCapabilities InitialCacheCapabilities { get; } + + /// + /// Performs a full health check of the cache, including any external dependencies. + /// Returns errors and the current capability level. + /// + /// + /// + ValueTask CacheHealthCheck(CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/BlobCache/IBlobCacheHealthDetails.cs b/src/Imazen.Abstractions/BlobCache/IBlobCacheHealthDetails.cs new file mode 100644 index 00000000..2f880493 --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/IBlobCacheHealthDetails.cs @@ -0,0 +1,84 @@ +using System.Text; +using Imazen.Abstractions.Resulting; + +namespace Imazen.Abstractions.BlobCache; + +public interface IBlobCacheHealthDetails +{ + bool AtFullHealth { get; } + + TimeSpan? SuggestedRecheckDelay { get; } + string? Summary { get; } + + IReadOnlyList? ErrorList { get; } + + BlobCacheCapabilities CurrentCapabilities { get; } + + DateTimeOffset? LastChecked { get; } + + string GetReport(); +} + +public record BlobCacheHealthDetails: IBlobCacheHealthDetails +{ + public required bool AtFullHealth { get; init; } + + public TimeSpan? SuggestedRecheckDelay { get; init; } + public string? Summary { get; init; } + + public IReadOnlyList? ErrorList { get; init; } + + public required BlobCacheCapabilities CurrentCapabilities { get; init; } + + public DateTimeOffset? LastChecked { get; init; } + + public string GetReport() + { + var sb = new StringBuilder(); + sb.AppendLine($"At full health: {AtFullHealth}"); + sb.AppendLine($"Last checked: {LastChecked}"); + sb.AppendLine($"Current capabilities: {CurrentCapabilities}"); + sb.AppendLine($"Summary: {Summary}"); + if (ErrorList != null) + { + sb.AppendLine($"Errors: {string.Join("\n", ErrorList)}"); + } + return sb.ToString(); + } + + public static BlobCacheHealthDetails FullHealth(BlobCacheCapabilities capabilities, DateTimeOffset? lastCheckedAt = null) + { + return new BlobCacheHealthDetails() + { + AtFullHealth = true, + CurrentCapabilities = capabilities, + LastChecked = lastCheckedAt ?? DateTimeOffset.UtcNow, + Summary = "OK" + }; + } + + public static BlobCacheHealthDetails Errors(string summary, IReadOnlyList errors, BlobCacheCapabilities capabilities, DateTimeOffset? lastCheckedAt = null) + { + return new BlobCacheHealthDetails() + { + AtFullHealth = false, + Summary = summary, + CurrentCapabilities = capabilities, + LastChecked = lastCheckedAt ?? DateTimeOffset.UtcNow, + ErrorList = errors + + }; + } + public static BlobCacheHealthDetails Error(CodeResult error, BlobCacheCapabilities capabilities, DateTimeOffset? lastCheckedAt = null) + { + return new BlobCacheHealthDetails() + { + AtFullHealth = false, + Summary = error.ToString(), + CurrentCapabilities = capabilities, + LastChecked = lastCheckedAt ?? DateTimeOffset.UtcNow, + ErrorList = new List(){error} + + }; + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/BlobCache/IBlobCacheProvider.cs b/src/Imazen.Abstractions/BlobCache/IBlobCacheProvider.cs new file mode 100644 index 00000000..72d945af --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/IBlobCacheProvider.cs @@ -0,0 +1,8 @@ +namespace Imazen.Abstractions.BlobCache +{ + + public interface IBlobCacheProvider + { + IEnumerable GetBlobCaches(); + } +} diff --git a/src/Imazen.Abstractions/BlobCache/IBlobCacheRequest.cs b/src/Imazen.Abstractions/BlobCache/IBlobCacheRequest.cs new file mode 100644 index 00000000..9b6f72c9 --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/IBlobCacheRequest.cs @@ -0,0 +1,94 @@ +using Imazen.Common.Extensibility.Support; + +namespace Imazen.Abstractions.BlobCache; + +public interface IBlobCacheRequest +{ + /// + /// Caches may use different storage backends for different categories of data, such as essential data, generated data, or metadata. + /// Two requests for the same cache key may return different results if they are in different categories. + /// + BlobGroup BlobCategory { get; } + + /// + /// 32 byte hash of the cache key + /// + byte[] CacheKeyHash { get; } + + /// + /// An encoded string of the cache key hash + /// + string CacheKeyHashString { get; } + + /// + /// May trigger additional disk or network requests to acquire metadata about the blob, with additional + /// probability of failure. If false, even content-type may not be returned. + /// + bool FetchAllMetadata { get; } + + /// + /// If true, caches should fail quickly if there is any kind of problem (such as a locked file or network error), + /// instead of performing retries or waiting for resource availability. + /// + bool FailFast { get; } + + IBlobCacheRequestConditions? Conditions { get; } +} + + +public class BlobCacheRequest: IBlobCacheRequest +{ + public BlobCacheRequest(BlobGroup blobCategory, byte[] cacheKeyHash, string cacheKeyHashString, bool fetchAllMetadata) + { + BlobCategory = blobCategory; + CacheKeyHash = cacheKeyHash; + CacheKeyHashString = cacheKeyHashString; + FetchAllMetadata = fetchAllMetadata; + } + + public BlobCacheRequest(BlobGroup blobCategory, byte[] hashBasis) + { + BlobCategory = blobCategory; + CacheKeyHash = HashBasedPathBuilder.HashKeyBasisStatic(hashBasis); + CacheKeyHashString = HashBasedPathBuilder.GetStringFromHashStatic(CacheKeyHash); + FetchAllMetadata = false; + + } + + public BlobCacheRequest(IBlobCacheRequest request) + { + BlobCategory = request.BlobCategory; + CacheKeyHash = request.CacheKeyHash; + CacheKeyHashString = request.CacheKeyHashString; + FetchAllMetadata = request.FetchAllMetadata; + FailFast = request.FailFast; + Conditions = request.Conditions; + } + public BlobGroup BlobCategory { get; init; } + public byte[] CacheKeyHash { get; init; } + public string CacheKeyHashString { get; init; } + + + /// + /// Clarify this - many kinds of metadata + /// + public bool FetchAllMetadata { get; init; } = false; + + public bool FailFast { get; init; } = false; + public IBlobCacheRequestConditions? Conditions { get; init; } = null; + + +} + +public static class BlobCacheRequestExtensions +{ + public static IBlobCacheRequest WithFailFast(this IBlobCacheRequest request, bool failFast) + { + return new BlobCacheRequest(request) {FailFast = failFast}; + } + + public static IBlobCacheRequest WithFetchAllMetadata(this IBlobCacheRequest request, bool fetchAllMetadata) + { + return new BlobCacheRequest(request) {FetchAllMetadata = fetchAllMetadata}; + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/BlobCache/IBlobCacheRequestConditions.cs b/src/Imazen.Abstractions/BlobCache/IBlobCacheRequestConditions.cs new file mode 100644 index 00000000..ec34d165 --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/IBlobCacheRequestConditions.cs @@ -0,0 +1,15 @@ +namespace Imazen.Abstractions.BlobCache; + +public interface IBlobCacheRequestConditions +{ + string? IfMatch { get; } + IReadOnlyList? IfNoneMatch { get; } +} + +public record BlobCacheRequestConditions(string? IfMatch, IReadOnlyList? IfNoneMatch) : IBlobCacheRequestConditions +{ + public static BlobCacheRequestConditions None => new(null, null); + public static BlobCacheRequestConditions ConditionIfMatch(string? ifMatch) => new(ifMatch, null); + public static BlobCacheRequestConditions ConditionIfNoneMatch(IReadOnlyList? ifNoneMatch) => new(null, ifNoneMatch); + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/BlobCache/IBlobCacheWithRenwals.cs b/src/Imazen.Abstractions/BlobCache/IBlobCacheWithRenwals.cs new file mode 100644 index 00000000..6dd17975 --- /dev/null +++ b/src/Imazen.Abstractions/BlobCache/IBlobCacheWithRenwals.cs @@ -0,0 +1,14 @@ +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; + +namespace Imazen.Abstractions.BlobCache +{ + /// + /// Useful if your blob storage provider requires manual renewal of blobs to keep them from being evicted. + /// + [Obsolete] + public interface IBlobCacheWithRenewals : IBlobCache + { + Task CacheRenewEntry(IBlobStorageReference entry, CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/BlobStore/IBlobStore.cs b/src/Imazen.Abstractions/BlobStore/IBlobStore.cs new file mode 100644 index 00000000..a3101c62 --- /dev/null +++ b/src/Imazen.Abstractions/BlobStore/IBlobStore.cs @@ -0,0 +1,7 @@ +namespace Imazen.Abstractions.BlobStore; + +internal interface IBlobStore +{ + // TODO: design an interface suitable for uploading image originals + // Bonus goal: Offer a simpler way to implement IBlobCache using default path logic. +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/BlobAttributes.cs b/src/Imazen.Abstractions/Blobs/BlobAttributes.cs new file mode 100644 index 00000000..a689cfc8 --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/BlobAttributes.cs @@ -0,0 +1,59 @@ +using Imazen.Common.Extensibility.Support; + +namespace Imazen.Abstractions.Blobs; + +// TODO, maybe move to IBlobAttributes for delegation? + +public interface IBlobAttributes : IEstimateAllocatedBytesRecursive +{ + string? Etag { get; } + string? ContentType { get; } + + [Obsolete("Can be inaccurate, not based on actual stream length")] + + long? BlobByteCount { get; } + + public DateTimeOffset? LastModifiedDateUtc { get; } + + /// + /// Tags to apply when caching this blob or auditing access (in addition to those specified in the originating request). + /// + IReadOnlyList? StorageTags { get; } + + /// + /// Useful if your blob storage provider requires manual renewal of blobs to keep them from being evicted. Use with IBlobCacheWithRenewals. + /// + DateTimeOffset? EstimatedExpiry { get; } + + // intrinsic metadata, + // custom metadata (e.g. EXIF) + + // Task GetMetadataAsync() + + + IBlobStorageReference? BlobStorageReference { get; } + +} + +public record class BlobAttributes : IBlobAttributes +{ + public string? Etag { get; init; } + public string? ContentType { get; init; } + + public long? BlobByteCount { get; init; } + public DateTimeOffset? LastModifiedDateUtc { get; init; } + public IReadOnlyList? StorageTags { get; init; } + public DateTimeOffset? EstimatedExpiry { get; init; } + + public IBlobStorageReference? BlobStorageReference { get; set; } + + public int EstimateAllocatedBytesRecursive => + Etag.EstimateMemorySize(true) + + ContentType.EstimateMemorySize(true) + + BlobByteCount.EstimateMemorySize(true) + + LastModifiedDateUtc.EstimateMemorySize(true) + + EstimatedExpiry.EstimateMemorySize(true) + + 8 + BlobStorageReference?.EstimateAllocatedBytesRecursive ?? 0 + + StorageTags.EstimateMemorySize(true); + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/BlobResult.cs b/src/Imazen.Abstractions/Blobs/BlobResult.cs new file mode 100644 index 00000000..0ae093d2 --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/BlobResult.cs @@ -0,0 +1,21 @@ +global using IBlobResult = Imazen.Abstractions.Resulting.IDisposableResult; + +using Imazen.Abstractions.Resulting; + +namespace Imazen.Abstractions.Blobs; + + +public class BlobResult : DisposableResult +{ + private BlobResult(IBlobWrapper okValue, bool disposeValue) : base(okValue, disposeValue) + { + } + + private BlobResult(bool ignored, HttpStatus errorValue, bool disposeError) : base(false, errorValue, disposeError) + { + } + + public new static BlobResult Ok(IBlobWrapper okValue, bool disposeValue) => new BlobResult(okValue, disposeValue); + + public new static BlobResult Err(HttpStatus errorValue, bool disposeError) => new BlobResult(false, errorValue, disposeError); +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/BlobWrapper.cs b/src/Imazen.Abstractions/Blobs/BlobWrapper.cs new file mode 100644 index 00000000..ea66ec18 --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/BlobWrapper.cs @@ -0,0 +1,187 @@ +namespace Imazen.Abstractions.Blobs +{ + /// + /// Provides access to a blob stream that can only be used once, + /// and not shared. We should figure out ownership and disposal semantics. + /// + public interface IBlobWrapper : IDisposable //TODO: Change to IAsyncDisposable if anyone needs that (network streams)? + { + IBlobAttributes Attributes { get; } + + bool IsNativelyReusable { get; } + + bool CanTakeReusable { get; } + + ValueTask TakeReusable(IReusableBlobFactory factory, CancellationToken cancellationToken = default); + + ValueTask EnsureReusable(IReusableBlobFactory factory, CancellationToken cancellationToken = default); + + bool CanTakeConsumable { get; } + + IConsumableBlob TakeConsumable(); + + bool CanCreateConsumable { get; } + + long? EstimateAllocatedBytes { get; } + + ValueTask CreateConsumable(IReusableBlobFactory factory, CancellationToken cancellationToken = default); + IConsumableBlob MakeOrTakeConsumable(); + } + + /// + /// TODO: make this properly thread-safe + /// + public class BlobWrapper : IBlobWrapper + { + private IConsumableBlob? consumable; + private IReusableBlob? reusable; + internal DateTime CreatedAtUtc { get; } + internal LatencyTrackingZone? LatencyZone { get; set; } + + public BlobWrapper(LatencyTrackingZone? latencyZone, IConsumableBlob consumable) + { + this.consumable = consumable; + this.Attributes = consumable.Attributes; + CreatedAtUtc = DateTime.UtcNow; + LatencyZone = latencyZone; + } + public BlobWrapper(LatencyTrackingZone? latencyZone, IReusableBlob reusable) + { + this.reusable = reusable; + this.Attributes = reusable.Attributes; + CreatedAtUtc = DateTime.UtcNow; + LatencyZone = latencyZone; + } + [Obsolete("Use the constructor that takes a first parameter of LatencyTrackingZone, so that you " + + "can allow Imageflow Server to apply intelligent caching logic to this blob.")] + public BlobWrapper(IConsumableBlob consumable) + { + this.consumable = consumable; + this.Attributes = consumable.Attributes; + CreatedAtUtc = DateTime.UtcNow; + } + + + + public IBlobAttributes Attributes { get; } + public bool IsNativelyReusable => reusable != null; + + public bool CanTakeReusable => reusable != null || consumable != null; + + public long? EstimateAllocatedBytes => reusable?.EstimateAllocatedBytesRecursive; + + public async ValueTask TakeReusable(IReusableBlobFactory factory, CancellationToken cancellationToken = default) + { + if (reusable != null) + { + var r = reusable; + reusable = null; + return r; + } + + if (consumable != null) + { + IConsumableBlob c = consumable; + if (c != null) + { + try + { + consumable = null; + if (!c.StreamAvailable) + { + throw new InvalidOperationException("Cannot create a reusable blob from this wrapper, the consumable stream has already been taken"); + } + return await factory.ConsumeAndCreateReusableCopy(c, cancellationToken); + } + finally + { + c.Dispose(); + } + } + } + + throw new InvalidOperationException("Cannot take or create a reusable blob from this wrapper, it is empty"); + } + + public async ValueTask EnsureReusable(IReusableBlobFactory factory, CancellationToken cancellationToken = default) + { + if (reusable != null) return; + if (consumable != null) + { + IConsumableBlob c = consumable; + if (c != null) + { + try + { + consumable = null; + if (!c.StreamAvailable) + { + throw new InvalidOperationException("Cannot create a reusable blob from this wrapper, the consumable stream has already been taken"); + } + + reusable = await factory.ConsumeAndCreateReusableCopy(c, cancellationToken); + return; + } + finally + { + c.Dispose(); + } + } + } + + throw new InvalidOperationException("Cannot take or create a reusable blob from this wrapper, it is empty"); + } + + public bool CanTakeConsumable => consumable != null; + + public IConsumableBlob TakeConsumable() + { + if (consumable != null) + { + var c = consumable; + consumable = null; + return c; + } + + if (reusable != null) + { + throw new InvalidOperationException("Try TakeOrCreateConsumable(), -cannot take a consumable blob from this wrapper, it only contains a reusable one"); + + } + throw new InvalidOperationException("Cannot take a consumable blob from this wrapper, it is empty of both consumable and reusable"); + } + + public bool CanCreateConsumable => consumable != null || reusable != null; + + public async ValueTask CreateConsumable(IReusableBlobFactory factory, CancellationToken cancellationToken = default) + { + reusable ??= await TakeReusable(factory, cancellationToken); + return reusable.GetConsumable(); + + } + + public IConsumableBlob MakeOrTakeConsumable() + { + if (reusable != null) + { + var r = reusable; + reusable = null; + return r.GetConsumable(); + } + + return TakeConsumable(); + } + + + public void Dispose() + { + consumable?.Dispose(); + reusable?.Dispose(); + } + } + + + + +} + \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/ConsumableStreamBlob.cs b/src/Imazen.Abstractions/Blobs/ConsumableStreamBlob.cs new file mode 100644 index 00000000..689217bb --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/ConsumableStreamBlob.cs @@ -0,0 +1,75 @@ +namespace Imazen.Abstractions.Blobs; + +public enum DisposalPromise +{ + CallerDisposesStreamThenBlob = 1, + CallerDisposesBlobOnly = 2 +} + +public interface IConsumableBlob : IDisposable +{ + /// + /// The attributes of the blob, such as its content type + /// + IBlobAttributes Attributes { get; } + /// + /// If true, the stream is available and can be borrowed. + /// It can only be taken once. + /// + bool StreamAvailable { get; } + + /// + /// If not null, specifies the length of the stream in bytes + /// + long? StreamLength { get; } + + + /// + /// Borrows the stream from this blob wrapper. Can only be called once. The IConsumableBlob wrapper should not be disposed until after the stream is disposed as there may + /// be associated resources that need to be cleaned up. + /// + /// + Stream BorrowStream(DisposalPromise callerPromises); +} + +public interface IConsumableMemoryBlob : IConsumableBlob +{ + /// + /// Borrows a read only view of the memory from this blob wrapper. The IConsumableMemoryBlob wrapper should not be disposed until the Memory is no longer in use. + /// Can be called multiple times. May throw an ObjectDisposedException if the structure has been disposed already. + /// + ReadOnlyMemory BorrowMemory { get; } +} + +public sealed class ConsumableStreamBlob(IBlobAttributes attrs, Stream stream, IDisposable? disposeAfterStream = null) + : IConsumableBlob +{ + public IBlobAttributes Attributes { get; } = attrs; + private Stream? _stream = stream; + private DisposalPromise? disposalPromise = default; + private bool disposed = false; + + public void Dispose() + { + disposed = true; + if (disposalPromise != DisposalPromise.CallerDisposesStreamThenBlob) + { + _stream?.Dispose(); + _stream = null; + } + + disposeAfterStream?.Dispose(); + disposeAfterStream = null; + } + + public bool StreamAvailable => !disposalPromise.HasValue; + public long? StreamLength { get; } = stream.CanSeek ? (int?)stream.Length : null; + + public Stream BorrowStream(DisposalPromise callerPromises) + { + if (disposed) throw new ObjectDisposedException("The ConsumableBlob has been disposed"); + if (!StreamAvailable) throw new InvalidOperationException("Stream has already been taken"); + disposalPromise = callerPromises; + return _stream ?? throw new ObjectDisposedException("The ConsumableBlob has been disposed"); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/Drafts/IBlobRequest.cs b/src/Imazen.Abstractions/Blobs/Drafts/IBlobRequest.cs new file mode 100644 index 00000000..d1163122 --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/Drafts/IBlobRequest.cs @@ -0,0 +1,14 @@ +namespace Imazen.Abstractions.Blobs.Drafts +{ + internal interface IBlobRequest + { + IDictionary FinalQueryString { get; } + string FinalVirtualPath { get; } + IList SearchableTags { get; } + IDictionary ParsedComponents { get; } + + // TODO: Add owner id, decryption key lookup id, etc? + + } + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/Drafts/IBlobRequestOptions.cs b/src/Imazen.Abstractions/Blobs/Drafts/IBlobRequestOptions.cs new file mode 100644 index 00000000..c5b95c43 --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/Drafts/IBlobRequestOptions.cs @@ -0,0 +1,19 @@ +namespace Imazen.Abstractions.Blobs.Drafts +{ + internal interface IBlobRequestOptions + { + /// + /// Cache layers need to store these tags to be queried later, such as for + /// redaction, invalidation, or compliance/auditing. + /// There also may be tags in the source blob. so maybe this needs to be mutable? + /// + IList ApplyCacheTags { get; } + + bool FetchContentType { get; } + + bool FetchMetadata { get; } + + bool RequireReusable { get; } + + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/Drafts/IBlobRequestRouter.cs b/src/Imazen.Abstractions/Blobs/Drafts/IBlobRequestRouter.cs new file mode 100644 index 00000000..e4e4d057 --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/Drafts/IBlobRequestRouter.cs @@ -0,0 +1,6 @@ +namespace Imazen.Abstractions.Blobs.Drafts; + +internal interface IBlobRequestRouter +{ + IBlobSource GetSourceForRequest(IBlobRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/Drafts/IBlobRoutedRequest.cs b/src/Imazen.Abstractions/Blobs/Drafts/IBlobRoutedRequest.cs new file mode 100644 index 00000000..f20423b6 --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/Drafts/IBlobRoutedRequest.cs @@ -0,0 +1,6 @@ +namespace Imazen.Abstractions.Blobs.Drafts; + +internal interface IBlobRoutedRequest +{ + IDictionary RouteParts { get; } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/Drafts/IBlobSource.cs b/src/Imazen.Abstractions/Blobs/Drafts/IBlobSource.cs new file mode 100644 index 00000000..a4a3f91b --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/Drafts/IBlobSource.cs @@ -0,0 +1,8 @@ +namespace Imazen.Abstractions.Blobs.Drafts; + +internal interface IBlobSource +{ + string Description { get; } + + Task Fetch(IBlobRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Imazen.Abstractions/Blobs/Drafts/IBlobSourceProvider.cs b/src/Imazen.Abstractions/Blobs/Drafts/IBlobSourceProvider.cs new file mode 100644 index 00000000..6e2d2fbf --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/Drafts/IBlobSourceProvider.cs @@ -0,0 +1,6 @@ +namespace Imazen.Abstractions.Blobs.Drafts; + +internal interface IBlobSourceProvider +{ + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/IBlobStorageReference.cs b/src/Imazen.Abstractions/Blobs/IBlobStorageReference.cs new file mode 100644 index 00000000..02f84e1e --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/IBlobStorageReference.cs @@ -0,0 +1,13 @@ +using Imazen.Common.Extensibility.Support; + +namespace Imazen.Abstractions.Blobs +{ + public interface IBlobStorageReference : IEstimateAllocatedBytesRecursive + { + + /// + /// Should include all information needed to uniquely identify the resource, such as the container/region/path, or computer/drive/path, etc. + /// + string GetFullyQualifiedRepresentation(); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/LatencyTrackingZone.cs b/src/Imazen.Abstractions/Blobs/LatencyTrackingZone.cs new file mode 100644 index 00000000..e3f3b57e --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/LatencyTrackingZone.cs @@ -0,0 +1,13 @@ +namespace Imazen.Abstractions.Blobs; + +/// +/// Should be unique to a specific remote server or folder on a server. +/// For example, each container or bucket should provide a unique tracking zone. +/// Each mapped folder for local or network files should provide a unique tracking zone. +/// Each remote server should provide a unique tracking zone. +/// This should not differ more than required, otherwise the self-tuning +/// capabilities of the cache logic will be degraded. +/// +/// +public record LatencyTrackingZone( + string TrackingZone, int DefaultMs, bool AlwaysShield = false); \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/LegacyProviders/BlobMissingException.cs b/src/Imazen.Abstractions/Blobs/LegacyProviders/BlobMissingException.cs new file mode 100644 index 00000000..1daa7f7d --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/LegacyProviders/BlobMissingException.cs @@ -0,0 +1,19 @@ +namespace Imazen.Abstractions.Blobs.LegacyProviders +{ + public class BlobMissingException : Exception + { + public BlobMissingException() + { + } + + public BlobMissingException(string message) + : base(message) + { + } + + public BlobMissingException(string message, Exception inner) + : base(message, inner) + { + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/LegacyProviders/IBlobWrapperProvider.cs b/src/Imazen.Abstractions/Blobs/LegacyProviders/IBlobWrapperProvider.cs new file mode 100644 index 00000000..83f2a2fc --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/LegacyProviders/IBlobWrapperProvider.cs @@ -0,0 +1,24 @@ +using Imazen.Abstractions.Resulting; + +namespace Imazen.Abstractions.Blobs.LegacyProviders +{ + public interface IBlobWrapperProvider : IUniqueNamed + { + IEnumerable GetPrefixes(); + + /// + /// Only called if one of GetPrefixes matches already. + /// + /// + /// + bool SupportsPath(string virtualPath); + + Task> Fetch(string virtualPath); + } + + public record BlobWrapperPrefixZone(string Prefix, LatencyTrackingZone LatencyZone); + public interface IBlobWrapperProviderZoned : IUniqueNamed + { + IEnumerable GetPrefixesAndZones(); + } +} diff --git a/src/Imazen.Common/BlobStorage/BlobMetadata.cs b/src/Imazen.Abstractions/Blobs/MetadataDraft/BlobMetadata.cs similarity index 95% rename from src/Imazen.Common/BlobStorage/BlobMetadata.cs rename to src/Imazen.Abstractions/Blobs/MetadataDraft/BlobMetadata.cs index b597e85e..135a7c03 100644 --- a/src/Imazen.Common/BlobStorage/BlobMetadata.cs +++ b/src/Imazen.Abstractions/Blobs/MetadataDraft/BlobMetadata.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Text; using System.Text.RegularExpressions; @@ -18,7 +16,7 @@ internal class BlobMetadata private const int MaxKeyLengthBytes = 128; private const int MaxValueLengthBytes = 256; private const int MaxTotalSizeBytes = 2048; // 2 KB - private Dictionary metadata; + private readonly Dictionary metadata; private int currentTotalSizeBytes; private static readonly Regex ValidKeyCharacters = new Regex("^[a-zA-Z0-9][a-zA-Z0-9-_\\.]*$"); @@ -39,7 +37,7 @@ public void Set(string key, string value) currentTotalSizeBytes += Encoding.UTF8.GetByteCount(key) + Encoding.UTF8.GetByteCount(value); } - public string Get(string key) + public string? Get(string key) { return metadata.ContainsKey(key) ? metadata[key] : null; } diff --git a/src/Imazen.Common/BlobStorage/BlobStorageException.cs b/src/Imazen.Abstractions/Blobs/MetadataDraft/BlobMetadataException.cs similarity index 92% rename from src/Imazen.Common/BlobStorage/BlobStorageException.cs rename to src/Imazen.Abstractions/Blobs/MetadataDraft/BlobMetadataException.cs index b710f47d..3abfc4f4 100644 --- a/src/Imazen.Common/BlobStorage/BlobStorageException.cs +++ b/src/Imazen.Abstractions/Blobs/MetadataDraft/BlobMetadataException.cs @@ -1,5 +1,3 @@ -using System; - namespace Imazen.Common.BlobStorage { internal class BlobMetadataException : Exception diff --git a/src/Imazen.Common/BlobStorage/IBlobMetadata.cs b/src/Imazen.Abstractions/Blobs/MetadataDraft/IBlobMetadata.cs similarity index 94% rename from src/Imazen.Common/BlobStorage/IBlobMetadata.cs rename to src/Imazen.Abstractions/Blobs/MetadataDraft/IBlobMetadata.cs index 3d9c19ab..6a79007a 100644 --- a/src/Imazen.Common/BlobStorage/IBlobMetadata.cs +++ b/src/Imazen.Abstractions/Blobs/MetadataDraft/IBlobMetadata.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace Imazen.Common.BlobStorage { diff --git a/src/Imazen.Abstractions/Blobs/ReusableArraySegmentBlob.cs b/src/Imazen.Abstractions/Blobs/ReusableArraySegmentBlob.cs new file mode 100644 index 00000000..712e1489 --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/ReusableArraySegmentBlob.cs @@ -0,0 +1,124 @@ +using Imazen.Common.Extensibility.Support; + +namespace Imazen.Abstractions.Blobs +{ + /// + /// Provides access to a blob that can be shared and + /// reused by multiple threads, + /// and supports creation of multiple read streams to the backing memory. + /// The instance should never be disposed except by the Imageflow runtime itself. + /// + public interface IReusableBlob : IDisposable, IEstimateAllocatedBytesRecursive + { + IBlobAttributes Attributes { get; } + long StreamLength { get; } + IConsumableBlob GetConsumable(); + } + + public interface ISimpleReusableBlob: IReusableBlob + { + internal Stream CreateReadStream(); + internal bool IsDisposed { get; } + } + public sealed class ReusableArraySegmentBlob : ISimpleReusableBlob + { + public IBlobAttributes Attributes { get; } + private ArraySegment? data; + private bool disposed = false; + public TimeSpan CreationDuration { get; init; } + public DateTime CreationCompletionUtc { get; init; } = DateTime.UtcNow; + + public ReusableArraySegmentBlob(ArraySegment data, IBlobAttributes metadata, TimeSpan creationDuration) + { + CreationDuration = creationDuration; + this.data = data; + this.Attributes = metadata; + // Precalculate since it will be called often + EstimateAllocatedBytesRecursive = + 24 + Attributes.EstimateAllocatedBytesRecursive + + 24 + (data.Array?.Length ?? 0); + } + + public int EstimateAllocatedBytesRecursive { get; } + + public Stream CreateReadStream() + { + if (IsDisposed || data == null) + { + throw new ObjectDisposedException("The ReusableArraySegmentBlob has been disposed"); + } + var d = this.data.Value; + if (d.Count == 0 || d.Array == null) + { + return new MemoryStream(0); + } + return new MemoryStream(d.Array, d.Offset, d.Count, false); + } + + public IConsumableBlob GetConsumable() + { + if (IsDisposed) + { + throw new ObjectDisposedException("The ReusableArraySegmentBlob has been disposed"); + } + return new ConsumableWrapperForReusable(this); + } + + public bool IsDisposed => disposed; + public long StreamLength => IsDisposed ? throw new ObjectDisposedException("The ReusableArraySegmentBlob has been disposed") : data?.Count ?? 0; + public void Dispose() + { + disposed = true; + data = null; + } + } + + internal sealed class ConsumableWrapperForReusable(ISimpleReusableBlob reusable) : IConsumableBlob + { + //private Stream? stream; + private DisposalPromise? disposalPromise = default; + private bool Taken => disposalPromise.HasValue; + private bool disposed = false; + private Stream? stream; + public void Dispose() + { + disposed = true; + if (disposalPromise != DisposalPromise.CallerDisposesBlobOnly) + { + stream?.Dispose(); + stream = null; + } + } + + public IBlobAttributes Attributes => + reusable.IsDisposed + ? throw new ObjectDisposedException("The underlying reusable blob has been disposed") + : disposed ? throw new ObjectDisposedException("The consumable wrapper has been disposed") + : reusable.Attributes; + + public bool StreamAvailable => !Taken && !disposed && !(reusable?.IsDisposed ?? true); + public long? StreamLength => + reusable.IsDisposed + ? throw new ObjectDisposedException("The underlying reusable blob has been disposed") + : disposed ? throw new ObjectDisposedException("The consumable wrapper has been disposed") + : reusable.StreamLength; + + + public Stream BorrowStream(DisposalPromise callerPromises) + { + if (Taken) + { + throw new InvalidOperationException("The stream has already been taken"); + } + disposalPromise = callerPromises; + var s = reusable.IsDisposed + ? throw new ObjectDisposedException("The underlying reusable blob has been disposed") + : disposed ? + throw new ObjectDisposedException("The consumable wrapper has been disposed") + : reusable.CreateReadStream(); + + stream = s; + return s; + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/SearchableBlobTag.cs b/src/Imazen.Abstractions/Blobs/SearchableBlobTag.cs new file mode 100644 index 00000000..79f84d3a --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/SearchableBlobTag.cs @@ -0,0 +1,129 @@ +using Imazen.Common.Extensibility.Support; + +namespace Imazen.Abstractions.Blobs; + +// TODO: would be nice to keep this in u8 to halve memory usage. + + +/// +/// For indexed tags (s3 tags are not, and don't count) +/// These are also stored as metadata. They are not subject to the same encryption as blob contents +/// and should not be used to store PHI or sensitive data. Remember to use seeded fingerprints +/// of paths if you're trying to make it possible to query variants by path without making the path +/// visible as metadata. +/// These should be limited to the subset supported by all providers as both tags and metadata. +/// +public readonly record struct SearchableBlobTag : IEstimateAllocatedBytesRecursive +{ + private SearchableBlobTag(string key, string value) + { + Key = key; + Value = value; + } + public string Key { get; init; } + public string Value { get; init; } + + public KeyValuePair ToKeyValuePair() => new(Key, Value); + + public int EstimateAllocatedBytesRecursive => + Key.EstimateMemorySize(true) + Value.EstimateMemorySize(true) + 8; + + private static bool ValidateChars(string keyOrValue) + { + // In S3/Azure storage tags, Allowed chars are: a-z, A-Z, 0-9, space, +, -, =, ., _, :, / + //TODO In metadata, rules vary. Azure metadata must be valid C# identifier + // Most must be valid HTTP 1.1 headers and the key may or may not be case insensitive. + // keys can be ^[a-zA-Z0-9][a-zA-Z0-9-_\\.]*$ + + foreach (var c in keyOrValue) + { + switch (c) + { + case >= 'a' and <= 'z': + case >= 'A' and <= 'Z': + case >= '0' and <= '9': + case ' ': + case '+': + case '-': + case '=': + case '.': + case '_': + case ':': + case '/': + // case '@': (Azure only) + continue; + default: + return false; + } + } + return true; + } + + public bool IsStrictlyValid() + { + return Key.Length is >= 1 and <= 128 && + Value.Length <= 256 && + ValidateChars(Key) && + ValidateChars(Value); + } + + public static SearchableBlobTag CreateAndValidateStrict(string key, string value) + { + if (key.Length is < 1 or > 128) + throw new ArgumentException("Tag key must be between 1 and 128 characters", nameof(key)); + if (value.Length > 256) + throw new ArgumentException("Tag value must be between 0 and 256 characters", nameof(value)); + if (!ValidateChars(key)) + throw new ArgumentException("Tag key contains invalid characters. Allowed chars are: a-z, A-Z, 0-9, space, +, -, =, ., _, :, /", nameof(key)); + if (!ValidateChars(value)) + throw new ArgumentException("Tag value contains invalid characters. Allowed chars are: a-z, A-Z, 0-9, space, +, -, =, ., _, :, /", nameof(value)); + return new SearchableBlobTag(key, value); + } + + + + public static SearchableBlobTag CreateUnvalidated(string key, string value) + { + return new SearchableBlobTag(key, value); + } + + +} + + + // Amazon S3 + // You can add tags to new objects when you upload them, or you can add them to existing objects. + // + // You can associate up to 10 tags with an object. Tags that are associated with an object must have unique tag keys. + // + // A tag key can be up to 128 Unicode characters in length, and tag values can be up to 256 Unicode characters in length. Amazon S3 object tags are internally represented in UTF-16. Note that in UTF-16, characters consume either 1 or 2 character positions. + // + // The key and values are case sensitive. + // AWS services allow: letters (a-z, A-Z), numbers (0-9), and spaces representable in UTF-8, and the following characters: + - = . _ : / @. + // Azure + // The following limits apply to blob index tags: + // + // Each blob can have up to 10 blob index tags + // + // Tag keys must be between one and 128 characters. + // + // Tag values must be between zero and 256 characters. + // + // Tag keys and values are case-sensitive. + // + // Tag keys and values only support string data types. Any numbers, dates, times, or special characters are saved as strings. + // + // If versioning is enabled, index tags are applied to a specific version of blob. If you set index tags on the current version, and a new version is created, then the tag won't be associated with the new version. The tag will be associated only with the previous version. + // + // Tag keys and values must adhere to the following naming rules: + // + // Alphanumeric characters: + // + // a through z (lowercase letters) + // + // A through Z (uppercase letters) + // + // 0 through 9 (numbers) + // + // Valid special characters: space, plus, minus, period, colon, equals, underscore, forward slash ( +-.:=_/ + \ No newline at end of file diff --git a/src/Imazen.Abstractions/Blobs/SimpleReusableBlobFactory.cs b/src/Imazen.Abstractions/Blobs/SimpleReusableBlobFactory.cs new file mode 100644 index 00000000..37e9936f --- /dev/null +++ b/src/Imazen.Abstractions/Blobs/SimpleReusableBlobFactory.cs @@ -0,0 +1,49 @@ +using System.Diagnostics; +using System.Net.Http.Headers; + +namespace Imazen.Abstractions.Blobs; + +/// +/// Implementations should be thread-safe and disposable. Since this will be (the?) main source +/// for long-lived large allocations, various strategies can be implemented - such as a custom +/// read-only stream backed by a pool of memory chunks. When all streams to the data are disposed, the +/// chunks could be released into a pool for reuse. +/// +public interface IReusableBlobFactory : IDisposable +{ + ValueTask ConsumeAndCreateReusableCopy(IConsumableBlob consumableBlob, + CancellationToken cancellationToken = default); +} +/// +/// This could utilize different backing stores such as a memory pool, releasing blobs to the pool when they (and all their created streams) +/// are disposed +/// +public class SimpleReusableBlobFactory: IReusableBlobFactory +{ + public async ValueTask ConsumeAndCreateReusableCopy(IConsumableBlob consumableBlob, + CancellationToken cancellationToken = default) + { + using (consumableBlob) + { + var sw = Stopwatch.StartNew(); +#if NETSTANDARD2_1_OR_GREATER + await using var stream = consumableBlob.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob); +#else + using var stream = consumableBlob.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob); +#endif + var ms = new MemoryStream(stream.CanSeek ? (int)stream.Length : 4096); + await stream.CopyToAsync(ms, 81920, cancellationToken); + ms.Position = 0; + var byteArray = ms.ToArray(); + var arraySegment = new ArraySegment(byteArray); + sw.Stop(); + var reusable = new ReusableArraySegmentBlob(arraySegment, consumableBlob.Attributes, sw.Elapsed); + return reusable; + } + } + + public void Dispose() + { + // MemoryStreams have no unmanaged resources, and the garbage collector is optimal for them. + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Concurrency/ConcurrencyHelpers.cs b/src/Imazen.Abstractions/Concurrency/ConcurrencyHelpers.cs new file mode 100644 index 00000000..81e1de14 --- /dev/null +++ b/src/Imazen.Abstractions/Concurrency/ConcurrencyHelpers.cs @@ -0,0 +1,114 @@ +namespace Imazen.Routing.Helpers; + +public static class ConcurrencyHelpers +{ + /// + /// Bubbles up exceptions and cancellations, and returns the first task that matches the predicate. + /// If no task matches, returns default(TRes) + /// + /// + /// + /// + /// + /// + public static Task WhenAnyMatchesOrDefault( + List> allTasks, + Func predicate, + CancellationToken cancellationToken = default) + { + if (allTasks.Count == 0) + { + throw new ArgumentException("At least one task must be provided", nameof(allTasks)); + } + var taskCompletionSource = new TaskCompletionSource(); + var cancellationRegistration = cancellationToken.Register(() => taskCompletionSource.TrySetCanceled()); + var allTasksCount = allTasks.Count; + // We can use the same closure for every task, since it doesn't capture any task-specific state, + // just thee predicate, taskCompletionSource, cancellationRegistration, and allTasksCount + Action> continuation = t => + { + if (t.IsFaulted) + { + //We don't touch exceptions; the caller can iterate the tasks to find exceptions + //taskCompletionSource.TrySetException(t.Exception!.InnerExceptions); + } + else if (t.IsCanceled) + { + // The caller handles all cancellations. They give us tasks preconfigured with cancellation tokens + // the caller controls. Our cancellation token is just a signal to stop waiting for results. + // taskCompletionSource.TrySetCanceled(); + } + else if (predicate(t.Result)) + { + taskCompletionSource.TrySetResult(t.Result); + } + + // if all tasks have failed to match, we'll set the result to null + if (Interlocked.Decrement(ref allTasksCount) == 0) + { + // We can de-register listening for cancellation, since we're done + cancellationRegistration.Dispose(); + taskCompletionSource.TrySetResult(default); + + } + }; + + // run all simultaneously, and set the result when one completes and matches the predicate + foreach (var task in allTasks) + { + // I guess it get scheduled anyway? + task.ContinueWith(continuation, cancellationToken, TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + return taskCompletionSource.Task; + } + + + /// + /// This alternate implementation uses Task.Any in a loop + /// + public static async Task WhenAnyMatchesOrDefault2( + List> allTasks, + Func predicate, + CancellationToken cancellationToken = default) + { + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + var completedTasks = allTasks.Where(t => t.IsCompleted).ToList(); + var match = completedTasks.FirstOrDefault( + t => t.Status == TaskStatus.RanToCompletion && predicate(t.Result)); + if (match != null) + { + return match.Result; + } + + // If all tasks are completed (faulted, cancelled, or success), and none match, return default + var incompleteTasks = allTasks.Where(t => !t.IsCompleted).ToList(); + if (incompleteTasks.Count == 0) + { + return default; + } + + // Wait for any task to complete + var taskComplete = await Task.WhenAny(incompleteTasks).ConfigureAwait(false); + // If it didn't succeed and meet criteria, try again + if (taskComplete.Status != TaskStatus.RanToCompletion) + continue; + // If it did succeed and meet criteria, return the result + var result = taskComplete.Result; + if (predicate(result)) + { + return result; + } + + } + + } + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Concurrency/NonOverlappingAsyncRunner.cs b/src/Imazen.Abstractions/Concurrency/NonOverlappingAsyncRunner.cs new file mode 100644 index 00000000..cbb18bf4 --- /dev/null +++ b/src/Imazen.Abstractions/Concurrency/NonOverlappingAsyncRunner.cs @@ -0,0 +1,420 @@ +using Microsoft.Extensions.Hosting; + +namespace Imazen.Routing.Caching.Health; + +//// +/// This class constructor accepts a Func<CancellationToken, Task<T>> parameter and a timeout. The func always runs via Task.Run, even if initiated via RunNonOverlapping. +/// This class exposes a method RunNonOverlapping function that accepts a cancellation token (if this one is cancelled, the task returns early but the function keeps running). +/// This class only cancels the background function if StopAsync(CancellationToken ct) is called or it is disposed (it implements async dispose). +/// If RunNonOverlapping is called, and the background task is not running, it is scheduled with Task.Run +/// RunNonOverlapping returns a task that completes when the background task completes, fails, or is cancelled. These results are passed through. + +public class NonOverlappingAsyncRunner( + Func> taskFactory, + bool taskMustBeDisposed = false, + TimeSpan timeout = default, + CancellationToken cancellationToken = default) + : IHostedService, IDisposable, IAsyncDisposable +{ + private CancellationTokenSource? taskCancellation; + private readonly object taskInitLock = new object(); + private TaskCompletionSource? taskStatus; + private Task? task; + private bool stopped; + private bool stopping; + public Task StopAsync(CancellationToken stopWaitingForCancellationToken = default) + => StopAsync(Timeout.InfiniteTimeSpan, stopWaitingForCancellationToken); + + public async Task StopAsync(TimeSpan timeout, CancellationToken stopWaitingForCancellationToken = default) + { + + try + { + Task? asyncCancelTask = null; + if (stopWaitingForCancellationToken.IsCancellationRequested) + { + return; + } + + lock (taskInitLock) + { + stopping = true; +#if NET8_0_OR_GREATER + asyncCancelTask = taskCancellation?.CancelAsync(); +#else + taskCancellation?.Cancel(); +#endif + } + try + { + if (stopWaitingForCancellationToken.IsCancellationRequested) + { + return; + } + // We need to respect the stopWaitingForCancellationToken above waiting for the task to complete + // but if nothing is running, Task.CompletedTask is returned, so we can't await it. + if (asyncCancelTask != null && asyncCancelTask is { IsCompleted: false }) + { + await Task.WhenAny(asyncCancelTask, + Task.Delay(timeout, stopWaitingForCancellationToken)).ConfigureAwait(false); + + // This doesn't handle the waiting for activation case. + } + + await RunNonOverlappingAsyncInternal(true, timeout, stopWaitingForCancellationToken) + .ConfigureAwait(false); + } + catch (TaskCanceledException) + { + // Ignore + } + catch (OperationCanceledException) + { + // Ignore + } + + }finally + { + // even if canceled/faulted, we're done + stopped = true; + DisposeTaskAndCancellation(); + } + } + + private void DisposeTaskAndCancellation() + { + lock (taskInitLock) + { + try + { + if (task != null) + { + var taskStatusCopy = task.Status; + if (taskStatusCopy is TaskStatus.RanToCompletion or TaskStatus.Canceled or TaskStatus.Faulted) + { + // Dispose says it can be used with Cancel + task?.Dispose(); + } + else if (taskMustBeDisposed) + { + throw new InvalidOperationException($"Task status was {taskStatusCopy}, and cannot be disposed."); + } + } + + task = null; + } + finally + { + if (taskCancellation != null) + { + if (taskCancellation.IsCancellationRequested) + { + taskCancellation.Dispose(); + } + else + { + taskCancellation.Cancel(); + } + + taskCancellation.Dispose(); + taskCancellation = null; + } + } + } + } + public void Dispose() + { + try + { + var t = StopAsync(); + t.Wait(5); + } + finally + { + stopped = true; + DisposeTaskAndCancellation(); + } + } + + public async ValueTask DisposeAsync() + { + try + { + stopping = true; + await StopAsync().ConfigureAwait(false); + } + finally + { + stopped = true; + taskCancellation?.Dispose(); + task?.Dispose(); + taskCancellation = null; + task = null; + } + } + + /// + /// Returns true if the completedSyncResult value should be used instead + /// of using taskStatus. + /// + /// + /// + private bool StartTask(out T completedSyncResult) + { + if (taskCancellation is { IsCancellationRequested: true }) + { + completedSyncResult = default!; + return false; + } + lock (taskInitLock) + { + if (stopping | stopped) throw new InvalidOperationException("Cannot start a task after StopAsync or Dispose has been called"); + if (taskStatus != null) + { + completedSyncResult = default!; + return false; + } + + if (taskCancellation is not { IsCancellationRequested: true }) + { + // Create if missing + if (taskCancellation == null) + { + taskCancellation?.Dispose(); + taskCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + } + // And reset the timer. We reset the cancellation token when we set taskStatus == null, + // which clears the timer. + if (timeout != default && timeout != TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan) + { + taskCancellation.CancelAfter(timeout); + } + } + + ValueTask innerTask = taskFactory(taskCancellation.Token); + if (innerTask.IsCompleted) + { + completedSyncResult = innerTask.Result; + ResetCancellationTokenSourceUnsynchronized(); + return true; + } + taskStatus = new TaskCompletionSource(); + task = Task.Run(async () => + { + if (taskCancellation is { IsCancellationRequested: true }) + { + taskStatus.TrySetCanceled(); + return; + } + try + { + + var result = await innerTask.ConfigureAwait(false); + taskStatus.TrySetResult(result); + } + catch (OperationCanceledException) + { + taskStatus.TrySetCanceled(); + } + catch (Exception ex) + { + taskStatus.TrySetException(ex); + } + finally + { + lock (taskInitLock) + { + taskStatus = null; + ResetCancellationTokenSourceUnsynchronized(); + } + } + }, taskCancellation.Token); + completedSyncResult = default!; + return false; + } + } + private void ResetCancellationTokenSourceUnsynchronized() + { +#if NET6_0_OR_GREATER + taskCancellation?.TryReset(); // Reset the timer +#else + + var t = taskCancellation; + t?.Dispose(); + taskCancellation = null; +#endif + } + + /// + /// Fires off the task and returns a value only if the function response synchronously instead of asynchronously. + /// Ignored if we are stopping or stopped. + /// + /// The type of the result. + /// Returns a value only if the function response synchronously instead of asynchronously.. + public T? FireAndForget() + { + if (stopping || stopped) return default; + if (taskStatus == null && StartTask(out var completedSyncResult2)) + { + return completedSyncResult2; + } + return default; + } + + /// + /// This method returns a task that completes when the background task completes, fails, or is cancelled. The results are passed through. + /// It won't start the background task if it is already running. If the background task is not running, it is scheduled with Task.Run. + /// Previous results are never used, only in-progress or new results. + /// + /// This task will return a cancelled task if the proxyTimeout is reached. The background task will not be affected. + /// This task will return a cancelled task if the proxyCancellation is cancelled. The background task will not be affected. + /// + /// Thrown when the timeout is reached or the cancellation token is activated + /// Any exceptions thrown by the underlying task + public ValueTask RunNonOverlappingAsync(TimeSpan proxyTimeout = default, + CancellationToken proxyCancellation = default) + { + + if (stopping || stopped) throw new ObjectDisposedException(nameof(NonOverlappingAsyncRunner)); + return RunNonOverlappingAsyncInternal(false, proxyTimeout, proxyCancellation); + } + + // TODO, probably remove this duplicate implementation + private ValueTask CreateLinkedTaskWithTimeoutAndCancellation(Task newTask, TimeSpan proxyTimeout, CancellationToken proxyCancellation) + { + // We have to create a linked task that will cancel if either the proxy or the constructor cancellation token is cancelled + // or if the proxy timeout is reached + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, proxyCancellation); + if (proxyTimeout != default && proxyTimeout != Timeout.InfiniteTimeSpan) + { + linkedCts.CancelAfter(proxyTimeout); + } + var linkedToken = linkedCts.Token; + // Now we have to return a task that completes when the task completes, or when the proxy is cancelled + // linkedToken should not cancel the original task, just the proxy + // We can use WhenAny, but we have to do logic on the result to expose the result (or throw) + return new ValueTask(Task.WhenAny(newTask, Task.Delay(Timeout.InfiniteTimeSpan, linkedToken)).ContinueWith(taskWrapper => + { + try + { + // taskWrapper.Result will be the task that completed, or the delay task. + // taskWrapper does RunToCompletion even if one faults. + // t is firstOfTheTasks + var t = taskWrapper.Result; + if (t.IsCanceled) + { + throw new OperationCanceledException(linkedToken); + } + else if (t.IsFaulted) + { + // The outer task will still throw an AggregateException, + // but at least it won't be as nested? + throw t.Exception!.InnerExceptions.Count == 1 + ? t.Exception!.InnerExceptions[0] + : t.Exception!; + } + else + { + // Task.Delay will never return since we gave it infinite time, it only faults or cancels + return newTask.Result; + } + } + finally + { + linkedCts.Dispose(); + } + }, linkedToken)); + } + + // This variant will use await so that exceptions are unwrapped and users don't deal with AggregateExceptions + private async ValueTask CreateLinkedTaskWithTimeoutAndCancellationAsync(Task newTask, TimeSpan proxyTimeout, + CancellationToken proxyCancellation) + { + // We have to create a linked task that will cancel if either the proxy or the constructor cancellation token is cancelled + // or if the proxy timeout is reached + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, proxyCancellation); + if (proxyTimeout != default && proxyTimeout != Timeout.InfiniteTimeSpan) + { + linkedCts.CancelAfter(proxyTimeout); + } + var linkedToken = linkedCts.Token; + // Now we have to return a task that completes when the task completes, or when the proxy is cancelled + // linkedToken should not cancel the original task, just the proxy + // We can use WhenAny, but we have to do logic on the result to expose the result (or throw) + try + { + // taskWrapper.Result will be the task that completed, or the delay task. + // taskWrapper does RunToCompletion even if one faults. + var firstOfTheTasks = await Task.WhenAny(newTask, Task.Delay(Timeout.InfiniteTimeSpan, linkedToken)); + // determine which task completed + if (firstOfTheTasks == newTask) + { + return await newTask; + } + else + { + // Task.Delay will never return since we gave it infinite time, it only faults or cancels + await firstOfTheTasks; + throw new InvalidOperationException("Task.Delay should never return"); + } + } + finally + { + linkedCts.Dispose(); + } + } + + + private ValueTask RunNonOverlappingAsyncInternal(bool stoppingWaiting, TimeSpan proxyTimeout = default, CancellationToken proxyCancellation = default) + { + lock (taskInitLock) + { + if (proxyTimeout == Timeout.InfiniteTimeSpan) proxyTimeout = default; + + // If we're in wait-for-existing-tasks mode, we can't start a new task + if (stoppingWaiting && taskStatus == null) + { + return new ValueTask(default(T)!); + } + + if (proxyCancellation != default || proxyTimeout != default) + { + if (taskStatus == null) + { + if (StartTask(out var completedSyncResult)) + { + // It completed synchronously, so we can just return the result + return new ValueTask(completedSyncResult); + } + } + if (taskStatus != null) + { + return CreateLinkedTaskWithTimeoutAndCancellationAsync(taskStatus.Task, proxyTimeout, proxyCancellation); + } + // StartTask should have set taskStatus + throw new InvalidOperationException("Task.Delay should never return"); + } + // No proxy cancellation or timeout? Ee can just return the task + if (taskStatus != null) + { + return new ValueTask(taskStatus.Task); + } + if (stoppingWaiting) + { + throw new InvalidOperationException("Unreachable code"); + } + return StartTask(out var completedSyncResult2) ? new ValueTask(completedSyncResult2) : new ValueTask(taskStatus!.Task); + } + } + + + public Task StartAsync(CancellationToken cancellation) + { + return Task.CompletedTask; + } + + + +} + + \ No newline at end of file diff --git a/src/Imazen.Abstractions/DependencyInjection/IImageServerContainer.cs b/src/Imazen.Abstractions/DependencyInjection/IImageServerContainer.cs new file mode 100644 index 00000000..84fc3fca --- /dev/null +++ b/src/Imazen.Abstractions/DependencyInjection/IImageServerContainer.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Imazen.Abstractions.DependencyInjection; + +public interface IImageServerContainer : IServiceProvider +{ + void Register(Func instanceCreator) where TService : class; + void Register(TService instance) where TService : class; + IEnumerable Resolve(); + + IServiceProvider? GetOuterProvider(); + + IEnumerable GetInstanceOfEverythingLocal(); + bool Contains(); +} + +// TODO: Make this AOT friendly +public class ImageServerContainer(IServiceProvider? outerProvider) : IImageServerContainer +{ + private readonly Dictionary> services = new(); + + public IServiceProvider? GetOuterProvider() + { + return outerProvider; + } + + public IEnumerable GetInstanceOfEverythingLocal() + { + return services.SelectMany(kvp => kvp.Value.Where(v => v is T).Cast()); + } + + public bool Contains() + { + return services.ContainsKey(typeof(T)); + } + + public void Register(Func instanceCreator) where TService : class + { + var instance = instanceCreator(); + if (instance == null) throw new ArgumentNullException(nameof(instanceCreator)); + if (services.ContainsKey(typeof(TService))) + { + services[typeof(TService)].Add(instance); + } + else + { + services[typeof(TService)] = [instance]; + } + } + public void RegisterAll(IEnumerable instances) + { + if (services.ContainsKey(typeof(TService))) + { + services[typeof(TService)].AddRange(instances.Cast()); + } + else + { + services[typeof(TService)] = instances.Cast().ToList(); + } + } + + public void Register(TService instance) where TService : class + { + if (instance == null) throw new ArgumentNullException(nameof(instance)); + if (services.ContainsKey(typeof(TService))) + { + services[typeof(TService)].Add(instance); + } + else + { + services[typeof(TService)] = [instance]; + } + } + + public IEnumerable Resolve() + { + return services[typeof(TService)].Cast(); + } + + + + public object? GetService(Type serviceType) + { + if (serviceType.IsGenericType && serviceType.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + var actualServiceType = serviceType.GetGenericArguments()[0]; + if (services.TryGetValue(actualServiceType, out var instances)) + { + var castMethod = typeof(Enumerable).GetMethod("Cast"); + var method = castMethod!.MakeGenericMethod(actualServiceType); + return method.Invoke(null, new object[] { instances }); + } + } + else if (services.TryGetValue(serviceType, out var instances)) + { + return instances.First(); + } + + return null; + } + + + public void CopyFromOuter() + { + var outer = GetOuterProvider(); + if (outer == null) return; + var outerInstances = outer.GetService>(); + RegisterAll(outerInstances); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/ArgumentNullThrowHelperPolyfill.cs b/src/Imazen.Abstractions/HttpStrings/ArgumentNullThrowHelperPolyfill.cs new file mode 100644 index 00000000..5e0c762d --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/ArgumentNullThrowHelperPolyfill.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Imazen.Abstractions.HttpStrings; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// Imazen licenses any changes to th + +#nullable enable + +internal static partial class ArgumentNullThrowHelper +{ + /// Throws an if is null. + /// The reference type argument to validate as non-null. + /// The name of the parameter with which corresponds. + public static void ThrowIfNull( +#if INTERNAL_NULLABLE_ATTRIBUTES || NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER + [NotNull] +#endif + object? argument, [CallerArgumentExpression("argument")] string? paramName = null) + { +#if !NET7_0_OR_GREATER || NETSTANDARD || NETFRAMEWORK + if (argument is null) + { + Throw(paramName); + } +#else + ArgumentNullException.ThrowIfNull(argument, paramName); +#endif + } + +#if !NET7_0_OR_GREATER || NETSTANDARD || NETFRAMEWORK +#if INTERNAL_NULLABLE_ATTRIBUTES || NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER + [DoesNotReturn] +#endif + internal static void Throw(string? paramName) => + throw new ArgumentNullException(paramName); +#endif +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/HttpMethodStrings.cs b/src/Imazen.Abstractions/HttpStrings/HttpMethodStrings.cs new file mode 100644 index 00000000..902f658d --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/HttpMethodStrings.cs @@ -0,0 +1,196 @@ +namespace Imazen.Abstractions.HttpStrings; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + + +/// +/// Contains methods to verify the request method of an HTTP request. +/// +public static class HttpMethodStrings +{ + // We are intentionally using 'static readonly' here instead of 'const'. + // 'const' values would be embedded into each assembly that used them + // and each consuming assembly would have a different 'string' instance. + // Using .'static readonly' means that all consumers get these exact same + // 'string' instance, which means the 'ReferenceEquals' checks below work + // and allow us to optimize comparisons when these constants are used. + + // Please do NOT change these to 'const' + /// + /// HTTP "CONNECT" method. + /// + public static readonly string Connect = "CONNECT"; + /// + /// HTTP "DELETE" method. + /// + public static readonly string Delete = "DELETE"; + /// + /// HTTP "GET" method. + /// + public static readonly string Get = "GET"; + /// + /// HTTP "HEAD" method. + /// + public static readonly string Head = "HEAD"; + /// + /// HTTP "OPTIONS" method. + /// + public static readonly string Options = "OPTIONS"; + /// + /// HTTP "PATCH" method. + /// + public static readonly string Patch = "PATCH"; + /// + /// HTTP "POST" method. + /// + public static readonly string Post = "POST"; + /// + /// HTTP "PUT" method. + /// + public static readonly string Put = "PUT"; + /// + /// HTTP "TRACE" method. + /// + public static readonly string Trace = "TRACE"; + + /// + /// Returns a value that indicates if the HTTP request method is CONNECT. + /// + /// The HTTP request method. + /// + /// if the method is CONNECT; otherwise, . + /// + public static bool IsConnect(string method) + { + return Equals(Connect, method); + } + + /// + /// Returns a value that indicates if the HTTP request method is DELETE. + /// + /// The HTTP request method. + /// + /// if the method is DELETE; otherwise, . + /// + public static bool IsDelete(string method) + { + return Equals(Delete, method); + } + + /// + /// Returns a value that indicates if the HTTP request method is GET. + /// + /// The HTTP request method. + /// + /// if the method is GET; otherwise, . + /// + public static bool IsGet(string method) + { + return Equals(Get, method); + } + + /// + /// Returns a value that indicates if the HTTP request method is HEAD. + /// + /// The HTTP request method. + /// + /// if the method is HEAD; otherwise, . + /// + public static bool IsHead(string method) + { + return Equals(Head, method); + } + + /// + /// Returns a value that indicates if the HTTP request method is OPTIONS. + /// + /// The HTTP request method. + /// + /// if the method is OPTIONS; otherwise, . + /// + public static bool IsOptions(string method) + { + return Equals(Options, method); + } + + /// + /// Returns a value that indicates if the HTTP request method is PATCH. + /// + /// The HTTP request method. + /// + /// if the method is PATCH; otherwise, . + /// + public static bool IsPatch(string method) + { + return Equals(Patch, method); + } + + /// + /// Returns a value that indicates if the HTTP request method is POST. + /// + /// The HTTP request method. + /// + /// if the method is POST; otherwise, . + /// + public static bool IsPost(string method) + { + return Equals(Post, method); + } + + /// + /// Returns a value that indicates if the HTTP request method is PUT. + /// + /// The HTTP request method. + /// + /// if the method is PUT; otherwise, . + /// + public static bool IsPut(string method) + { + return Equals(Put, method); + } + + /// + /// Returns a value that indicates if the HTTP request method is TRACE. + /// + /// The HTTP request method. + /// + /// if the method is TRACE; otherwise, . + /// + public static bool IsTrace(string method) + { + return Equals(Trace, method); + } + + /// + /// Returns the equivalent static instance, or the original instance if none match. This conversion is optional but allows for performance optimizations when comparing method values elsewhere. + /// + /// + /// + public static string GetCanonicalizedValue(string method) => method switch + { + string _ when IsGet(method) => Get, + string _ when IsPost(method) => Post, + string _ when IsPut(method) => Put, + string _ when IsDelete(method) => Delete, + string _ when IsOptions(method) => Options, + string _ when IsHead(method) => Head, + string _ when IsPatch(method) => Patch, + string _ when IsTrace(method) => Trace, + string _ when IsConnect(method) => Connect, + string _ => method + }; + + /// + /// Returns a value that indicates if the HTTP methods are the same. + /// + /// The first HTTP request method to compare. + /// The second HTTP request method to compare. + /// + /// if the methods are the same; otherwise, . + /// + public static bool Equals(string methodA, string methodB) + { + return object.ReferenceEquals(methodA, methodB) || StringComparer.OrdinalIgnoreCase.Equals(methodA, methodB); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/HttpProtocolStrings.cs b/src/Imazen.Abstractions/HttpStrings/HttpProtocolStrings.cs new file mode 100644 index 00000000..114633ae --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/HttpProtocolStrings.cs @@ -0,0 +1,125 @@ +namespace Imazen.Abstractions.HttpStrings; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + + +/// +/// Contains methods to verify the request protocol version of an HTTP request. +/// +public static class HttpProtocolStrings +{ + // We are intentionally using 'static readonly' here instead of 'const'. + // 'const' values would be embedded into each assembly that used them + // and each consuming assembly would have a different 'string' instance. + // Using .'static readonly' means that all consumers get these exact same + // 'string' instance, which means the 'ReferenceEquals' checks below work + // and allow us to optimize comparisons when these constants are used. + + // Please do NOT change these to 'const' + + /// + /// HTTP protocol version 0.9. + /// + public static readonly string Http09 = "HTTP/0.9"; + + /// + /// HTTP protocol version 1.0. + /// + public static readonly string Http10 = "HTTP/1.0"; + + /// + /// HTTP protocol version 1.1. + /// + public static readonly string Http11 = "HTTP/1.1"; + + /// + /// HTTP protocol version 2. + /// + public static readonly string Http2 = "HTTP/2"; + + /// + /// HTTP protcol version 3. + /// + public static readonly string Http3 = "HTTP/3"; + + /// + /// Returns a value that indicates if the HTTP request protocol is HTTP/0.9. + /// + /// The HTTP request protocol. + /// + /// if the protocol is HTTP/0.9; otherwise, . + /// + public static bool IsHttp09(string protocol) + { + return object.ReferenceEquals(Http09, protocol) || StringComparer.OrdinalIgnoreCase.Equals(Http09, protocol); + } + + /// + /// Returns a value that indicates if the HTTP request protocol is HTTP/1.0. + /// + /// The HTTP request protocol. + /// + /// if the protocol is HTTP/1.0; otherwise, . + /// + public static bool IsHttp10(string protocol) + { + return object.ReferenceEquals(Http10, protocol) || StringComparer.OrdinalIgnoreCase.Equals(Http10, protocol); + } + + /// + /// Returns a value that indicates if the HTTP request protocol is HTTP/1.1. + /// + /// The HTTP request protocol. + /// + /// if the protocol is HTTP/1.1; otherwise, . + /// + public static bool IsHttp11(string protocol) + { + return object.ReferenceEquals(Http11, protocol) || StringComparer.OrdinalIgnoreCase.Equals(Http11, protocol); + } + + /// + /// Returns a value that indicates if the HTTP request protocol is HTTP/2. + /// + /// The HTTP request protocol. + /// + /// if the protocol is HTTP/2; otherwise, . + /// + public static bool IsHttp2(string protocol) + { + return object.ReferenceEquals(Http2, protocol) || StringComparer.OrdinalIgnoreCase.Equals(Http2, protocol); + } + + /// + /// Returns a value that indicates if the HTTP request protocol is HTTP/3. + /// + /// The HTTP request protocol. + /// + /// if the protocol is HTTP/3; otherwise, . + /// + public static bool IsHttp3(string protocol) + { + return object.ReferenceEquals(Http3, protocol) || StringComparer.OrdinalIgnoreCase.Equals(Http3, protocol); + } + + /// + /// Gets the HTTP request protocol for the specified . + /// + /// The version. + /// A HTTP request protocol. + public static string GetHttpProtocol(Version version) + { + ArgumentNullThrowHelper.ThrowIfNull(version); + + return version switch + { + { Major: 3, Minor: 0 } => Http3, + { Major: 2, Minor: 0 } => Http2, + { Major: 1, Minor: 1 } => Http11, + { Major: 1, Minor: 0 } => Http10, + { Major: 0, Minor: 9 } => Http09, + _ => throw new ArgumentOutOfRangeException(nameof(version), "Version doesn't map to a known HTTP protocol.") + }; + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/KeyValueAccumulator.cs b/src/Imazen.Abstractions/HttpStrings/KeyValueAccumulator.cs new file mode 100644 index 00000000..e18d3bcd --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/KeyValueAccumulator.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Primitives; + +namespace Imazen.Abstractions.HttpStrings; +/// +/// This API supports infrastructure and is not intended to be used +/// directly from your code. This API may change or be removed in future releases. +/// +internal struct KeyValueAccumulator +{ + private Dictionary _accumulator; + private Dictionary> _expandingAccumulator; + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public void Append(string key, string value) + { + if (_accumulator == null) + { + _accumulator = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + StringValues values; + if (_accumulator.TryGetValue(key, out values)) + { + if (values.Count == 0) + { + // Marker entry for this key to indicate entry already in expanding list dictionary + _expandingAccumulator[key].Add(value); + } + else if (values.Count == 1) + { + // Second value for this key + _accumulator[key] = new string[] { values[0]!, value }; + } + else + { + // Third value for this key + // Add zero count entry and move to data to expanding list dictionary + _accumulator[key] = default(StringValues); + + if (_expandingAccumulator == null) + { + _expandingAccumulator = new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + + // Already 3 entries so use starting allocated as 8; then use List's expansion mechanism for more + var list = new List(8); + var array = values.ToArray(); + + list.Add(array[0]!); + list.Add(array[1]!); + list.Add(value); + + _expandingAccumulator[key] = list; + } + } + else + { + // First value for this key + _accumulator[key] = new StringValues(value); + } + + ValueCount++; + } + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public bool HasValues => ValueCount > 0; + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public int KeyCount => _accumulator?.Count ?? 0; + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public int ValueCount { get; private set; } + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public Dictionary GetResults() + { + if (_expandingAccumulator != null) + { + // Coalesce count 3+ multi-value entries into _accumulator dictionary + foreach (var entry in _expandingAccumulator) + { + _accumulator[entry.Key] = new StringValues(entry.Value.ToArray()); + } + } + + return _accumulator ?? new Dictionary(0, StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/LICENSE.txt b/src/Imazen.Abstractions/HttpStrings/LICENSE.txt new file mode 100644 index 00000000..705b5057 --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/LICENSE.txt @@ -0,0 +1,7 @@ +Many classes in this folder are derived from the .NET Core framework. + +They have been backported and poly-filled to function on .NET Standard 2.0. + +The original versions are licensed under the MIT license by the .NET Foundation. +The back-ported versions are licensed under the MIT license by the authors of this project. + diff --git a/src/Imazen.Abstractions/HttpStrings/QueryHelpers.cs b/src/Imazen.Abstractions/HttpStrings/QueryHelpers.cs new file mode 100644 index 00000000..f7ffc1ab --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/QueryHelpers.cs @@ -0,0 +1,165 @@ +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Abstractions.HttpStrings; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +/// +/// Provides methods for parsing and manipulating query strings. +/// +public static class QueryHelpers +{ + /// + /// Append the given query key and value to the URI. + /// + /// The base URI. + /// The name of the query key. + /// The query value. + /// The combined result. + /// is null. + /// is null. + /// is null. + public static string AddQueryString(string uri, string name, string value) + { + ArgumentNullThrowHelper.ThrowIfNull(uri); + ArgumentNullThrowHelper.ThrowIfNull(name); + ArgumentNullThrowHelper.ThrowIfNull(value); + + return AddQueryString( + uri, new[] { new KeyValuePair(name, value) }); + } + + /// + /// Append the given query keys and values to the URI. + /// + /// The base URI. + /// A dictionary of query keys and values to append. + /// The combined result. + /// is null. + /// is null. + public static string AddQueryString(string uri, IDictionary queryString) + { + ArgumentNullThrowHelper.ThrowIfNull(uri); + ArgumentNullThrowHelper.ThrowIfNull(uri); + ArgumentNullThrowHelper.ThrowIfNull(queryString); + + return AddQueryString(uri, (IEnumerable>)queryString); + } + + /// + /// Append the given query keys and values to the URI. + /// + /// The base URI. + /// A collection of query names and values to append. + /// The combined result. + /// is null. + /// is null. + public static string AddQueryString(string uri, IEnumerable> queryString) + { + ArgumentNullThrowHelper.ThrowIfNull(uri); + ArgumentNullThrowHelper.ThrowIfNull(queryString); + + return AddQueryString(uri, queryString.SelectMany( + kvp => + kvp.Value, (kvp, v) => + new KeyValuePair(kvp.Key, v))); + } + + /// + /// Append the given query keys and values to the URI. + /// + /// The base URI. + /// A collection of name value query pairs to append. + /// The combined result. + /// is null. + /// is null. + public static string AddQueryString( + string uri, + IEnumerable> queryString) + { + ArgumentNullThrowHelper.ThrowIfNull(uri); + ArgumentNullThrowHelper.ThrowIfNull(queryString); + + var anchorIndex = uri.IndexOf('#'); + var uriToBeAppended = uri.AsSpan(); + var anchorText = ReadOnlySpan.Empty; + // If there is an anchor, then the query string must be inserted before its first occurrence. + if (anchorIndex != -1) + { + anchorText = uriToBeAppended.Slice(anchorIndex); + uriToBeAppended = uriToBeAppended.Slice(0, anchorIndex); + } + + var queryIndex = uriToBeAppended.IndexOf('?'); + var hasQuery = queryIndex != -1; + + var sb = new StringBuilder(); +#if NET6_0_OR_GREATER + sb.Append(uriToBeAppended); +#else + sb.Append(uriToBeAppended.ToString()); +#endif + foreach (var parameter in queryString) + { + if (parameter.Value == null) + { + continue; + } + + sb.Append(hasQuery ? '&' : '?'); + sb.Append(UrlEncoder.Default.Encode(parameter.Key)); + sb.Append('='); + sb.Append(UrlEncoder.Default.Encode(parameter.Value)); + hasQuery = true; + } +#if NET6_0_OR_GREATER + sb.Append(anchorText); +#else + sb.Append(anchorText.ToString()); +#endif + return sb.ToString(); + } + + /// + /// Parse a query string into its component key and value parts. + /// + /// The raw query string value, with or without the leading '?'. + /// A collection of parsed keys and values. + public static Dictionary ParseQuery(string? queryString) + { + var result = ParseNullableQuery(queryString); + + if (result == null) + { + return new Dictionary(); + } + + return result; + } + + /// + /// Parse a query string into its component key and value parts. + /// + /// The raw query string value, with or without the leading '?'. + /// A collection of parsed keys and values, null if there are no entries. + public static Dictionary? ParseNullableQuery(string? queryString) + { + var accumulator = new KeyValueAccumulator(); + var enumerable = new QueryStringEnumerable(queryString); + + foreach (var pair in enumerable) + { + accumulator.Append(pair.DecodeName().ToString(), pair.DecodeValue().ToString()); + } + + if (!accumulator.HasValues) + { + return null; + } + + return accumulator.GetResults(); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/QueryStringEnumerable.cs b/src/Imazen.Abstractions/HttpStrings/QueryStringEnumerable.cs new file mode 100644 index 00000000..c711bde3 --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/QueryStringEnumerable.cs @@ -0,0 +1,148 @@ +namespace Imazen.Abstractions.HttpStrings; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +internal readonly struct QueryStringEnumerable +{ + private readonly ReadOnlyMemory _queryString; + + /// + /// Constructs an instance of . + /// + /// The query string. + public QueryStringEnumerable(string? queryString) + : this(queryString.AsMemory()) + { + } + + /// + /// Constructs an instance of . + /// + /// The query string. + public QueryStringEnumerable(ReadOnlyMemory queryString) + { + _queryString = queryString; + } + + /// + /// Retrieves an object that can iterate through the name/value pairs in the query string. + /// + /// An object that can iterate through the name/value pairs in the query string. + public Enumerator GetEnumerator() + => new Enumerator(_queryString); + + /// + /// Represents a single name/value pair extracted from a query string during enumeration. + /// + public readonly struct EncodedNameValuePair + { + /// + /// Gets the name from this name/value pair in its original encoded form. + /// To get the decoded string, call . + /// + public readonly ReadOnlyMemory EncodedName { get; } + + /// + /// Gets the value from this name/value pair in its original encoded form. + /// To get the decoded string, call . + /// + public readonly ReadOnlyMemory EncodedValue { get; } + + internal EncodedNameValuePair(ReadOnlyMemory encodedName, ReadOnlyMemory encodedValue) + { + EncodedName = encodedName; + EncodedValue = encodedValue; + } + + /// + /// Decodes the name from this name/value pair. + /// + /// Characters representing the decoded name. + public ReadOnlyMemory DecodeName() + => Decode(EncodedName); + + /// + /// Decodes the value from this name/value pair. + /// + /// Characters representing the decoded value. + public ReadOnlyMemory DecodeValue() + => Decode(EncodedValue); + + private static ReadOnlyMemory Decode(ReadOnlyMemory chars) + { + if (chars.Length < 16 && chars.Span.IndexOfAny('%', '+') < 0) + { + return chars; + } + return + Uri.UnescapeDataString(chars.ToString()).Replace('+', ' ') + .AsMemory(); + } + } + + /// + /// An enumerator that supplies the name/value pairs from a URI query string. + /// + public struct Enumerator + { + private ReadOnlyMemory _query; + + internal Enumerator(ReadOnlyMemory query) + { + Current = default; + _query = query.IsEmpty || query.Span[0] != '?' + ? query + : query.Slice(1); + } + + /// + /// Gets the currently referenced key/value pair in the query string being enumerated. + /// + public EncodedNameValuePair Current { get; private set; } + + /// + /// Moves to the next key/value pair in the query string being enumerated. + /// + /// True if there is another key/value pair, otherwise false. + public bool MoveNext() + { + while (!_query.IsEmpty) + { + // Chomp off the next segment + ReadOnlyMemory segment; + var delimiterIndex = _query.Span.IndexOf('&'); + if (delimiterIndex >= 0) + { + segment = _query.Slice(0, delimiterIndex); + _query = _query.Slice(delimiterIndex + 1); + } + else + { + segment = _query; + _query = default; + } + + // If it's nonempty, emit it + var equalIndex = segment.Span.IndexOf('='); + if (equalIndex >= 0) + { + Current = new EncodedNameValuePair( + segment.Slice(0, equalIndex), + segment.Slice(equalIndex + 1)); + return true; + } + else if (!segment.IsEmpty) + { + Current = new EncodedNameValuePair(segment, default); + return true; + } + } + + Current = default; + return false; + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/RunePolyfill.cs b/src/Imazen.Abstractions/HttpStrings/RunePolyfill.cs new file mode 100644 index 00000000..9756f19d --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/RunePolyfill.cs @@ -0,0 +1,571 @@ + +#if NETSTANDARD2_1_OR_GREATER +// Not needed +#else + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +// Polyfill of a polyfill +// Copied from https://github.com/dotnet/runtime/tree/21b4a8585362c1bc12d545b63e62a0d9dd4e8673/src/libraries/System.Text.Encodings.Web/src/Polyfills + +// Contains a polyfill implementation of System.Text.Rune that works on netstandard2.0. +// Implementation copied from: +// https://github.com/dotnet/runtime/blob/177d6f1a0bfdc853ae9ffeef4be99ff984c4f5dd/src/libraries/System.Private.CoreLib/src/System/Text/Rune.cs + +namespace Imazen.Abstractions.HttpStrings +{ + internal readonly struct Rune : IEquatable + { + private const int MaxUtf16CharsPerRune = 2; // supplementary plane code points are encoded as 2 UTF-16 code units + + private const char HighSurrogateStart = '\ud800'; + private const char LowSurrogateStart = '\udc00'; + private const int HighSurrogateRange = 0x3FF; + + private readonly uint _value; + + /// + /// Creates a from the provided Unicode scalar value. + /// + /// + /// If does not represent a value Unicode scalar value. + /// + public Rune(uint value) + { + if (!UnicodeUtility.IsValidUnicodeScalar(value)) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _value = value; + } + + /// + /// Creates a from the provided Unicode scalar value. + /// + /// + /// If does not represent a value Unicode scalar value. + /// + public Rune(int value) + : this((uint)value) + { + } + + // non-validating ctor + private Rune(uint scalarValue, bool _) + { + // x UnicodeDebug.AssertIsValidScalar(scalarValue); + _value = scalarValue; + } + + /// + /// Returns true if and only if this scalar value is ASCII ([ U+0000..U+007F ]) + /// and therefore representable by a single UTF-8 code unit. + /// + public bool IsAscii => UnicodeUtility.IsAsciiCodePoint(_value); + + /// + /// Returns true if and only if this scalar value is within the BMP ([ U+0000..U+FFFF ]) + /// and therefore representable by a single UTF-16 code unit. + /// + public bool IsBmp => UnicodeUtility.IsBmpCodePoint(_value); + + public static bool operator ==(Rune left, Rune right) => left._value == right._value; + + public static bool operator !=(Rune left, Rune right) => left._value != right._value; + + public static bool IsControl(Rune value) + { + // Per the Unicode stability policy, the set of control characters + // is forever fixed at [ U+0000..U+001F ], [ U+007F..U+009F ]. No + // characters will ever be added to or removed from the "control characters" + // group. See https://www.unicode.org/policies/stability_policy.html. + + // Logic below depends on Rune.Value never being -1 (since Rune is a validating type) + // 00..1F (+1) => 01..20 (&~80) => 01..20 + // 7F..9F (+1) => 80..A0 (&~80) => 00..20 + + return ((value._value + 1) & ~0x80u) <= 0x20u; + } + + /// + /// A instance that represents the Unicode replacement character U+FFFD. + /// + public static Rune ReplacementChar => UnsafeCreate(UnicodeUtility.ReplacementChar); + + /// + /// Returns the length in code units () of the + /// UTF-16 sequence required to represent this scalar value. + /// + /// + /// The return value will be 1 or 2. + /// + public int Utf16SequenceLength + { + get + { + int codeUnitCount = UnicodeUtility.GetUtf16SequenceLength(_value); + Debug.Assert(codeUnitCount > 0 && codeUnitCount <= MaxUtf16CharsPerRune); + return codeUnitCount; + } + } + + /// + /// Returns the Unicode scalar value as an integer. + /// + public int Value => (int)_value; + + /// + /// Decodes the at the beginning of the provided UTF-16 source buffer. + /// + /// + /// + /// If the source buffer begins with a valid UTF-16 encoded scalar value, returns , + /// and outs via the decoded and via the + /// number of s used in the input buffer to encode the . + /// + /// + /// If the source buffer is empty or contains only a standalone UTF-16 high surrogate character, returns , + /// and outs via and via the length of the input buffer. + /// + /// + /// If the source buffer begins with an ill-formed UTF-16 encoded scalar value, returns , + /// and outs via and via the number of + /// s used in the input buffer to encode the ill-formed sequence. + /// + /// + /// + /// The general calling convention is to call this method in a loop, slicing the buffer by + /// elements on each iteration of the loop. On each iteration of the loop + /// will contain the real scalar value if successfully decoded, or it will contain if + /// the data could not be successfully decoded. This pattern provides convenient automatic U+FFFD substitution of + /// invalid sequences while iterating through the loop. + /// + public static OperationStatus DecodeFromUtf16(ReadOnlySpan source, out Rune result, out int charsConsumed) + { + if (!source.IsEmpty) + { + // First, check for the common case of a BMP scalar value. + // If this is correct, return immediately. + + char firstChar = source[0]; + if (TryCreate(firstChar, out result)) + { + charsConsumed = 1; + return OperationStatus.Done; + } + + // First thing we saw was a UTF-16 surrogate code point. + // Let's optimistically assume for now it's a high surrogate and hope + // that combining it with the next char yields useful results. + + if (1 < (uint)source.Length) + { + char secondChar = source[1]; + if (TryCreate(firstChar, secondChar, out result)) + { + // Success! Formed a supplementary scalar value. + charsConsumed = 2; + return OperationStatus.Done; + } + else + { + // Either the first character was a low surrogate, or the second + // character was not a low surrogate. This is an error. + goto InvalidData; + } + } + else if (!char.IsHighSurrogate(firstChar)) + { + // Quick check to make sure we're not going to report NeedMoreData for + // a single-element buffer where the data is a standalone low surrogate + // character. Since no additional data will ever make this valid, we'll + // report an error immediately. + goto InvalidData; + } + } + + // If we got to this point, the input buffer was empty, or the buffer + // was a single element in length and that element was a high surrogate char. + + charsConsumed = source.Length; + result = ReplacementChar; + return OperationStatus.NeedMoreData; + + InvalidData: + + charsConsumed = 1; // maximal invalid subsequence for UTF-16 is always a single code unit in length + result = ReplacementChar; + return OperationStatus.InvalidData; + } + + /// + /// Decodes the at the beginning of the provided UTF-8 source buffer. + /// + /// + /// + /// If the source buffer begins with a valid UTF-8 encoded scalar value, returns , + /// and outs via the decoded and via the + /// number of s used in the input buffer to encode the . + /// + /// + /// If the source buffer is empty or contains only a partial UTF-8 subsequence, returns , + /// and outs via and via the length of the input buffer. + /// + /// + /// If the source buffer begins with an ill-formed UTF-8 encoded scalar value, returns , + /// and outs via and via the number of + /// s used in the input buffer to encode the ill-formed sequence. + /// + /// + /// + /// The general calling convention is to call this method in a loop, slicing the buffer by + /// elements on each iteration of the loop. On each iteration of the loop + /// will contain the real scalar value if successfully decoded, or it will contain if + /// the data could not be successfully decoded. This pattern provides convenient automatic U+FFFD substitution of + /// invalid sequences while iterating through the loop. + /// + public static OperationStatus DecodeFromUtf8(ReadOnlySpan source, out Rune result, out int bytesConsumed) + { + // This method follows the Unicode Standard's recommendation for detecting + // the maximal subpart of an ill-formed subsequence. See The Unicode Standard, + // Ch. 3.9 for more details. In summary, when reporting an invalid subsequence, + // it tries to consume as many code units as possible as long as those code + // units constitute the beginning of a longer well-formed subsequence per Table 3-7. + + int index = 0; + + // Try reading input[0]. + + if ((uint)index >= (uint)source.Length) + { + goto NeedsMoreData; + } + + uint tempValue = source[index]; + if (!UnicodeUtility.IsAsciiCodePoint(tempValue)) + { + goto NotAscii; + } + + Finish: + + bytesConsumed = index + 1; + Debug.Assert(1 <= bytesConsumed && bytesConsumed <= 4); // Valid subsequences are always length [1..4] + result = UnsafeCreate(tempValue); + return OperationStatus.Done; + + NotAscii: + + // Per Table 3-7, the beginning of a multibyte sequence must be a code unit in + // the range [C2..F4]. If it's outside of that range, it's either a standalone + // continuation byte, or it's an overlong two-byte sequence, or it's an out-of-range + // four-byte sequence. + + if (!UnicodeUtility.IsInRangeInclusive(tempValue, 0xC2, 0xF4)) + { + goto FirstByteInvalid; + } + + tempValue = (tempValue - 0xC2) << 6; + + // Try reading input[1]. + + index++; + if ((uint)index >= (uint)source.Length) + { + goto NeedsMoreData; + } + + // Continuation bytes are of the form [10xxxxxx], which means that their two's + // complement representation is in the range [-65..-128]. This allows us to + // perform a single comparison to see if a byte is a continuation byte. + + int thisByteSignExtended = (sbyte)source[index]; + if (thisByteSignExtended >= -64) + { + goto Invalid; + } + + tempValue += (uint)thisByteSignExtended; + tempValue += 0x80; // remove the continuation byte marker + tempValue += (0xC2 - 0xC0) << 6; // remove the leading byte marker + + if (tempValue < 0x0800) + { + Debug.Assert(UnicodeUtility.IsInRangeInclusive(tempValue, 0x0080, 0x07FF)); + goto Finish; // this is a valid 2-byte sequence + } + + // This appears to be a 3- or 4-byte sequence. Since per Table 3-7 we now have + // enough information (from just two code units) to detect overlong or surrogate + // sequences, we need to perform these checks now. + + if (!UnicodeUtility.IsInRangeInclusive(tempValue, ((0xE0 - 0xC0) << 6) + (0xA0 - 0x80), ((0xF4 - 0xC0) << 6) + (0x8F - 0x80))) + { + // The first two bytes were not in the range [[E0 A0]..[F4 8F]]. + // This is an overlong 3-byte sequence or an out-of-range 4-byte sequence. + goto Invalid; + } + + if (UnicodeUtility.IsInRangeInclusive(tempValue, ((0xED - 0xC0) << 6) + (0xA0 - 0x80), ((0xED - 0xC0) << 6) + (0xBF - 0x80))) + { + // This is a UTF-16 surrogate code point, which is invalid in UTF-8. + goto Invalid; + } + + if (UnicodeUtility.IsInRangeInclusive(tempValue, ((0xF0 - 0xC0) << 6) + (0x80 - 0x80), ((0xF0 - 0xC0) << 6) + (0x8F - 0x80))) + { + // This is an overlong 4-byte sequence. + goto Invalid; + } + + // The first two bytes were just fine. We don't need to perform any other checks + // on the remaining bytes other than to see that they're valid continuation bytes. + + // Try reading input[2]. + + index++; + if ((uint)index >= (uint)source.Length) + { + goto NeedsMoreData; + } + + thisByteSignExtended = (sbyte)source[index]; + if (thisByteSignExtended >= -64) + { + goto Invalid; // this byte is not a UTF-8 continuation byte + } + + tempValue <<= 6; + tempValue += (uint)thisByteSignExtended; + tempValue += 0x80; // remove the continuation byte marker + tempValue -= (0xE0 - 0xC0) << 12; // remove the leading byte marker + + if (tempValue <= 0xFFFF) + { + Debug.Assert(UnicodeUtility.IsInRangeInclusive(tempValue, 0x0800, 0xFFFF)); + goto Finish; // this is a valid 3-byte sequence + } + + // Try reading input[3]. + + index++; + if ((uint)index >= (uint)source.Length) + { + goto NeedsMoreData; + } + + thisByteSignExtended = (sbyte)source[index]; + if (thisByteSignExtended >= -64) + { + goto Invalid; // this byte is not a UTF-8 continuation byte + } + + tempValue <<= 6; + tempValue += (uint)thisByteSignExtended; + tempValue += 0x80; // remove the continuation byte marker + tempValue -= (0xF0 - 0xE0) << 18; // remove the leading byte marker + + // x UnicodeDebug.AssertIsValidSupplementaryPlaneScalar(tempValue); + goto Finish; // this is a valid 4-byte sequence + + FirstByteInvalid: + + index = 1; // Invalid subsequences are always at least length 1. + + Invalid: + + Debug.Assert(1 <= index && index <= 3); // Invalid subsequences are always length 1..3 + bytesConsumed = index; + result = ReplacementChar; + return OperationStatus.InvalidData; + + NeedsMoreData: + + Debug.Assert(0 <= index && index <= 3); // Incomplete subsequences are always length 0..3 + bytesConsumed = index; + result = ReplacementChar; + return OperationStatus.NeedMoreData; + } + + public override bool Equals([NotNullWhen(true)] object? obj) => (obj is Rune other) && Equals(other); + + public bool Equals(Rune other) => this == other; + + public override int GetHashCode() => Value; + + /// + /// Attempts to create a from the provided input value. + /// + public static bool TryCreate(char ch, out Rune result) + { + uint extendedValue = ch; + if (!UnicodeUtility.IsSurrogateCodePoint(extendedValue)) + { + result = UnsafeCreate(extendedValue); + return true; + } + else + { + result = default; + return false; + } + } + + /// + /// Attempts to create a from the provided UTF-16 surrogate pair. + /// Returns if the input values don't represent a well-formed UTF-16surrogate pair. + /// + public static bool TryCreate(char highSurrogate, char lowSurrogate, out Rune result) + { + // First, extend both to 32 bits, then calculate the offset of + // each candidate surrogate char from the start of its range. + + uint highSurrogateOffset = (uint)highSurrogate - HighSurrogateStart; + uint lowSurrogateOffset = (uint)lowSurrogate - LowSurrogateStart; + + // This is a single comparison which allows us to check both for validity at once since + // both the high surrogate range and the low surrogate range are the same length. + // If the comparison fails, we call to a helper method to throw the correct exception message. + + if ((highSurrogateOffset | lowSurrogateOffset) <= HighSurrogateRange) + { + // The 0x40u << 10 below is to account for uuuuu = wwww + 1 in the surrogate encoding. + result = UnsafeCreate((highSurrogateOffset << 10) + ((uint)lowSurrogate - LowSurrogateStart) + (0x40u << 10)); + return true; + } + else + { + // Didn't have a high surrogate followed by a low surrogate. + result = default; + return false; + } + } + /// + /// Attempts to create a from the provided input value. + /// + public static bool TryCreate(int value, out Rune result) => TryCreate((uint)value, out result); + public static bool TryCreate(uint value, out Rune result) + { + if (UnicodeUtility.IsValidUnicodeScalar(value)) + { + result = UnsafeCreate(value); + return true; + } + else + { + result = default; + return false; + } + } + + /// + /// Encodes this to a UTF-16 destination buffer. + /// + /// The buffer to which to write this value as UTF-16. + /// + /// The number of s written to , + /// or 0 if the destination buffer is not large enough to contain the output. + /// True if the value was written to the buffer; otherwise, false. + public bool TryEncodeToUtf16(Span destination, out int charsWritten) + { + if (destination.Length >= 1) + { + if (IsBmp) + { + destination[0] = (char)_value; + charsWritten = 1; + return true; + } + else if (destination.Length >= 2) + { + UnicodeUtility.GetUtf16SurrogatesFromSupplementaryPlaneScalar(_value, out destination[0], out destination[1]); + charsWritten = 2; + return true; + } + } + + // Destination buffer not large enough + + charsWritten = default; + return false; + } + + /// + /// Encodes this to a destination buffer as UTF-8 bytes. + /// + /// The buffer to which to write this value as UTF-8. + /// + /// The number of s written to , + /// or 0 if the destination buffer is not large enough to contain the output. + /// True if the value was written to the buffer; otherwise, false. + public bool TryEncodeToUtf8(Span destination, out int bytesWritten) + { + // The bit patterns below come from the Unicode Standard, Table 3-6. + + if (destination.Length >= 1) + { + if (IsAscii) + { + destination[0] = (byte)_value; + bytesWritten = 1; + return true; + } + + if (destination.Length >= 2) + { + if (_value <= 0x7FFu) + { + // Scalar 00000yyy yyxxxxxx -> bytes [ 110yyyyy 10xxxxxx ] + destination[0] = (byte)((_value + (0b110u << 11)) >> 6); + destination[1] = (byte)((_value & 0x3Fu) + 0x80u); + bytesWritten = 2; + return true; + } + + if (destination.Length >= 3) + { + if (_value <= 0xFFFFu) + { + // Scalar zzzzyyyy yyxxxxxx -> bytes [ 1110zzzz 10yyyyyy 10xxxxxx ] + destination[0] = (byte)((_value + (0b1110 << 16)) >> 12); + destination[1] = (byte)(((_value & (0x3Fu << 6)) >> 6) + 0x80u); + destination[2] = (byte)((_value & 0x3Fu) + 0x80u); + bytesWritten = 3; + return true; + } + + if (destination.Length >= 4) + { + // Scalar 000uuuuu zzzzyyyy yyxxxxxx -> bytes [ 11110uuu 10uuzzzz 10yyyyyy 10xxxxxx ] + destination[0] = (byte)((_value + (0b11110 << 21)) >> 18); + destination[1] = (byte)(((_value & (0x3Fu << 12)) >> 12) + 0x80u); + destination[2] = (byte)(((_value & (0x3Fu << 6)) >> 6) + 0x80u); + destination[3] = (byte)((_value & 0x3Fu) + 0x80u); + bytesWritten = 4; + return true; + } + } + } + } + + // Destination buffer not large enough + + bytesWritten = default; + return false; + } + + /// + /// Creates a without performing validation on the input. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static Rune UnsafeCreate(uint scalarValue) => new Rune(scalarValue, false); + } +} +#endif // NETSTANDARD2_1_OR_GREATER \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/SearchValuesPolyfill.cs b/src/Imazen.Abstractions/HttpStrings/SearchValuesPolyfill.cs new file mode 100644 index 00000000..4fc28d51 --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/SearchValuesPolyfill.cs @@ -0,0 +1,55 @@ +namespace Imazen.Abstractions.HttpStrings; + +#if NETSTANDARD2_1_OR_GREATER +#else +internal class SearchValues +{ + internal SearchValues(T[] values) + { + Values = values; + } + + public T[] Values { get; } + + public bool Contains(T c) + { + return Values.Contains(c); + } +} + +internal static class SearchValues +{ + public static SearchValues Create(string values) + { + return new SearchValues(values.ToCharArray()); + } +} + +internal static class SpanExtensions +{ + public static int IndexOfAnyExcept(this ReadOnlySpan span, SearchValues searchValues) + { + for (var i = 0; i < span.Length; i++) + { + var c = span[i]; + if (searchValues.Contains(c)) continue; + return i; + } + + return -1; + } + // // ContainsAnyExcept + + public static bool ContainsAnyExcept(this ReadOnlySpan span, SearchValues searchValues) + { + for (var i = 0; i < span.Length; i++) + { + var c = span[i]; + if (searchValues.Contains(c)) continue; + return true; + } + + return false; + } +} +#endif \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/UnicodeUtilityPolyfill.cs b/src/Imazen.Abstractions/HttpStrings/UnicodeUtilityPolyfill.cs new file mode 100644 index 00000000..28a726cc --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/UnicodeUtilityPolyfill.cs @@ -0,0 +1,190 @@ + +#if NETSTANDARD2_1_OR_GREATER +#else + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +namespace Imazen.Abstractions.HttpStrings +{ + internal static class UnicodeUtility + { + /// + /// The Unicode replacement character U+FFFD. + /// + public const uint ReplacementChar = 0xFFFD; + + /// + /// Returns the Unicode plane (0 through 16, inclusive) which contains this code point. + /// + public static int GetPlane(uint codePoint) + { + // x UnicodeDebug.AssertIsValidCodePoint(codePoint); + + return (int)(codePoint >> 16); + } + + /// + /// Returns a Unicode scalar value from two code points representing a UTF-16 surrogate pair. + /// + public static uint GetScalarFromUtf16SurrogatePair(uint highSurrogateCodePoint, uint lowSurrogateCodePoint) + { + // x UnicodeDebug.AssertIsHighSurrogateCodePoint(highSurrogateCodePoint); + // x UnicodeDebug.AssertIsLowSurrogateCodePoint(lowSurrogateCodePoint); + + // This calculation comes from the Unicode specification, Table 3-5. + // Need to remove the D800 marker from the high surrogate and the DC00 marker from the low surrogate, + // then fix up the "wwww = uuuuu - 1" section of the bit distribution. The code is written as below + // to become just two instructions: shl, lea. + + return (highSurrogateCodePoint << 10) + lowSurrogateCodePoint - ((0xD800U << 10) + 0xDC00U - (1 << 16)); + } + + /// + /// Given a Unicode scalar value, gets the number of UTF-16 code units required to represent this value. + /// + public static int GetUtf16SequenceLength(uint value) + { + // x UnicodeDebug.AssertIsValidScalar(value); + + value -= 0x10000; // if value < 0x10000, high byte = 0xFF; else high byte = 0x00 + value += (2 << 24); // if value < 0x10000, high byte = 0x01; else high byte = 0x02 + value >>= 24; // shift high byte down + return (int)value; // and return it + } + + /// + /// Decomposes an astral Unicode scalar into UTF-16 high and low surrogate code units. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void GetUtf16SurrogatesFromSupplementaryPlaneScalar(uint value, out char highSurrogateCodePoint, out char lowSurrogateCodePoint) + { + // x UnicodeDebug.AssertIsValidSupplementaryPlaneScalar(value); + + // This calculation comes from the Unicode specification, Table 3-5. + + highSurrogateCodePoint = (char)((value + ((0xD800u - 0x40u) << 10)) >> 10); + lowSurrogateCodePoint = (char)((value & 0x3FFu) + 0xDC00u); + } + + /// + /// Given a Unicode scalar value, gets the number of UTF-8 code units required to represent this value. + /// + public static int GetUtf8SequenceLength(uint value) + { + // x UnicodeDebug.AssertIsValidScalar(value); + + // The logic below can handle all valid scalar values branchlessly. + // It gives generally good performance across all inputs, and on x86 + // it's only six instructions: lea, sar, xor, add, shr, lea. + + // 'a' will be -1 if input is < 0x800; else 'a' will be 0 + // => 'a' will be -1 if input is 1 or 2 UTF-8 code units; else 'a' will be 0 + + int a = ((int)value - 0x0800) >> 31; + + // The number of UTF-8 code units for a given scalar is as follows: + // - U+0000..U+007F => 1 code unit + // - U+0080..U+07FF => 2 code units + // - U+0800..U+FFFF => 3 code units + // - U+10000+ => 4 code units + // + // If we XOR the incoming scalar with 0xF800, the chart mutates: + // - U+0000..U+F7FF => 3 code units + // - U+F800..U+F87F => 1 code unit + // - U+F880..U+FFFF => 2 code units + // - U+10000+ => 4 code units + // + // Since the 1- and 3-code unit cases are now clustered, they can + // both be checked together very cheaply. + + value ^= 0xF800u; + value -= 0xF880u; // if scalar is 1 or 3 code units, high byte = 0xFF; else high byte = 0x00 + value += (4 << 24); // if scalar is 1 or 3 code units, high byte = 0x03; else high byte = 0x04 + value >>= 24; // shift high byte down + + // Final return value: + // - U+0000..U+007F => 3 + (-1) * 2 = 1 + // - U+0080..U+07FF => 4 + (-1) * 2 = 2 + // - U+0800..U+FFFF => 3 + ( 0) * 2 = 3 + // - U+10000+ => 4 + ( 0) * 2 = 4 + return (int)value + (a * 2); + } + + /// + /// Returns iff is an ASCII + /// character ([ U+0000..U+007F ]). + /// + /// + /// Per http://www.unicode.org/glossary/#ASCII, ASCII is only U+0000..U+007F. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsAsciiCodePoint(uint value) => value <= 0x7Fu; + + /// + /// Returns iff is in the + /// Basic Multilingual Plane (BMP). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsBmpCodePoint(uint value) => value <= 0xFFFFu; + + /// + /// Returns iff is a UTF-16 high surrogate code point, + /// i.e., is in [ U+D800..U+DBFF ], inclusive. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsHighSurrogateCodePoint(uint value) => IsInRangeInclusive(value, 0xD800U, 0xDBFFU); + + /// + /// Returns iff is between + /// and , inclusive. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsInRangeInclusive(uint value, uint lowerBound, uint upperBound) => (value - lowerBound) <= (upperBound - lowerBound); + + /// + /// Returns iff is a UTF-16 low surrogate code point, + /// i.e., is in [ U+DC00..U+DFFF ], inclusive. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsLowSurrogateCodePoint(uint value) => IsInRangeInclusive(value, 0xDC00U, 0xDFFFU); + + /// + /// Returns iff is a UTF-16 surrogate code point, + /// i.e., is in [ U+D800..U+DFFF ], inclusive. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsSurrogateCodePoint(uint value) => IsInRangeInclusive(value, 0xD800U, 0xDFFFU); + + /// + /// Returns iff is a valid Unicode code + /// point, i.e., is in [ U+0000..U+10FFFF ], inclusive. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsValidCodePoint(uint codePoint) => codePoint <= 0x10FFFFU; + + /// + /// Returns iff is a valid Unicode scalar + /// value, i.e., is in [ U+0000..U+D7FF ], inclusive; or [ U+E000..U+10FFFF ], inclusive. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsValidUnicodeScalar(uint value) + { + // This is an optimized check that on x86 is just three instructions: lea, xor, cmp. + // + // After the subtraction operation, the input value is modified as such: + // [ 00000000..0010FFFF ] -> [ FFEF0000..FFFFFFFF ] + // + // We now want to _exclude_ the range [ FFEFD800..FFEFDFFF ] (surrogates) from being valid. + // After the xor, this particular exclusion range becomes [ FFEF0000..FFEF07FF ]. + // + // So now the range [ FFEF0800..FFFFFFFF ] contains all valid code points, + // excluding surrogates. This allows us to perform a single comparison. + + return ((value - 0x110000u) ^ 0xD800u) >= 0xFFEF0800u; + } + } +} +#endif \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/UrlDecoder.cs b/src/Imazen.Abstractions/HttpStrings/UrlDecoder.cs new file mode 100644 index 00000000..f14f3598 --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/UrlDecoder.cs @@ -0,0 +1,643 @@ +using System.Runtime.CompilerServices; + +namespace Imazen.Abstractions.HttpStrings; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Modified by Imazen and released under the MIT license. + + +[Flags] +internal enum DecodeFlags : byte +{ + DoNotDecode2FToSlash = 0x1, + DecodePlusToSpace = 0x2, + FormEncoding = 0x3, // DoNotDecode2FToSlash | DecodePlusToSpace + None = 0x0, +} + +internal sealed class UrlDecoder +{ + /// + /// Unescape a URL path + /// + /// The byte span represents a UTF8 encoding url path. + /// The byte span where unescaped url path is copied to. + /// Whether we are doing form encoding or not. + /// The length of the byte sequence of the unescaped url path. + public static int DecodeRequestLine(ReadOnlySpan source, Span destination, bool isFormEncoding) + { + return DecodeRequestLine(source, destination, isFormEncoding ? DecodeFlags.FormEncoding : DecodeFlags.None); + } + public static int DecodeRequestLine(ReadOnlySpan source, Span destination, DecodeFlags decodeFlags) + { + if (destination.Length < source.Length) + { + throw new ArgumentException( + "Length of the destination byte span is less than the source.", + nameof(destination)); + } + + // This requires the destination span to be larger or equal to source span + source.CopyTo(destination); + return DecodeInPlace(destination.Slice(0, source.Length), decodeFlags); + } + + /// + /// Unescape a URL path in place. + /// + /// The byte span represents a UTF8 encoding url path. + /// Whether we are doing form encoding or not. + /// The number of the bytes representing the result. + /// + /// The unescape is done in place, which means after decoding the result is the subset of + /// the input span. + /// + public static int DecodeInPlace(Span buffer, bool isFormEncoding) + { + return DecodeInPlace(buffer, isFormEncoding ? DecodeFlags.FormEncoding : DecodeFlags.None); + } + public static int DecodeInPlace(Span buffer, DecodeFlags decodeFlags) + { + // the slot to read the input + var sourceIndex = 0; + + // the slot to write the unescaped byte + var destinationIndex = 0; + + while (true) + { + if (sourceIndex == buffer.Length) + { + break; + } + + if (buffer[sourceIndex] == '+' && (decodeFlags & DecodeFlags.DecodePlusToSpace) != 0) + { + // Set it to ' ' when we are doing form encoding. + buffer[sourceIndex] = 0x20; + } + else if (buffer[sourceIndex] == '%') + { + var decodeIndex = sourceIndex; + + // If decoding process succeeds, the writer iterator will be moved + // to the next write-ready location. On the other hand if the scanned + // percent-encodings cannot be interpreted as sequence of UTF-8 octets, + // these bytes should be copied to output as is. + // The decodeReader iterator is always moved to the first byte not yet + // be scanned after the process. A failed decoding means the chars + // between the reader and decodeReader can be copied to output untouched. + if (!DecodeCore(ref decodeIndex, ref destinationIndex, buffer, decodeFlags)) + { + Copy(sourceIndex, decodeIndex, ref destinationIndex, buffer); + } + + sourceIndex = decodeIndex; + } + else + { + buffer[destinationIndex++] = buffer[sourceIndex++]; + } + } + + return destinationIndex; + } + + /// + /// Unescape the percent-encodings + /// + /// The iterator point to the first % char + /// The place to write to + /// The byte array + /// Whether we are doing form encodoing + private static bool DecodeCore(ref int sourceIndex, ref int destinationIndex, Span buffer, DecodeFlags decodeFlags) + { + // preserves the original head. if the percent-encodings cannot be interpreted as sequence of UTF-8 octets, + // bytes from this till the last scanned one will be copied to the memory pointed by writer. + var byte1 = UnescapePercentEncoding(ref sourceIndex, buffer, decodeFlags); + if (byte1 == -1) + { + return false; + } + + if (byte1 == 0) + { + throw new InvalidOperationException("The path contains null characters."); + } + + if (byte1 <= 0x7F) + { + // first byte < U+007f, it is a single byte ASCII + buffer[destinationIndex++] = (byte)byte1; + return true; + } + + int byte2 = 0, byte3 = 0, byte4 = 0; + + // anticipate more bytes + int currentDecodeBits; + int byteCount; + int expectValueMin; + if ((byte1 & 0xE0) == 0xC0) + { + // 110x xxxx, expect one more byte + currentDecodeBits = byte1 & 0x1F; + byteCount = 2; + expectValueMin = 0x80; + } + else if ((byte1 & 0xF0) == 0xE0) + { + // 1110 xxxx, expect two more bytes + currentDecodeBits = byte1 & 0x0F; + byteCount = 3; + expectValueMin = 0x800; + } + else if ((byte1 & 0xF8) == 0xF0) + { + // 1111 0xxx, expect three more bytes + currentDecodeBits = byte1 & 0x07; + byteCount = 4; + expectValueMin = 0x10000; + } + else + { + // invalid first byte + return false; + } + + var remainingBytes = byteCount - 1; + while (remainingBytes > 0) + { + // read following three chars + if (sourceIndex == buffer.Length) + { + return false; + } + + var nextSourceIndex = sourceIndex; + var nextByte = UnescapePercentEncoding(ref nextSourceIndex, buffer, decodeFlags); + if (nextByte == -1) + { + return false; + } + + if ((nextByte & 0xC0) != 0x80) + { + // the follow up byte is not in form of 10xx xxxx + return false; + } + + currentDecodeBits = (currentDecodeBits << 6) | (nextByte & 0x3F); + remainingBytes--; + + if (remainingBytes == 1 && currentDecodeBits >= 0x360 && currentDecodeBits <= 0x37F) + { + // this is going to end up in the range of 0xD800-0xDFFF UTF-16 surrogates that + // are not allowed in UTF-8; + return false; + } + + if (remainingBytes == 2 && currentDecodeBits >= 0x110) + { + // this is going to be out of the upper Unicode bound 0x10FFFF. + return false; + } + + sourceIndex = nextSourceIndex; + if (byteCount - remainingBytes == 2) + { + byte2 = nextByte; + } + else if (byteCount - remainingBytes == 3) + { + byte3 = nextByte; + } + else if (byteCount - remainingBytes == 4) + { + byte4 = nextByte; + } + } + + if (currentDecodeBits < expectValueMin) + { + // overlong encoding (e.g. using 2 bytes to encode something that only needed 1). + return false; + } + + // all bytes are verified, write to the output + // TODO: measure later to determine if the performance of following logic can be improved + // the idea is to combine the bytes into short/int and write to span directly to avoid + // range check cost + if (byteCount > 0) + { + buffer[destinationIndex++] = (byte)byte1; + } + if (byteCount > 1) + { + buffer[destinationIndex++] = (byte)byte2; + } + if (byteCount > 2) + { + buffer[destinationIndex++] = (byte)byte3; + } + if (byteCount > 3) + { + buffer[destinationIndex++] = (byte)byte4; + } + + return true; + } + + private static void Copy(int begin, int end, ref int writer, Span buffer) + { + while (begin != end) + { + buffer[writer++] = buffer[begin++]; + } + } + + /// + /// Read the percent-encoding and try unescape it. + /// + /// The operation first peek at the character the + /// iterator points at. If it is % the is then + /// moved on to scan the following to characters. If the two following + /// characters are hexadecimal literals they will be unescaped and the + /// value will be returned. + /// + /// If the first character is not % the iterator + /// will be removed beyond the location of % and -1 will be returned. + /// + /// If the following two characters can't be successfully unescaped the + /// iterator will be move behind the % and -1 + /// will be returned. + /// + /// The value to read + /// The byte array + /// Whether we are decoding a form or not. Will escape '/' if we are doing form encoding + /// The unescaped byte if success. Otherwise return -1. + private static int UnescapePercentEncoding(ref int scan, Span buffer, DecodeFlags decodeFlags) + { + if (buffer[scan++] != '%') + { + return -1; + } + + var probe = scan; + + var value1 = ReadHex(ref probe, buffer); + if (value1 == -1) + { + return -1; + } + + var value2 = ReadHex(ref probe, buffer); + if (value2 == -1) + { + return -1; + } + + if (SkipUnescape(value1, value2, decodeFlags)) + { + return -1; + } + + scan = probe; + return (value1 << 4) + value2; + } + + /// + /// Read the next char and convert it into hexadecimal value. + /// + /// The index will be moved to the next + /// byte no matter whether the operation successes. + /// + /// The index of the byte in the buffer to read + /// The byte span from which the hex to be read + /// The hexadecimal value if successes, otherwise -1. + private static int ReadHex(ref int scan, Span buffer) + { + if (scan == buffer.Length) + { + return -1; + } + + var value = buffer[scan++]; + var isHex = ((value >= '0') && (value <= '9')) || + ((value >= 'A') && (value <= 'F')) || + ((value >= 'a') && (value <= 'f')); + + if (!isHex) + { + return -1; + } + + if (value <= '9') + { + return value - '0'; + } + else if (value <= 'F') + { + return (value - 'A') + 10; + } + else // a - f + { + return (value - 'a') + 10; + } + } + + private static bool SkipUnescape(int value1, int value2, DecodeFlags decodeFlags) + { + // skip %2F - '/' + if (value1 == 2 && value2 == 15) + { + return (decodeFlags & DecodeFlags.DoNotDecode2FToSlash) != 0; + } + + return false; + } + + /// + /// Unescape a URL path + /// + /// The escape sequences is expected to be well-formed UTF-8 code units. + /// The char span where unescaped url path is copied to. + /// The length of the char sequence of the unescaped url path. + /// + /// Form Encoding is not supported compared to the + /// for performance gains, as current use-cases does not require it. + /// + public static int DecodeRequestLine(ReadOnlySpan source, Span destination) + { + // This requires the destination span to be larger or equal to source span + // which is validated by Span.CopyTo. + source.CopyTo(destination); + return DecodeInPlace(destination.Slice(0, source.Length)); + } + + /// + /// Unescape a URL path in place. + /// + /// The escape sequences is expected to be well-formed UTF-8 code units. + /// The number of the chars representing the result. + /// + /// The unescape is done in place, which means after decoding the result is the subset of + /// the input span. + /// Form Encoding is not supported compared to the + /// for performance gains, as current use-cases does not require it. + /// + public static int DecodeInPlace(Span buffer) + { + // Compared to the byte overload implementation, this is a different + // by using the first occurrence of % as the starting position both + // for the source and the destination index. + int position = buffer.IndexOf('%'); + if (position == -1) + { + return buffer.Length; + } + + // the slot to read the input + var sourceIndex = position; + + // the slot to write the unescaped char + var destinationIndex = position; + + while (true) + { + if (sourceIndex == buffer.Length) + { + break; + } + + if (buffer[sourceIndex] == '%') + { + var decodeIndex = sourceIndex; + + // If decoding process succeeds, the writer iterator will be moved + // to the next write-ready location. On the other hand if the scanned + // percent-encodings cannot be interpreted as sequence of UTF-8 octets, + // these chars should be copied to output as is. + // The decodeReader iterator is always moved to the first char not yet + // be scanned after the process. A failed decoding means the chars + // between the reader and decodeReader can be copied to output untouched. + if (!DecodeCore(ref decodeIndex, ref destinationIndex, buffer)) + { + Copy(sourceIndex, decodeIndex, ref destinationIndex, buffer); + } + + sourceIndex = decodeIndex; + } + else + { + buffer[destinationIndex++] = buffer[sourceIndex++]; + } + } + + return destinationIndex; + } + + /// + /// Unescape the percent-encodings + /// + /// The iterator point to the first % char + /// The place to write to + /// The char array + private static bool DecodeCore(ref int sourceIndex, ref int destinationIndex, Span buffer) + { + // preserves the original head. if the percent-encodings cannot be interpreted as sequence of UTF-8 octets, + // chars from this till the last scanned one will be copied to the memory pointed by writer. + var codeUnit1 = UnescapePercentEncoding(ref sourceIndex, buffer); + if (codeUnit1 == -1) + { + return false; + } + + if (codeUnit1 == 0) + { + throw new InvalidOperationException("The path contains null characters."); + } + + if (codeUnit1 <= 0x7F) + { + // first code unit < U+007f, it is a single char ASCII + buffer[destinationIndex++] = (char)codeUnit1; + return true; + } + + // anticipate more code units + int currentDecodeBits; + int codeUnitCount; + int expectValueMin; + if ((codeUnit1 & 0xE0) == 0xC0) + { + // 110x xxxx, expect one more code unit + currentDecodeBits = codeUnit1 & 0x1F; + codeUnitCount = 2; + expectValueMin = 0x80; + } + else if ((codeUnit1 & 0xF0) == 0xE0) + { + // 1110 xxxx, expect two more code units + currentDecodeBits = codeUnit1 & 0x0F; + codeUnitCount = 3; + expectValueMin = 0x800; + } + else if ((codeUnit1 & 0xF8) == 0xF0) + { + // 1111 0xxx, expect three more code units + currentDecodeBits = codeUnit1 & 0x07; + codeUnitCount = 4; + expectValueMin = 0x10000; + } + else + { + // invalid first code unit + return false; + } + + var remainingCodeUnits = codeUnitCount - 1; + while (remainingCodeUnits > 0) + { + // read following three code units + if (sourceIndex == buffer.Length) + { + return false; + } + + var nextSourceIndex = sourceIndex; + var nextCodeUnit = UnescapePercentEncoding(ref nextSourceIndex, buffer); + if (nextCodeUnit == -1) + { + return false; + } + + // When UnescapePercentEncoding returns -1 we shall return false. + // For performance reasons, there is no separate if statement for the above check + // as the condition below also returns -1 for that case. + if ((nextCodeUnit & 0xC0) != 0x80) + { + // the follow up code unit is not in form of 10xx xxxx + return false; + } + + currentDecodeBits = (currentDecodeBits << 6) | (nextCodeUnit & 0x3F); + remainingCodeUnits--; + + sourceIndex = nextSourceIndex; + } + + if (currentDecodeBits < expectValueMin) + { + // overlong encoding + return false; + } + +#if NETSTANDARD2_1_OR_GREATER + if (!System.Text.Rune.TryCreate(currentDecodeBits, out var rune) || !rune.TryEncodeToUtf16(buffer.Slice(destinationIndex), out var charsWritten)) +#else + if (!Rune.TryCreate(currentDecodeBits, out var rune) || !rune.TryEncodeToUtf16(buffer.Slice(destinationIndex), out var charsWritten)) +#endif // NETSTANDARD2_1_OR_GREATER + { + // Reasons for this failure could be: + // Value is in the range of 0xD800-0xDFFF UTF-16 surrogates that are not allowed in UTF-8 + // Value is above the upper Unicode bound of 0x10FFFF + return false; + } + + destinationIndex += charsWritten; + return true; + } + + /// + /// Read the percent-encoding and try unescape it. + /// + /// The operation first peek at the character the + /// iterator points at. If it is % the is then + /// moved on to scan the following to characters. If the two following + /// characters are hexadecimal literals they will be unescaped and the + /// value will be returned. + /// + /// If the first character is not % the iterator + /// will be removed beyond the location of % and -1 will be returned. + /// + /// If the following two characters can't be successfully unescaped the + /// iterator will be move behind the % and -1 + /// will be returned. + /// + /// The value to read + /// The char array + /// The unescaped char if success. Otherwise return -1. + private static int UnescapePercentEncoding(ref int scan, ReadOnlySpan buffer) + { + int tempIdx = scan++; + if (buffer[tempIdx] != '%') + { + return -1; + } + + var probe = scan; + + int firstNibble = ReadHex(ref probe, buffer); + int secondNibble = ReadHex(ref probe, buffer); + int value = firstNibble << 4 | secondNibble; + + // Skip invalid hex values and %2F - '/' + if (value < 0 || value == '/') + { + return -1; + } + scan = probe; + return value; + } + + /// + /// Read the next char and convert it into hexadecimal value. + /// + /// The index will be moved to the next + /// char no matter whether the operation successes. + /// + /// The index of the char in the buffer to read + /// The char span from which the hex to be read + /// The hexadecimal value if successes, otherwise -1. + private static int ReadHex(ref int scan, ReadOnlySpan buffer) + { + // To eliminate boundary checks, using a temporary variable tempIdx. + int tempIdx = scan++; + if ((uint)tempIdx >= (uint)buffer.Length) + { + return -1; + } + int value = buffer[tempIdx]; + + return FromChar(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int FromChar(int c) + { + return (uint)c >= (uint)CharToHexLookup.Length ? -1 : CharToHexLookup[c]; + } + + private static ReadOnlySpan CharToHexLookup => + [ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 15 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 31 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 47 + 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, -1, -1, -1, -1, -1, -1, // 63 + -1, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 79 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 95 + -1, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 111 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 127 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 143 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 159 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 175 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 191 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 207 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 223 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 239 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 // 255 + ]; +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/UrlFragmentString.cs b/src/Imazen.Abstractions/HttpStrings/UrlFragmentString.cs new file mode 100644 index 00000000..2e4dd900 --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/UrlFragmentString.cs @@ -0,0 +1,167 @@ +using Imazen.Abstractions.HttpStrings; + +namespace Imazen.Abstractions.Urls; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + + +/// +/// Provides correct handling for FragmentString value when needed to generate a URI string +/// +[DebuggerDisplay("{Value}")] +public readonly struct UrlFragmentString : IEquatable +{ + /// + /// Represents the empty fragment string. This field is read-only. + /// + public static readonly UrlFragmentString Empty = new UrlFragmentString(string.Empty); + + private readonly string _value; + + /// + /// Initialize the fragment string with a given value. This value must be in escaped and delimited format with + /// a leading '#' character. + /// + /// The fragment string to be assigned to the Value property. + public UrlFragmentString(string value) + { + if (!string.IsNullOrEmpty(value) && value[0] != '#') + { + throw new ArgumentException("The leading '#' must be included for a non-empty fragment.", nameof(value)); + } + _value = value; + } + + /// + /// The escaped fragment string with the leading '#' character + /// + public string Value + { + get { return _value; } + } + + /// + /// True if the fragment string is not empty + /// + public bool HasValue + { + get { return !string.IsNullOrEmpty(_value); } + } + + /// + /// Provides the fragment string escaped in a way which is correct for combining into the URI representation. + /// A leading '#' character will be included unless the Value is null or empty. Characters which are potentially + /// dangerous are escaped. + /// + /// The fragment string value + public override string ToString() + { + return ToUriComponent(); + } + + /// + /// Provides the fragment string escaped in a way which is correct for combining into the URI representation. + /// A leading '#' character will be included unless the Value is null or empty. Characters which are potentially + /// dangerous are escaped. + /// + /// The fragment string value + public string ToUriComponent() + { + // Escape things properly so System.Uri doesn't mis-interpret the data. + return HasValue ? _value : string.Empty; + } + + /// + /// Returns an FragmentString given the fragment as it is escaped in the URI format. The string MUST NOT contain any + /// value that is not a fragment. + /// + /// The escaped fragment as it appears in the URI format. + /// The resulting FragmentString + public static UrlFragmentString FromUriComponent(string uriComponent) + { + if (String.IsNullOrEmpty(uriComponent)) + { + return Empty; + } + return new UrlFragmentString(uriComponent); + } + + /// + /// Returns an FragmentString given the fragment as from a Uri object. Relative Uri objects are not supported. + /// + /// The Uri object + /// The resulting FragmentString + public static UrlFragmentString FromUriComponent(Uri uri) + { + ArgumentNullThrowHelper.ThrowIfNull(uri); + + string fragmentValue = uri.GetComponents(UriComponents.Fragment, UriFormat.UriEscaped); + if (!string.IsNullOrEmpty(fragmentValue)) + { + fragmentValue = "#" + fragmentValue; + } + return new UrlFragmentString(fragmentValue); + } + + /// + /// Evaluates if the current fragment is equal to another fragment . + /// + /// A to compare. + /// if the fragments are equal. + public bool Equals(UrlFragmentString other) + { + if (!HasValue && !other.HasValue) + { + return true; + } + return string.Equals(_value, other._value, StringComparison.Ordinal); + } + + /// + /// Evaluates if the current fragment is equal to an object . + /// + /// An object to compare. + /// if the fragments are equal. + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + { + return !HasValue; + } + return obj is UrlFragmentString && Equals((UrlFragmentString)obj); + } + + /// + /// Gets a hash code for the value. + /// + /// The hash code as an . + public override int GetHashCode() + { + return (HasValue ? _value.GetHashCode() : 0); + } + + /// + /// Evaluates if one fragment is equal to another. + /// + /// A instance. + /// A instance. + /// if the fragments are equal. + public static bool operator ==(UrlFragmentString left, UrlFragmentString right) + { + return left.Equals(right); + } + + /// + /// Evalutes if one fragment is not equal to another. + /// + /// A instance. + /// A instance. + /// if the fragments are not equal. + public static bool operator !=(UrlFragmentString left, UrlFragmentString right) + { + return !left.Equals(right); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/UrlHostString.cs b/src/Imazen.Abstractions/HttpStrings/UrlHostString.cs new file mode 100644 index 00000000..deffa488 --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/UrlHostString.cs @@ -0,0 +1,398 @@ +using System.Diagnostics; +using System.Globalization; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Abstractions.HttpStrings; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +/// +/// Represents the host portion of a URI can be used to construct URI's properly formatted and encoded for use in +/// HTTP headers. +/// +[DebuggerDisplay("{Value}")] +public readonly struct UrlHostString : IEquatable +{ + // Allowed Characters: + // A-Z, a-z, 0-9, ., + // -, %, [, ], : + // Above for IPV6 + private static readonly SearchValues s_safeHostStringChars = + SearchValues.Create("%-.0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ[]abcdefghijklmnopqrstuvwxyz"); + + private static readonly IdnMapping s_idnMapping = new(); + + private readonly string _value; + + /// + /// Creates a new HostString without modification. The value should be Unicode rather than punycode, and may have a port. + /// IPv4 and IPv6 addresses are also allowed, and also may have ports. + /// + /// + public UrlHostString(string value) + { + _value = value; + } + + /// + /// Creates a new HostString from its host and port parts. + /// + /// The value should be Unicode rather than punycode. IPv6 addresses must use square braces. + /// A positive, greater than 0 value representing the port in the host string. + public UrlHostString(string host, int port) + { + ArgumentNullThrowHelper.ThrowIfNull(host); + + if (port <= 0) + { + throw new ArgumentOutOfRangeException(nameof(port), port, "The port must be greater than zero."); + } + + int index; + if (!host.Contains('[') + && (index = host.IndexOf(':')) >= 0 + && index < host.Length - 1 + && host.IndexOf(':', index + 1) >= 0) + { + // IPv6 without brackets ::1 is the only type of host with 2 or more colons + host = $"[{host}]"; + } + + _value = host + ":" + port.ToString(CultureInfo.InvariantCulture); + } + + /// + /// Returns the original value from the constructor. + /// + public string Value + { + get { return _value; } + } + + /// + /// Returns true if the host is set. + /// + public bool HasValue + { + get { return !string.IsNullOrEmpty(_value); } + } + + /// + /// Returns the value of the host part of the value. The port is removed if it was present. + /// IPv6 addresses will have brackets added if they are missing. + /// + /// The host portion of the value. + public string Host + { + get + { + GetParts(_value, out var host, out _); + + return host.ToString(); + } + } + + /// + /// Returns the value of the port part of the host, or null if none is found. + /// + /// The port portion of the value. + public int? Port + { + get + { + GetParts(_value, out _, out var port); + + #if NETSTANDARD2_1_OR_GREATER + if (!StringSegment.IsNullOrEmpty(port) + && int.TryParse(port.AsSpan(), NumberStyles.None, CultureInfo.InvariantCulture, out var p)) + { + return p; + } + #else + if (!StringSegment.IsNullOrEmpty(port) + && int.TryParse(port.ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out var p)) + { + return p; + } + #endif + + return null; + } + } + + /// + /// Returns the value as normalized by ToUriComponent(). + /// + /// The value as normalized by . + public override string ToString() + { + return ToUriComponent(); + } + + /// + /// Returns the value properly formatted and encoded for use in a URI in a HTTP header. + /// Any Unicode is converted to punycode. IPv6 addresses will have brackets added if they are missing. + /// + /// The value formated for use in a URI or HTTP header. + public string ToUriComponent() + { + if (!HasValue) + { + return string.Empty; + } + + if (!_value.AsSpan().ContainsAnyExcept(s_safeHostStringChars)) + { + return _value; + } + + GetParts(_value, out var host, out var port); + + var encoded = s_idnMapping.GetAscii(host.Buffer!, host.Offset, host.Length); + +#if NETSTANDARD2_1_OR_GREATER + return StringSegment.IsNullOrEmpty(port) + ? encoded + : string.Concat(encoded, ":", port.AsSpan()); +#else + return StringSegment.IsNullOrEmpty(port) + ? encoded + : string.Concat(encoded, ":", port.ToString()); +#endif + } + + /// + /// Creates a new HostString from the given URI component. + /// Any punycode will be converted to Unicode. + /// + /// The URI component string to create a from. + /// The that was created. + public static UrlHostString FromUriComponent(string uriComponent) + { + if (!string.IsNullOrEmpty(uriComponent)) + { + int index; + if (uriComponent.Contains('[')) + { + // IPv6 in brackets [::1], maybe with port + } + else if ((index = uriComponent.IndexOf(':')) >= 0 + && index < uriComponent.Length - 1 + && uriComponent.IndexOf(':', index + 1) >= 0) + { + // IPv6 without brackets ::1 is the only type of host with 2 or more colons + } +#if NETSTANDARD2_1_OR_GREATER + else if (uriComponent.Contains("xn--", StringComparison.Ordinal)) +#else + else if (uriComponent.IndexOf("xn--", StringComparison.Ordinal) >= 0) +#endif + { + // Contains punycode + if (index >= 0) + { + // Has a port + var port = uriComponent.AsSpan(index); +#if NETSTANDARD2_1_OR_GREATER + uriComponent = string.Concat(s_idnMapping.GetUnicode(uriComponent, 0, index), port); +#else + uriComponent = string.Concat(s_idnMapping.GetUnicode(uriComponent, 0, index), port.ToString()); +#endif + } + else + { + uriComponent = s_idnMapping.GetUnicode(uriComponent); + } + } + } + return new UrlHostString(uriComponent); + } + + /// + /// Creates a new HostString from the host and port of the give Uri instance. + /// Punycode will be converted to Unicode. + /// + /// The to create a from. + /// The that was created. + public static UrlHostString FromUriComponent(Uri uri) + { + ArgumentNullThrowHelper.ThrowIfNull(uri); + + return new UrlHostString(uri.GetComponents( + UriComponents.NormalizedHost | // Always convert punycode to Unicode. + UriComponents.HostAndPort, UriFormat.Unescaped)); + } + + /// + /// Matches the host portion of a host header value against a list of patterns. + /// The host may be the encoded punycode or decoded unicode form so long as the pattern + /// uses the same format. + /// + /// Host header value with or without a port. + /// A set of pattern to match, without ports. + /// + /// The port on the given value is ignored. The patterns should not have ports. + /// The patterns may be exact matches like "example.com", a top level wildcard "*" + /// that matches all hosts, or a subdomain wildcard like "*.example.com" that matches + /// "abc.example.com:443" but not "example.com:443". + /// Matching is case insensitive. + /// + /// if matches any of the patterns. + public static bool MatchesAny(StringSegment value, IList patterns) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + ArgumentNullThrowHelper.ThrowIfNull(patterns); + + // Drop the port + GetParts(value, out var host, out var port); + + for (int i = 0; i < port.Length; i++) + { + if (port[i] < '0' || '9' < port[i]) + { + throw new FormatException($"The given host value '{value}' has a malformed port."); + } + } + + var count = patterns.Count; + for (int i = 0; i < count; i++) + { + var pattern = patterns[i]; + + if (pattern == "*") + { + return true; + } + + if (StringSegment.Equals(pattern, host, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Sub-domain wildcards: *.example.com + if (pattern.StartsWith("*.", StringComparison.Ordinal) && host.Length >= pattern.Length) + { + // .example.com + var allowedRoot = pattern.Subsegment(1); + + var hostRoot = host.Subsegment(host.Length - allowedRoot.Length); + if (hostRoot.Equals(allowedRoot, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + + /// + /// Compares the equality of the Value property, ignoring case. + /// + /// The to compare against. + /// if they have the same value. + public bool Equals(UrlHostString other) + { + if (!HasValue && !other.HasValue) + { + return true; + } + return string.Equals(_value, other._value, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Compares against the given object only if it is a HostString. + /// + /// The to compare against. + /// if they have the same value. + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + { + return !HasValue; + } + return obj is UrlHostString && Equals((UrlHostString)obj); + } + + /// + /// Gets a hash code for the value. + /// + /// The hash code as an . + public override int GetHashCode() + { + return (HasValue ? StringComparer.OrdinalIgnoreCase.GetHashCode(_value) : 0); + } + + /// + /// Compares the two instances for equality. + /// + /// The left parameter. + /// The right parameter. + /// if both 's have the same value. + public static bool operator ==(UrlHostString left, UrlHostString right) + { + return left.Equals(right); + } + + /// + /// Compares the two instances for inequality. + /// + /// The left parameter. + /// The right parameter. + /// if both 's values are not equal. + public static bool operator !=(UrlHostString left, UrlHostString right) + { + return !left.Equals(right); + } + + /// + /// Parses the current value. IPv6 addresses will have brackets added if they are missing. + /// + /// The value to get the parts of. + /// The portion of the which represents the host. + /// The portion of the which represents the port. + private static void GetParts(StringSegment value, out StringSegment host, out StringSegment port) + { + int index; + port = null; + host = null; + + if (StringSegment.IsNullOrEmpty(value)) + { + return; + } + else if ((index = value.IndexOf(']')) >= 0) + { + // IPv6 in brackets [::1], maybe with port + host = value.Subsegment(0, index + 1); + // Is there a colon and at least one character? + if (index + 2 < value.Length && value[index + 1] == ':') + { + port = value.Subsegment(index + 2); + } + } + else if ((index = value.IndexOf(':')) >= 0 + && index < value.Length - 1 + && value.IndexOf(':', index + 1) >= 0) + { + // IPv6 without brackets ::1 is the only type of host with 2 or more colons + host = $"[{value}]"; + port = null; + } + else if (index >= 0) + { + // Has a port + host = value.Subsegment(0, index); + port = value.Subsegment(index + 1); + } + else + { + host = value; + port = null; + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/UrlPathString.cs b/src/Imazen.Abstractions/HttpStrings/UrlPathString.cs new file mode 100644 index 00000000..76139191 --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/UrlPathString.cs @@ -0,0 +1,501 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; + +namespace Imazen.Abstractions.HttpStrings; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +/// +/// Provides correct escaping for Path and PathBase values when needed to reconstruct a request or redirect URI string +/// +[TypeConverter(typeof(PathStringConverter))] +[DebuggerDisplay("{Value}")] +public readonly struct UrlPathString : IEquatable +{ + private static readonly SearchValues s_validPathChars = + SearchValues.Create("!$&'()*+,-./0123456789:;=@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"); + + internal const int StackAllocThreshold = 128; + + /// + /// Represents the empty path. This field is read-only. + /// + public static readonly UrlPathString Empty = new(string.Empty); + + /// + /// Initialize the path string with a given value. This value must be in unescaped format. Use + /// PathString.FromUriComponent(value) if you have a path value which is in an escaped format. + /// + /// The unescaped path to be assigned to the Value property. + public UrlPathString(string? value) + { + if (!string.IsNullOrEmpty(value) && value![0] != '/') + { + throw new ArgumentException("The path must start with '/' or be empty.", nameof(value)); + } + Value = value; + } + + /// + /// The unescaped path value + /// + public string? Value { get; } + + /// + /// True if the path is not empty + /// + [MemberNotNullWhen(true, nameof(Value))] + public bool HasValue + { + get { return !string.IsNullOrEmpty(Value); } + } + + /// + /// Provides the path string escaped in a way which is correct for combining into the URI representation. + /// + /// The escaped path value + public override string ToString() + { + return ToUriComponent(); + } + + /// + /// Provides the path string escaped in a way which is correct for combining into the URI representation. + /// + /// The escaped path value + public string ToUriComponent() + { + var value = Value; + + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var indexOfInvalidChar = value.AsSpan().IndexOfAnyExcept(s_validPathChars); + + + return (indexOfInvalidChar < 0 + ? value + : ToEscapedUriComponent(value!, indexOfInvalidChar))!; + } + + private static string ToEscapedUriComponent(string value, int i) + { + StringBuilder? buffer = null; + + var start = 0; + var count = i; + var requiresEscaping = false; + + while ((uint)i < (uint)value.Length) + { + var isPercentEncodedChar = false; + if (s_validPathChars.Contains(value[i]) || (isPercentEncodedChar = Uri.IsHexEncoding(value, i))) + { + if (requiresEscaping) + { + // the current segment requires escape + buffer ??= new StringBuilder(value.Length * 3); + buffer.Append(Uri.EscapeDataString(value.Substring(start, count))); + + requiresEscaping = false; + start = i; + count = 0; + } + + if (isPercentEncodedChar) + { + count += 3; + i += 3; + } + else + { + // We just saw a character we don't want to escape. It's likely there are more, do a vectorized search. + var charsToSkip = value.AsSpan(i).IndexOfAnyExcept(s_validPathChars); + + if (charsToSkip < 0) + { + // Only valid characters remain + count += value.Length - i; + break; + } + + count += charsToSkip; + i += charsToSkip; + } + } + else + { + if (!requiresEscaping) + { + // the current segment doesn't require escape + buffer ??= new StringBuilder(value.Length * 3); + buffer.Append(value, start, count); + + requiresEscaping = true; + start = i; + count = 0; + } + + count++; + i++; + } + } + + if (count == value.Length && !requiresEscaping) + { + return value; + } + else + { + Debug.Assert(count > 0); + Debug.Assert(buffer is not null); + + if (requiresEscaping) + { + buffer!.Append(Uri.EscapeDataString(value.Substring(start, count))); + } + else + { + buffer!.Append(value, start, count); + } + + return buffer.ToString(); + } + } + + /// + /// Returns an PathString given the path as it is escaped in the URI format. The string MUST NOT contain any + /// value that is not a path. + /// + /// The escaped path as it appears in the URI format. + /// The resulting PathString + public static UrlPathString FromUriComponent(string uriComponent) + { + int position = uriComponent.IndexOf('%'); + if (position == -1) + { + return new UrlPathString(uriComponent); + } + Span pathBuffer = uriComponent.Length <= StackAllocThreshold ? stackalloc char[StackAllocThreshold] : new char[uriComponent.Length]; +#if NETSTANDARD2_1_OR_GREATER + uriComponent.CopyTo(pathBuffer); +#else + uriComponent.AsSpan().CopyTo(pathBuffer); +#endif + var length = UrlDecoder.DecodeInPlace(pathBuffer.Slice(position, uriComponent.Length - position)); + pathBuffer = pathBuffer.Slice(0, position + length); + return new UrlPathString(pathBuffer.ToString()); + } + + /// + /// Returns an PathString given the path as from a Uri object. Relative Uri objects are not supported. + /// + /// The Uri object + /// The resulting PathString + public static UrlPathString FromUriComponent(Uri uri) + { + ArgumentNullThrowHelper.ThrowIfNull(uri); + var uriComponent = uri.GetComponents(UriComponents.Path, UriFormat.UriEscaped); + Span pathBuffer = uriComponent.Length < StackAllocThreshold ? stackalloc char[StackAllocThreshold] : new char[uriComponent.Length + 1]; + pathBuffer[0] = '/'; + var length = UrlDecoder.DecodeRequestLine(uriComponent.AsSpan(), pathBuffer.Slice(1)); + pathBuffer = pathBuffer.Slice(0, length + 1); + return new UrlPathString(pathBuffer.ToString()); + } + + /// + /// Determines whether the beginning of this instance matches the specified . + /// + /// The to compare. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(UrlPathString other) + { + return StartsWithSegments(other, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether the beginning of this instance matches the specified when compared + /// using the specified comparison option. + /// + /// The to compare. + /// One of the enumeration values that determines how this and value are compared. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(UrlPathString other, StringComparison comparisonType) + { + var value1 = Value ?? string.Empty; + var value2 = other.Value ?? string.Empty; + if (value1.StartsWith(value2, comparisonType)) + { + return value1.Length == value2.Length || value1[value2.Length] == '/'; + } + return false; + } + + /// + /// Determines whether the beginning of this instance matches the specified and returns + /// the remaining segments. + /// + /// The to compare. + /// The remaining segments after the match. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(UrlPathString other, out UrlPathString remaining) + { + return StartsWithSegments(other, StringComparison.OrdinalIgnoreCase, out remaining); + } + + /// + /// Determines whether the beginning of this instance matches the specified when compared + /// using the specified comparison option and returns the remaining segments. + /// + /// The to compare. + /// One of the enumeration values that determines how this and value are compared. + /// The remaining segments after the match. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(UrlPathString other, StringComparison comparisonType, out UrlPathString remaining) + { + var value1 = Value ?? string.Empty; + var value2 = other.Value ?? string.Empty; + if (value1.StartsWith(value2, comparisonType)) + { + if (value1.Length == value2.Length || value1[value2.Length] == '/') + { + remaining = new UrlPathString(value1[value2.Length..]); + return true; + } + } + remaining = Empty; + return false; + } + + /// + /// Determines whether the beginning of this instance matches the specified and returns + /// the matched and remaining segments. + /// + /// The to compare. + /// The matched segments with the original casing in the source value. + /// The remaining segments after the match. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(UrlPathString other, out UrlPathString matched, out UrlPathString remaining) + { + return StartsWithSegments(other, StringComparison.OrdinalIgnoreCase, out matched, out remaining); + } + + /// + /// Determines whether the beginning of this instance matches the specified when compared + /// using the specified comparison option and returns the matched and remaining segments. + /// + /// The to compare. + /// One of the enumeration values that determines how this and value are compared. + /// The matched segments with the original casing in the source value. + /// The remaining segments after the match. + /// true if value matches the beginning of this string; otherwise, false. + public bool StartsWithSegments(UrlPathString other, StringComparison comparisonType, out UrlPathString matched, out UrlPathString remaining) + { + var value1 = Value ?? string.Empty; + var value2 = other.Value ?? string.Empty; + if (value1.StartsWith(value2, comparisonType)) + { + if (value1.Length == value2.Length || value1[value2.Length] == '/') + { + matched = new UrlPathString(value1.Substring(0, value2.Length)); + remaining = new UrlPathString(value1[value2.Length..]); + return true; + } + } + remaining = Empty; + matched = Empty; + return false; + } + + /// + /// Adds two PathString instances into a combined PathString value. + /// + /// The combined PathString value + public UrlPathString Add(UrlPathString other) + { + if (HasValue && + other.HasValue && + Value[^1] == '/') + { + // If the path string has a trailing slash and the other string has a leading slash, we need + // to trim one of them. +#if NETSTANDARD2_1_OR_GREATER + var combined = string.Concat(Value.AsSpan(), other.Value.AsSpan(1)); +#else + var combined = string.Concat(Value, other.Value[1..]); +#endif + return new UrlPathString(combined); + } + + return new UrlPathString(Value + other.Value); + } + + /// + /// Combines a PathString and QueryString into the joined URI formatted string value. + /// + /// The joined URI formatted string value + public string Add(UrlQueryString other) + { + return ToUriComponent() + other.ToUriComponent(); + } + + /// + /// Compares this PathString value to another value. The default comparison is StringComparison.OrdinalIgnoreCase. + /// + /// The second PathString for comparison. + /// True if both PathString values are equal + public bool Equals(UrlPathString other) + { + return Equals(other, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Compares this PathString value to another value using a specific StringComparison type + /// + /// The second PathString for comparison + /// The StringComparison type to use + /// True if both PathString values are equal + public bool Equals(UrlPathString other, StringComparison comparisonType) + { + if (!HasValue && !other.HasValue) + { + return true; + } + return string.Equals(Value, other.Value, comparisonType); + } + + /// + /// Compares this PathString value to another value. The default comparison is StringComparison.OrdinalIgnoreCase. + /// + /// The second PathString for comparison. + /// True if both PathString values are equal + public override bool Equals(object? obj) + { + if (obj is null) + { + return !HasValue; + } + return obj is UrlPathString pathString && Equals(pathString); + } + + /// + /// Returns the hash code for the PathString value. The hash code is provided by the OrdinalIgnoreCase implementation. + /// + /// The hash code + public override int GetHashCode() + { + return (HasValue ? StringComparer.OrdinalIgnoreCase.GetHashCode(Value) : 0); + } + + /// + /// Operator call through to Equals + /// + /// The left parameter + /// The right parameter + /// True if both PathString values are equal + public static bool operator ==(UrlPathString left, UrlPathString right) + { + return left.Equals(right); + } + + /// + /// Operator call through to Equals + /// + /// The left parameter + /// The right parameter + /// True if both PathString values are not equal + public static bool operator !=(UrlPathString left, UrlPathString right) + { + return !left.Equals(right); + } + + /// + /// + /// The left parameter + /// The right parameter + /// The ToString combination of both values + public static string operator +(string left, UrlPathString right) + { + // This overload exists to prevent the implicit string<->PathString converter from + // trying to call the PathString+PathString operator for things that are not path strings. + return string.Concat(left, right.ToString()); + } + + /// + /// + /// The left parameter + /// The right parameter + /// The ToString combination of both values + public static string operator +(UrlPathString left, string? right) + { + // This overload exists to prevent the implicit string<->PathString converter from + // trying to call the PathString+PathString operator for things that are not path strings. + return string.Concat(left.ToString(), right); + } + + /// + /// Operator call through to Add + /// + /// The left parameter + /// The right parameter + /// The PathString combination of both values + public static UrlPathString operator +(UrlPathString left, UrlPathString right) + { + return left.Add(right); + } + + /// + /// Operator call through to Add + /// + /// The left parameter + /// The right parameter + /// The PathString combination of both values + public static string operator +(UrlPathString left, UrlQueryString right) + { + return left.Add(right); + } + + /// + /// Implicitly creates a new PathString from the given string. + /// + /// + public static implicit operator UrlPathString(string? s) + => ConvertFromString(s); + + /// + /// Implicitly calls ToString(). + /// + /// + public static implicit operator string(UrlPathString urlPath) + => urlPath.ToString(); + + internal static UrlPathString ConvertFromString(string? s) + => string.IsNullOrEmpty(s) ? new UrlPathString(s) : FromUriComponent(s!); +} + +internal sealed class PathStringConverter : TypeConverter +{ + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + => sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + => value is string @string + ? UrlPathString.ConvertFromString(@string) + : base.ConvertFrom(context, culture, value); + + public override object? ConvertTo(ITypeDescriptorContext? context, + CultureInfo? culture, object? value, Type destinationType) + { + ArgumentNullThrowHelper.ThrowIfNull(destinationType); + + return destinationType == typeof(string) + ? value?.ToString() ?? string.Empty + : base.ConvertTo(context, culture, value, destinationType); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/HttpStrings/UrlQueryString.cs b/src/Imazen.Abstractions/HttpStrings/UrlQueryString.cs new file mode 100644 index 00000000..d0f55c2a --- /dev/null +++ b/src/Imazen.Abstractions/HttpStrings/UrlQueryString.cs @@ -0,0 +1,304 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Abstractions.HttpStrings; + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +/// +/// Provides correct handling for QueryString value when needed to reconstruct a request or redirect URI string +/// +[DebuggerDisplay("{Value}")] +public readonly struct UrlQueryString : IEquatable +{ + /// + /// Represents the empty query string. This field is read-only. + /// + public static readonly UrlQueryString Empty = new UrlQueryString(string.Empty); + + /// + /// Initialize the query string with a given value. This value must be in escaped and delimited format with + /// a leading '?' character. + /// + /// The query string to be assigned to the Value property. + public UrlQueryString(string? value) + { + if (!string.IsNullOrEmpty(value) && value![0] != '?') + { + throw new ArgumentException("The leading '?' must be included for a non-empty query.", nameof(value)); + } + Value = value; + } + + public Dictionary? ParseNullable() + { + return QueryHelpers.ParseNullableQuery(Value); + } + + public Dictionary Parse() + { + return QueryHelpers.ParseQuery(Value); + } + + /// + /// The escaped query string with the leading '?' character + /// + public string? Value { get; } + + /// + /// True if the query string is not empty + /// + [MemberNotNullWhen(true, nameof(Value))] + public bool HasValue => !string.IsNullOrEmpty(Value); + + /// + /// Provides the query string escaped in a way which is correct for combining into the URI representation. + /// A leading '?' character will be included unless the Value is null or empty. Characters which are potentially + /// dangerous are escaped. + /// + /// The query string value + public override string ToString() + { + return ToUriComponent(); + } + + /// + /// Provides the query string escaped in a way which is correct for combining into the URI representation. + /// A leading '?' character will be included unless the Value is null or empty. Characters which are potentially + /// dangerous are escaped. + /// + /// The query string value + public string ToUriComponent() + { + // Escape things properly so System.Uri doesn't mis-interpret the data. + return HasValue ? Value.Replace("#", "%23") : string.Empty; + } + + /// + /// Returns an QueryString given the query as it is escaped in the URI format. The string MUST NOT contain any + /// value that is not a query. + /// + /// The escaped query as it appears in the URI format. + /// The resulting QueryString + public static UrlQueryString FromUriComponent(string uriComponent) + { + if (string.IsNullOrEmpty(uriComponent)) + { + return new UrlQueryString(string.Empty); + } + return new UrlQueryString(uriComponent); + } + + /// + /// Returns an QueryString given the query as from a Uri object. Relative Uri objects are not supported. + /// + /// The Uri object + /// The resulting QueryString + public static UrlQueryString FromUriComponent(Uri uri) + { + ArgumentNullThrowHelper.ThrowIfNull(uri); + + string queryValue = uri.GetComponents(UriComponents.Query, UriFormat.UriEscaped); + if (!string.IsNullOrEmpty(queryValue)) + { + queryValue = "?" + queryValue; + } + return new UrlQueryString(queryValue); + } + + /// + /// Create a query string with a single given parameter name and value. + /// + /// The un-encoded parameter name + /// The un-encoded parameter value + /// The resulting QueryString + public static UrlQueryString Create(string name, string value) + { + ArgumentNullThrowHelper.ThrowIfNull(name); + + if (!string.IsNullOrEmpty(value)) + { + value = UrlEncoder.Default.Encode(value); + } + return new UrlQueryString($"?{UrlEncoder.Default.Encode(name)}={value}"); + } + + /// + /// Creates a query string composed from the given name value pairs. + /// + /// + /// The resulting QueryString + public static UrlQueryString Create(IEnumerable> parameters) + { + var builder = new StringBuilder(); + var first = true; + foreach (var pair in parameters) + { + AppendKeyValuePair(builder, pair.Key, pair.Value, first); + first = false; + } + + return new UrlQueryString(builder.ToString()); + } + + /// + /// Creates a query string composed from the given name value pairs. + /// + /// + /// The resulting QueryString + public static UrlQueryString Create(IEnumerable> parameters) + { + var builder = new StringBuilder(); + var first = true; + + foreach (var pair in parameters) + { + // If nothing in this pair.Values, append null value and continue + if (StringValues.IsNullOrEmpty(pair.Value)) + { + AppendKeyValuePair(builder, pair.Key, null, first); + first = false; + continue; + } + // Otherwise, loop through values in pair.Value + foreach (var value in pair.Value) + { + AppendKeyValuePair(builder, pair.Key, value, first); + first = false; + } + } + + return new UrlQueryString(builder.ToString()); + } + + /// + /// Concatenates to the current query string. + /// + /// The to concatenate. + /// The concatenated . + public UrlQueryString Add(UrlQueryString other) + { + if (!HasValue || Value.Equals("?", StringComparison.Ordinal)) + { + return other; + } + if (!other.HasValue || other.Value.Equals("?", StringComparison.Ordinal)) + { + return this; + } + + // ?name1=value1 Add ?name2=value2 returns ?name1=value1&name2=value2 + // NOTE: had to add .ToString() for compatibility with .NET Framework +#if NETSTANDARD2_1_OR_GREATER + return new QueryString(string.Concat(Value, "&", other.Value.AsSpan(1))); +#else + return new UrlQueryString(string.Concat(Value, "&", other.Value[1..])); +#endif + } + + /// + /// Concatenates a query string with and + /// to the current query string. + /// + /// The name of the query string to concatenate. + /// The value of the query string to concatenate. + /// The concatenated . + public UrlQueryString Add(string name, string value) + { + ArgumentNullThrowHelper.ThrowIfNull(name); + + if (!HasValue || Value.Equals("?", StringComparison.Ordinal)) + { + return Create(name, value); + } + + var builder = new StringBuilder(Value); + AppendKeyValuePair(builder, name, value, first: false); + return new UrlQueryString(builder.ToString()); + } + + /// + /// Evalutes if the current query string is equal to . + /// + /// The to compare. + /// if the query strings are equal. + public bool Equals(UrlQueryString other) + { + if (!HasValue && !other.HasValue) + { + return true; + } + return string.Equals(Value, other.Value, StringComparison.Ordinal); + } + + /// + /// Evaluates if the current query string is equal to an object . + /// + /// An object to compare. + /// if the query strings are equal. + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) + { + return !HasValue; + } + return obj is UrlQueryString && Equals((UrlQueryString)obj); + } + + /// + /// Gets a hash code for the value. + /// + /// The hash code as an . + public override int GetHashCode() + { + return (HasValue ? Value.GetHashCode() : 0); + } + + /// + /// Evaluates if one query string is equal to another. + /// + /// A instance. + /// A instance. + /// if the query strings are equal. + public static bool operator ==(UrlQueryString left, UrlQueryString right) + { + return left.Equals(right); + } + + /// + /// Evaluates if one query string is not equal to another. + /// + /// A instance. + /// A instance. + /// if the query strings are not equal. + public static bool operator !=(UrlQueryString left, UrlQueryString right) + { + return !left.Equals(right); + } + + /// + /// Concatenates and into a single query string. + /// + /// A instance. + /// A instance. + /// The concatenated . + public static UrlQueryString operator +(UrlQueryString left, UrlQueryString right) + { + return left.Add(right); + } + + private static void AppendKeyValuePair(StringBuilder builder, string key, string? value, bool first) + { + builder.Append(first ? '?' : '&'); + builder.Append(UrlEncoder.Default.Encode(key)); + builder.Append('='); + if (!string.IsNullOrEmpty(value)) + { + builder.Append(UrlEncoder.Default.Encode(value!)); + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/IUniqueNamed.cs b/src/Imazen.Abstractions/IUniqueNamed.cs new file mode 100644 index 00000000..6b8b9970 --- /dev/null +++ b/src/Imazen.Abstractions/IUniqueNamed.cs @@ -0,0 +1,19 @@ +namespace Imazen.Abstractions +{ + public interface IUniqueNamed + { + /// + /// Must be unique across all caches and providers + /// + + string UniqueName { get; } + } + + public static class UniqueNamedExtensions + { + public static string NameAndClass(this T obj) where T : IUniqueNamed + { + return $"{obj.UniqueName} ({obj.GetType().Name})"; + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Imazen.Abstractions.csproj b/src/Imazen.Abstractions/Imazen.Abstractions.csproj new file mode 100644 index 00000000..0c084936 --- /dev/null +++ b/src/Imazen.Abstractions/Imazen.Abstractions.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/Imazen.Common/Instrumentation/Support/ConcurrentBitArray.cs b/src/Imazen.Abstractions/Internal/ConcurrentBitArray.cs similarity index 92% rename from src/Imazen.Common/Instrumentation/Support/ConcurrentBitArray.cs rename to src/Imazen.Abstractions/Internal/ConcurrentBitArray.cs index 90c16c67..a8fb9337 100644 --- a/src/Imazen.Common/Instrumentation/Support/ConcurrentBitArray.cs +++ b/src/Imazen.Abstractions/Internal/ConcurrentBitArray.cs @@ -1,9 +1,4 @@ -using System; -using System.Diagnostics.Contracts; -using System.IO; using System.Runtime.InteropServices; -using System.Runtime.Serialization.Formatters.Binary; -using System.Threading; namespace Imazen.Common.Instrumentation.Support { @@ -31,6 +26,7 @@ public ConcurrentBitArray(int bitCount) _data = new long[(bitCount + 63) / 64]; } + public void LoadFromBytes(byte[] bytes) { @@ -51,9 +47,8 @@ public void LoadFromSpan(Span bytes) { throw new ArgumentException($"Invalid input span size - must match the number of bytes in the ConcurrentBitArray {bytes.Length} != {ByteCount}"); } - // We want to overwrite the long array with bytes in the same order as the bytes in the span. - // little-endian - + if (bytes.Length > Buffer.ByteLength(_data)) throw new Exception($"Buffer is too small, {bytes.Length} > {Buffer.ByteLength(_data)}"); + bytes.CopyTo(MemoryMarshal.Cast(_data)); } @@ -75,6 +70,7 @@ public void MergeTrueBitsFrom(ConcurrentBitArray other) { throw new ArgumentException("ConcurrentBitArray instances must be the same size"); } + // TODO, try Vector for this for (int i = 0; i < _data.Length; i++) { long otherValue = other._data[i]; @@ -131,8 +127,8 @@ internal void SetBit(int index, bool value) public bool this[int index] { - get { return GetBit(index); } - set { SetBit(index, value); } + get => GetBit(index); + set => SetBit(index, value); } } } diff --git a/src/Imazen.Abstractions/Internal/Fnv1AHash.cs b/src/Imazen.Abstractions/Internal/Fnv1AHash.cs new file mode 100644 index 00000000..ee134f91 --- /dev/null +++ b/src/Imazen.Abstractions/Internal/Fnv1AHash.cs @@ -0,0 +1,78 @@ +namespace Imazen.Abstractions.Internal; + +internal struct Fnv1AHash +{ + private const ulong FnvPrime = 0x00000100000001B3; + private const ulong FnvOffsetBasis = 0xCBF29CE484222325; + private ulong hash; + public ulong CurrentHash => hash; + + public static Fnv1AHash Create() + { + return new Fnv1AHash(){ + hash = FnvOffsetBasis + }; + } + + private void HashInternal(ReadOnlySpan array) + { + foreach (var t in array) + { + unchecked + { + hash ^= t; + hash *= FnvPrime; + } + } + } + private void HashInternal(ReadOnlySpan array) + { + foreach (var t in array) + { + unchecked + { + hash ^= (byte)(t & 0xFF); + hash *= FnvPrime; + hash ^= (byte)((t >> 8) & 0xFF); + hash *= FnvPrime; + } + } + } + public void Add(string? s) + { + if (s == null) return; + HashInternal(s.AsSpan()); + } + public void Add(ReadOnlySpan s) + { + HashInternal(s); + } + + public void Add(byte[] array) + { + HashInternal(array); + } + public void Add(int i) + { + unchecked + { + byte b1 = (byte) (i & 0xFF); + byte b2 = (byte) ((i >> 8) & 0xFF); + byte b3 = (byte) ((i >> 16) & 0xFF); + byte b4 = (byte) ((i >> 24) & 0xFF); + hash ^= b1; + hash *= FnvPrime; + hash ^= b2; + hash *= FnvPrime; + hash ^= b3; + hash *= FnvPrime; + hash ^= b4; + } + } + public void Add(long val) + { + Add((int)(val & 0xFFFFFFFF)); + Add((int) (val >> 32)); + } + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/IReLogStore.cs b/src/Imazen.Abstractions/Logging/IReLogStore.cs new file mode 100644 index 00000000..76cef94b --- /dev/null +++ b/src/Imazen.Abstractions/Logging/IReLogStore.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Logging; + +namespace Imazen.Abstractions.Logging +{ + public interface IReLogStore + { + void Log(string categoryName, Stack? scopeStack, LogLevel logLevel, EventId eventId, + TState state, Exception? exception, Func formatter, bool retain, + string? retainUniqueKey); + + public string GetReport(ReLogStoreReportOptions options); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/IReLogger.cs b/src/Imazen.Abstractions/Logging/IReLogger.cs new file mode 100644 index 00000000..0bf6bbfc --- /dev/null +++ b/src/Imazen.Abstractions/Logging/IReLogger.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Logging; + +namespace Imazen.Abstractions.Logging +{ + /// + /// An ILogger instance capable of retaining information in memory for later recall. + /// Call .Retain. to enter retain mode. + /// + public interface IReLogger : ILogger + { + /// + /// Provides a logger that retains log entries according to app configuration + /// + IReLogger WithRetain { get; } + + /// + /// Provides a logger that will retain only unique log entries with the given key. + /// key does not need to include the category hierarchy or log level. + /// If specified, it will be used in place of (Exception.GetType().GetHashCode() if present, which falls back to scope hierarchy + args-to-formatter) + /// + /// + /// + /// + IReLogger WithRetainUnique(string key); + + /// + /// Provides a new logger that will append the given string to the current category name + /// + /// + /// + IReLogger WithSubcategory(string subcategoryString); + + + + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/IReLoggerFactory.cs b/src/Imazen.Abstractions/Logging/IReLoggerFactory.cs new file mode 100644 index 00000000..95e1a04a --- /dev/null +++ b/src/Imazen.Abstractions/Logging/IReLoggerFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Logging; + +namespace Imazen.Abstractions.Logging +{ + /// + /// Like ILoggerFactory, but provides IReLogger instances, + /// which can retain a set of redacted issues and provide them to the app for a diagnostics summary + /// + public interface IReLoggerFactory : ILoggerFactory + { + IReLogger CreateReLogger(string categoryName); + + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/ReLogStore.cs b/src/Imazen.Abstractions/Logging/ReLogStore.cs new file mode 100644 index 00000000..d9845307 --- /dev/null +++ b/src/Imazen.Abstractions/Logging/ReLogStore.cs @@ -0,0 +1,181 @@ +using System.Collections.Concurrent; +using System.Text; +using Imazen.Abstractions.Internal; +using Microsoft.Extensions.Logging; + +namespace Imazen.Abstractions.Logging +{ + public class ReLogStore : IReLogStore + { + private readonly ReLogStoreOptions options; + private readonly ConcurrentDictionary logEntries = new ConcurrentDictionary(); + public ReLogStore(ReLogStoreOptions options) + { + this.options = options; + } + public void Log(string categoryName, Stack? scopeStack, LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter, bool retain, string? retainUniqueKey) + { + // Check if it meets the criteria + // If so, store it + if (!retain) return; + + if (logEntries.Count > options.MaxEventGroups) + { + return; + } + + // hash categoryName, exception class (if present), and (retainUniqueKey) if present. + var hash = Fnv1AHash.Create(); + hash.Add(categoryName); + hash.Add((int)logLevel); + var exceptionType = exception?.GetType().GetHashCode(); + hash.Add(exceptionType?.GetHashCode() ?? 0); + + if (retainUniqueKey == null && exceptionType == null) + { + //TOOD: work around this alloc + hash.Add(state?.ToString()); + // loop scope stack and add to hash + hash.Add(scopeStack.GetScopeString()); + } + else + { + hash.Add(retainUniqueKey ?? ""); + } + + // TODO: later, we can update them periodically + var hashKey = hash.CurrentHash; + var entries = logEntries.GetOrAdd(hashKey, new LogEntryGroup(hashKey, logLevel)); + if (retainUniqueKey != null) + { + if (entries.Count > options.MaxEntriesPerUniqueKey) + { + entries.IncrementEntriesNotAdded(); + return; + } + } + else + { + if (entries.Count > options.MaxEntriesPerExceptionClass) + { + entries.IncrementEntriesNotAdded(); + return; + } + } + + entries.Add(new LogEntry() + { + CategoryName = categoryName, + ScopeString = scopeStack.GetScopeString(), + LogLevel = logLevel, + EventId = eventId, + Message = formatter(state, exception), + RetainUniqueKey = retainUniqueKey, + Timestamp = DateTimeOffset.UtcNow + }); + + } + + public string GetReport(ReLogStoreReportOptions reportOptions) + { + // Create a list of all groups, sort by loglevel and count + var groups = logEntries.Values.OrderByDescending(g => g.LogLevel).ThenByDescending(g => g.TotalEntries).ToList(); + var sb = new StringBuilder(); + // Showing {x} of {y} log entries marked for retention (z groups) + sb.Append($"Showing {groups.Sum(g => g.TotalEntries)} of {logEntries.Count} log entries marked for retention ({groups.Count} groups)\n"); + foreach (var group in groups) + { + // (totalCount Error) 2020-01-01 00:00:00.000 CategoryName ScopeString Message EventId + foreach (var entry in group.OrderByDescending(e => e.Timestamp)) + { + entry.Format(sb, group.TotalEntries, true); + sb.Append("\n"); + } + } + + return sb.ToString(); + } + + } + + internal static class ScopeStringExtensions + { + // loop through stack and build string + public static string? GetScopeString(this Stack? scopeStack) + { + if (scopeStack == null) return null; + var sb = new StringBuilder(); + foreach (var scope in scopeStack) + { + sb.Append(scope.ToString()); + sb.Append(">"); + } + if (sb.Length > 0) sb.Length--; + return sb.ToString(); + } + } + + internal class LogEntryGroup : ConcurrentBag + { + public LogEntryGroup(ulong groupHash, LogLevel logLevel) : base() + { + GroupHash = groupHash; + this.LogLevel = logLevel; + } + + public LogLevel LogLevel { get; } + private ulong GroupHash { get; } + private int entriesNotAdded; + public int EntriesNotAdded => entriesNotAdded; + + public int TotalEntries => entriesNotAdded + this.Count; + public void IncrementEntriesNotAdded() + { + entriesNotAdded++; + } + } + + internal struct LogEntry + { + public string CategoryName { get; set; } + public string? ScopeString { get; set; } + public LogLevel LogLevel { get; set; } + public EventId EventId { get; set; } + public string Message { get; set; } + public string? RetainUniqueKey { get; set; } + public DateTimeOffset Timestamp { get; set; } + + public void Format(StringBuilder sb, int totalCount, bool relativeTime) + { + // (totalCount Error) 2020-01-01 00:00:00.000 CategoryName ScopeString Message EventId + sb.EnsureCapacity(sb.Length + 100); + sb.Append('('); + sb.Append(totalCount); + sb.Append(' '); + sb.Append(LogLevel); + sb.Append(") "); + if (relativeTime) + { + sb.Append((Timestamp - DateTimeOffset.UtcNow).ToString("hh\\:mm\\:ss")); + sb.Append("s ago"); + } + else + { + sb.Append(Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")); + } + + sb.Append(' '); + sb.Append(CategoryName); + if (ScopeString != null) + { + sb.Append(' '); + sb.Append(ScopeString); + } + sb.Append(' '); + sb.Append(Message); + sb.Append(' '); + sb.Append(EventId); + + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/ReLogStoreOptions.cs b/src/Imazen.Abstractions/Logging/ReLogStoreOptions.cs new file mode 100644 index 00000000..3af788d5 --- /dev/null +++ b/src/Imazen.Abstractions/Logging/ReLogStoreOptions.cs @@ -0,0 +1,9 @@ +namespace Imazen.Abstractions.Logging +{ + public class ReLogStoreOptions + { + public int MaxEventGroups { get; set; } = 50; + public int MaxEntriesPerUniqueKey { get; set; } = 3; + public int MaxEntriesPerExceptionClass { get; set; } = 5; + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/ReLogStoreReportOptions.cs b/src/Imazen.Abstractions/Logging/ReLogStoreReportOptions.cs new file mode 100644 index 00000000..27f59134 --- /dev/null +++ b/src/Imazen.Abstractions/Logging/ReLogStoreReportOptions.cs @@ -0,0 +1,13 @@ +namespace Imazen.Abstractions.Logging +{ + + public enum ReLogStoreReportType + { + QuickSummary, + FullReport + } + public record ReLogStoreReportOptions + { + public ReLogStoreReportType ReportType { get; init; } = ReLogStoreReportType.QuickSummary; + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/ReLogger.cs b/src/Imazen.Abstractions/Logging/ReLogger.cs new file mode 100644 index 00000000..667e5343 --- /dev/null +++ b/src/Imazen.Abstractions/Logging/ReLogger.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; + +namespace Imazen.Abstractions.Logging +{ + internal class ReLogger : IReLogger + { + private readonly ILogger impl; + private readonly bool retain; + private readonly string? retainUniqueKey; + private readonly ReLoggerFactory parent; + private readonly string categoryName; + + internal ReLogger(ILogger impl, ReLoggerFactory parent, string categoryName) + { + this.impl = impl; + this.parent = parent; + this.retain = false; + this.retainUniqueKey = null; + this.categoryName = categoryName; + } + private ReLogger(ILogger impl, ReLoggerFactory parent, string categoryName, bool retain, string? retainUniqueKey) + { + this.impl = impl; + this.retain = retain; + this.parent = parent; + this.retainUniqueKey = retainUniqueKey; + this.categoryName = categoryName; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + parent.Log(categoryName, logLevel, eventId, state, exception, formatter, retain, retainUniqueKey); + impl.Log(logLevel, eventId, state, exception, formatter); + } + + public bool IsEnabled(LogLevel logLevel) + { + return impl.IsEnabled(logLevel); + } + + + private IReLogger? withRetain = null; + public IReLogger WithRetain + { + get + { + withRetain ??= new ReLogger(impl, parent, categoryName, true, null); + return withRetain; + } + } + + public IReLogger WithRetainUnique(string key) + { + return new ReLogger(impl, parent, categoryName, true, key); + } + + public IReLogger WithSubcategory(string subcategoryString) + { + return new ReLogger(impl, parent, $@"{categoryName}>{subcategoryString}", retain, retainUniqueKey); + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + var instance = new ReLoggerScope(impl.BeginScope(state), state, this); + parent.BeginScope(state, instance); + return instance; + } + internal void EndScope(TState state, ReLoggerScope scope) where TState : notnull + { + parent.EndScope(state, scope); + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/ReLoggerFactory.cs b/src/Imazen.Abstractions/Logging/ReLoggerFactory.cs new file mode 100644 index 00000000..eab3f281 --- /dev/null +++ b/src/Imazen.Abstractions/Logging/ReLoggerFactory.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Logging; + +namespace Imazen.Abstractions.Logging +{ + public class ReLoggerFactory : IReLoggerFactory + { + private readonly ILoggerFactory impl; + private readonly IReLogStore store; + + public ReLoggerFactory(ILoggerFactory impl, IReLogStore store) + { + this.impl = impl; + this.store = store; + } + public void Dispose() + { + impl.Dispose(); + } + + public ILogger CreateLogger(string categoryName) => CreateReLogger(categoryName); + public IReLogger CreateReLogger(string categoryName) + { + return new ReLogger(impl.CreateLogger(categoryName), this, categoryName); + } + public void AddProvider(ILoggerProvider provider) + { + impl.AddProvider(provider); + } + + internal void Log(string categoryName, LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter, bool retain, string? retainUniqueKey) + { + store.Log(categoryName, scopes.Value, logLevel, eventId, state, exception, formatter, retain, retainUniqueKey); + } + + // AsyncLocal is thread-local style storage that is async-aware, and is used by the official implementation + // for tracking BeginScope, so it's certainly fine + private readonly AsyncLocal> scopes = new AsyncLocal>(); + internal void BeginScope(TState state, ReLoggerScope instance) where TState : notnull + { + scopes.Value ??= new Stack(4); + scopes.Value.Push(instance); + } + + internal void EndScope(TState state, ReLoggerScope scope) where TState : notnull + { + if (scopes.Value == null) return; + if (scopes.Value.Count == 0) return; + if (ReferenceEquals(scopes.Value.Peek(), scope)) + { + scopes.Value.Pop(); + } + } + + + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/ReLoggerScope.cs b/src/Imazen.Abstractions/Logging/ReLoggerScope.cs new file mode 100644 index 00000000..ebb8e0db --- /dev/null +++ b/src/Imazen.Abstractions/Logging/ReLoggerScope.cs @@ -0,0 +1,29 @@ +namespace Imazen.Abstractions.Logging +{ + internal sealed class ReLoggerScope : IDisposable where TState : notnull + { + internal ReLoggerScope(IDisposable impl, TState state, ReLogger parent) + { + this.impl = impl; + this.parent = parent; + this.State = state; + } + + private readonly IDisposable impl; + private readonly ReLogger parent; + + private TState State { get; } + + public void Dispose() + { + impl.Dispose(); + parent.EndScope(State, this); + } + + public override string ToString() + { + // ReSharper disable once HeapView.PossibleBoxingAllocation + return State.ToString() ?? ""; + } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Logging/ServiceRegistrationExtensions.cs b/src/Imazen.Abstractions/Logging/ServiceRegistrationExtensions.cs new file mode 100644 index 00000000..19dd18e9 --- /dev/null +++ b/src/Imazen.Abstractions/Logging/ServiceRegistrationExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Imazen.Abstractions.Logging; + +public static class ServiceRegistrationExtensions +{ + public static IServiceCollection AddImageflowReLogStoreAndReLoggerFactoryIfMissing(this IServiceCollection services, + ReLogStoreOptions? logStorageOptions = null) + { + // Only add if not already added + if (services.All(x => x.ServiceType != typeof(IReLogStore))) + { + services.AddSingleton((container) => new ReLogStore(logStorageOptions ?? new ReLogStoreOptions())); + } + if (services.All(x => x.ServiceType != typeof(IReLoggerFactory))) + { + services.AddSingleton(container => + new ReLoggerFactory(container.GetRequiredService(), + container.GetRequiredService())); + } + return services; + } + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Resulting/Empty.cs b/src/Imazen.Abstractions/Resulting/Empty.cs new file mode 100644 index 00000000..a555bfe9 --- /dev/null +++ b/src/Imazen.Abstractions/Resulting/Empty.cs @@ -0,0 +1,7 @@ +namespace Imazen.Abstractions.Resulting; + +public sealed class Empty +{ + static readonly Empty Singleton = new Empty(); + public static Empty Value => Singleton; +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Resulting/ExtensionMethods.cs b/src/Imazen.Abstractions/Resulting/ExtensionMethods.cs new file mode 100644 index 00000000..8865f7af --- /dev/null +++ b/src/Imazen.Abstractions/Resulting/ExtensionMethods.cs @@ -0,0 +1,99 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; + +namespace Imazen.Abstractions.Resulting; + +public static class ExtensionMethods +{ + public static void Unwrap(this IResult result) + { + if (result.IsError) + { + throw new ResultIsErrorException(null); + } + } + public static T Unwrap(this IResult result) + { + if (result.IsError) + { + if (result.Error is Exception e) throw e; + throw new ResultIsErrorException(result.Error); + } + return result.Value!; + } + + public static bool TryUnwrap(this IResult result, [MaybeNullWhen(false)] out T value) + { + if (result.IsError) + { + value = default; + return false; + } + value = result.Value!; + return true; + } + + public static T Unwrap(this CodeResult result) + { + if (result.IsError) + { + throw new ResultIsErrorException(null); + } + return result.Value!; + } + public static bool TryUnwrap(this CodeResult result, [MaybeNullWhen(false)] out T value) + { + if (result.IsError) + { + value = default; + return false; + } + value = result.Value!; + return true; + } + + public static TE UnwrapError(this IResult result) + { + if (result.IsOk) throw new ResultIsOkException(result.Value); + return result.Error!; + } + + public static bool TryUnwrapError(this IResult result, [MaybeNullWhen(false)] out TE error) + { + if (result.IsOk) + { + error = default; + return false; + } + error = result.Error!; + return true; + } + + public static void UnwrapError(this IResult result) + { + if (result.IsOk) + { + throw new ResultIsOkException(null); + } + } + + + // Extension methods LogAsError, LogAsWarning, LogAsInfo, LogAsDebug, LogAsTrace + + + public static void LogAsError(this ILogger logger, Result result) + { + if (result.IsError) + { + logger.LogError(result.ToString()); + } + } + public static void LogAsWarning(this ILogger logger, Result result) + { + if (result.IsError) + { + logger.LogWarning(result.ToString()); + } + } + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Resulting/HttpStatus.cs b/src/Imazen.Abstractions/Resulting/HttpStatus.cs new file mode 100644 index 00000000..9e3a456c --- /dev/null +++ b/src/Imazen.Abstractions/Resulting/HttpStatus.cs @@ -0,0 +1,199 @@ +using System.Net; + +namespace Imazen.Abstractions.Resulting; + +// maybe switch to record struct? +public readonly struct HttpStatus(int statusCode, string? reasonPhrase) +{ + + + public HttpStatus(HttpStatusCode statusCode) : this((int)statusCode) + { + } + public HttpStatus(int statusCode) : this(statusCode, GetDefaultStatusCodePhrase(statusCode)) + { + } + public int StatusCode { get; } = statusCode; + public string? Message { get; } = reasonPhrase ?? GetDefaultStatusCodePhrase(statusCode); + + + public HttpStatus WithAddFrom(string? sourceAction) => sourceAction == null ? this : new HttpStatus(StatusCode,$"{Message} (from {sourceAction})"); + public HttpStatus WithAppend(string? appendString) => appendString == null ? this : new HttpStatus(StatusCode,$"{Message}, {appendString}"); + + public HttpStatus WithMessage(string? reasonPhrase) => new HttpStatus(StatusCode, reasonPhrase); + + public static implicit operator HttpStatus(int statusCode) => new HttpStatus(statusCode); + // HttpStatusCode + public static implicit operator HttpStatus(HttpStatusCode statusCode) => new HttpStatus((int)statusCode); + + public static implicit operator int(HttpStatus statusCode) => statusCode.StatusCode; + + public static explicit operator HttpStatusCode(HttpStatus statusCode) => (HttpStatusCode)statusCode.StatusCode; + + public static implicit operator HttpStatus((int statusCode, string? reasonPhrase) statusCode) => + new HttpStatus(statusCode.statusCode).WithAppend(statusCode.reasonPhrase); + + public static implicit operator HttpStatus((HttpStatusCode statusCode, string? reasonPhrase) statusCode) => + new HttpStatus((int)statusCode.statusCode).WithAppend(statusCode.reasonPhrase); + + + + public override string ToString() + { + return Message == null ? StatusCode.ToString() : $"{StatusCode} {Message}"; + } + + // equality based on status code and message + public override bool Equals(object? obj) + { + if (obj is not HttpStatus other) return false; + if (StatusCode != other.StatusCode || + (Message == null) != (other.Message == null)) + return false; + return Message == null || Message.Equals(other.Message, StringComparison.Ordinal); + } + + public static bool operator ==(HttpStatus left, HttpStatus right) + { + return left.StatusCode == right.StatusCode; + } + + public static bool operator !=(HttpStatus left, HttpStatus right) + { + return !(left == right); + } + + public static bool operator ==(HttpStatusCode left, HttpStatus right) + { + return (int)left == right.StatusCode; + } + + public static bool operator !=(HttpStatusCode left, HttpStatus right) + { + return !(left == right); + } + public static bool operator ==(HttpStatus left, int right) + { + return left.StatusCode == (int)right; + } + + public static bool operator !=(HttpStatus left, int right) + { + return !(left == right); + } + public static bool operator ==(int left, HttpStatus right) + { + return left == right.StatusCode; + } + public static bool operator !=(int left, HttpStatus right) + { + return !(left == right); + } + + public override int GetHashCode() + { + return StatusCode.GetHashCode(); + } + + public static HttpStatus Ok => new HttpStatus(200); + public static HttpStatus NotFound => new HttpStatus(404); + public static HttpStatus ServerError => new HttpStatus(500); + public static HttpStatus GatewayTimeout => new HttpStatus(504); + public static HttpStatus NotImplemented => new HttpStatus(501); + public static HttpStatus BadGateway => new HttpStatus(502); + public static HttpStatus ServiceUnavailable => new HttpStatus(503); + public static HttpStatus Unauthorized => new HttpStatus(401); + public static HttpStatus Forbidden => new HttpStatus(403); + public static HttpStatus BadRequest => new HttpStatus(400); + + private static string GetDefaultStatusCodePhrase(int code) + { + if (code < 100 || code > 599) + throw new ArgumentOutOfRangeException(nameof(code), "Status code must be between 100 and 599"); + return code switch + { + 100 => "100 Continue", + 101 => "101 Switching Protocols", + 102 => "102 Processing", + 103 => "103 Early Hints", + 200 => "200 OK", + 201 => "201 Created", + 202 => "202 Accepted", + 203 => "203 Non-Authoritative Information", + 204 => "204 No Content", + 205 => "205 Reset Content", + 206 => "206 Partial Content", + 207 => "207 Multi-Status", + 208 => "208 Already Reported", + 226 => "226 IM Used", + 300 => "300 Multiple Choices", + 301 => "301 Moved Permanently", + 302 => "302 Found", + 303 => "303 See Other", + 304 => "304 Not Modified", + 305 => "305 Use Proxy", + 306 => "306 Unused", + 307 => "307 Temporary Redirect", + 308 => "308 Permanent Redirect", + 400 => "400 Bad Request", + 401 => "401 Unauthorized", + 402 => "402 Payment Required", + 403 => "403 Forbidden", + 404 => "404 Not Found", + 405 => "405 Method Not Allowed", + 406 => "406 Not Acceptable", + 407 => "407 Proxy Authentication Required", + 408 => "408 Request Timeout", + 409 => "409 Conflict", + 410 => "410 Gone", + 411 => "411 Length Required", + 412 => "412 Precondition Failed", + 413 => "413 Payload Too Large", + 414 => "414 URI Too Long", + 415 => "415 Unsupported Media Type", + 416 => "416 Range Not Satisfiable", + 417 => "417 Expectation Failed", + 418 => "418 I'm a teapot", + 419 => "419 Authentication Timeout", + 421 => "421 Misdirected Request", + 422 => "422 Unprocessable Entity", + 423 => "423 Locked", + 424 => "424 Failed Dependency", + 425 => "425 Too Early", + 426 => "426 Upgrade Required", + 428 => "428 Precondition Required", + 429 => "429 Too Many Requests", + 431 => "431 Request Header Fields Too Large", + 451 => "451 Unavailable For Legal Reasons", + 500 => "500 Internal Server Error", + 501 => "501 Not Implemented", + 502 => "502 Bad Gateway", + 503 => "503 Service Unavailable", + 504 => "504 Gateway Timeout", + 505 => "505 HTTP Version", + 506 => "506 Variant Also Negotiates", + 507 => "507 Insufficient Storage", + 508 => "508 Loop Detected", + 509 => "509 Bandwidth Limit Exceeded", + 510 => "510 Not Extended", + 511 => "511 Network Authentication Required", + _ => "Unknown Status Code" + }; + } + +} + + +// List the full set of properties that can be returned from a Put operation on Azure and S3 +// https://docs.microsoft.com/en-us/rest/api/storageservices/put-blob +// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPUT.html + +// Which result codes exist? List them below in a comment +// 200 OK +// 201 Created +// 202 Accepted +// 203 Non-Authoritative Information (since HTTP/1.1) +// 204 No Content +// 205 Reset Content +// 206 Partial Content (RFC 7233) +// 207 Multi-Status (WebDAV; RFC 4918) \ No newline at end of file diff --git a/src/Imazen.Abstractions/Resulting/IResult.cs b/src/Imazen.Abstractions/Resulting/IResult.cs new file mode 100644 index 00000000..6dcb1cf5 --- /dev/null +++ b/src/Imazen.Abstractions/Resulting/IResult.cs @@ -0,0 +1,24 @@ +namespace Imazen.Abstractions.Resulting; + + +public interface IResult +{ + bool IsOk { get; } + + bool IsError { get; } +} + +public interface IResult : IResult +{ + T? Value { get; } +} +public interface IResult : IResult +{ + TE? Error { get; } +} + +public interface IDisposableResult : IResult, IDisposable +{ + TE? Error { get; } +} + diff --git a/src/Imazen.Abstractions/Resulting/Result.cs b/src/Imazen.Abstractions/Resulting/Result.cs new file mode 100644 index 00000000..8438c7ea --- /dev/null +++ b/src/Imazen.Abstractions/Resulting/Result.cs @@ -0,0 +1,311 @@ +using System.Net; +using System.Runtime.InteropServices; +using Imazen.Abstractions.Blobs.LegacyProviders; + +namespace Imazen.Abstractions.Resulting; + + + +public class Result : IResult +{ + protected Result(bool ok) + { + IsOk = ok; + } + + public bool IsOk { get; } + public bool IsError => !IsOk; + + private static readonly Result SharedOk = new Result(true); + + + public static Result Ok() => SharedOk; + + private static readonly Result SharedErr = new Result(false); + public static Result Err() => SharedErr; + + public static implicit operator Result(bool ok) => new Result(ok); + + public static implicit operator bool(Result result) => result.IsOk; + + public override string ToString() + { + return IsOk ? "(Ok)" : "(Error)"; + } + + + +} + +// +// public class Result : CodeResult +// { +// +// private Result(bool success, bool notImplemented, int statusCode, string? statusMessage, T? data, bool suppressDisposeData) +// { +// SuppressDisposeData = suppressDisposeData; +// NotImplemented = notImplemented; +// IsOk = success; +// StatusCode = statusCode; +// StatusMessage = statusMessage; +// Value = data; +// Enumerable.Empty<>() +// } +// +// +// +// public bool IsOk { get; } +// public bool NotImplemented { get; } = false; +// public int StatusCode { get; } +// public string? StatusMessage { get; } = null; +// public T? Value { get; } = default(T); +// +// public bool SuppressDisposeData { get; } +// +// +// public static CodeResult NotImplementedResult(string message) => ErrorResult(501, message); +// public static CodeResult ErrorResult(int statusCode, string message) => new Result(false, statusCode == 501, statusCode, message, default(T), true); +// +// public static CodeResult SuccessResult(T data, int statusCode, string message, bool suppressDisposeData) => new Result(false, statusCode == 501, statusCode, message, data, suppressDisposeData); +// +// public static CodeResult NotFoundResult(string? message = null) => ErrorResult(404, message ?? "Not found"); +// +// +// +// public void Dispose() +// { +// if (!SuppressDisposeData && Value is IDisposable d) d.Dispose(); +// } +// +// public static CodeResult ErrorResult(int statusCode) => new Result(false, statusCode == 501, statusCode, null, default(T), true); +// +// } + +public class Result : IResult +{ + + protected Result(bool isOk, T? okValue, TE? errorValue) + { + Value = okValue; + IsOk = isOk; + Error = errorValue; + if (IsOk && Value == null) throw new ArgumentNullException(nameof(okValue)); + if (!IsOk && Error == null) throw new ArgumentNullException(nameof(errorValue)); + } + protected Result(T okValue) : this(true, okValue, default(TE)) + { + } + protected Result(bool ignored, TE errorValue) : this(false, default(T), errorValue) + { + } + + + protected internal Result() + { + + } + + public bool IsError => !IsOk; + public bool IsOk { get; protected set; } + public T? Value { get; protected set; } + public TE? Error { get; protected set; } + + public static Result Err(TE errorValue) => new Result(false, default(T), errorValue); + + + public static Result Ok(T okValue) => new Result(okValue); + + private static readonly Result SharedEmptyErr = new Result(false, default(T), Empty.Value); + public static Result Err() => SharedEmptyErr; + + private static readonly Result SharedEmptyOk = new Result(Empty.Value); + public static Result Ok() => SharedEmptyOk; + + public static implicit operator Result(T value) => new Result(value); + + public static implicit operator Result(TE error) => new Result(false, default, error); + + // public override bool Equals(object? obj) + // { + // return ReferenceEquals(this, obj); + // } + + // public override int GetHashCode() + // { + // return base.GetHashCode(); + // } + + public override string ToString() + { + return IsOk ? $"Ok({Value})" : $"Error({Error})"; + } + +} + + +public static class ResultExtensions +{ + + // map ok and map err + public static Result MapOk(this IResult result, Func func) + { + return result.IsOk ? Result.Ok(func(result.Value!)) : Result.Err(result.Error!); + } + + public static Result MapErr(this IResult result, Func func) + { + return result.IsOk ? Result.Ok(result.Value!) : Result.Err(func(result.Error!)); + } + + // flatten nested + + public static IResult Flatten(this IResult, TE> result) + { + return result.IsOk ? result.Value! : Result.Err(result.Error!); + } + +} + +public class CodeResult : Result +{ + + protected CodeResult(T okValue) : base(okValue) + { + } + + protected CodeResult(bool ignored, HttpStatus errorValue) : base((bool)false, errorValue) + { + } + + public new static CodeResult Err(HttpStatus errorValue) => new CodeResult(false, errorValue); + + public new static CodeResult Ok(T okValue) => new CodeResult(okValue); + + public static CodeResult Ok(TA okValue) where TA : T => new CodeResult(okValue); + + public static implicit operator CodeResult(T value) => new CodeResult(value); + + public static implicit operator CodeResult(HttpStatus error) => new CodeResult(false, error); + + public override string ToString() + { + return IsOk ? $"Ok({Value})" : $"Err({Error})"; + } + + public CodeResult MapOk(Func func) + { + return IsOk ? CodeResult.Ok(func(Value!)) : CodeResult.Err(Error!); + } +} + +public class CodeResult : Result +{ + + protected CodeResult(HttpStatus okValue) : base(okValue) + { + + } + + protected CodeResult(bool ignored, HttpStatus errorValue) : base(ignored, errorValue) + { + } + public new static CodeResult Err(HttpStatus errorValue) => new CodeResult(false, errorValue); + + public static CodeResult Err(HttpStatusCode errorValue) => new CodeResult(false, errorValue); + public static CodeResult ErrFrom(HttpStatusCode errorValue, string sourceAction) => + new CodeResult(false, new HttpStatus((int)errorValue).WithAddFrom(sourceAction)); + + public static CodeResult ErrFrom(HttpStatus status, string sourceAction) => + new CodeResult(false, status.WithAddFrom(sourceAction)); + + public new static CodeResult Ok(HttpStatus okValue) => new CodeResult(okValue); + + public new static CodeResult Ok() => new CodeResult(HttpStatus.Ok); + + public static CodeResult FromException(Exception exception, string? sourceAction) + { + return exception switch + { + UnauthorizedAccessException e => Err(HttpStatus.Forbidden.WithAppend(e.Message).WithAddFrom(sourceAction)), + FileNotFoundException a => Err(HttpStatus.NotFound.WithAppend(a.Message).WithAddFrom(sourceAction)), + DirectoryNotFoundException b => Err(HttpStatus.NotFound.WithAppend(b.Message).WithAddFrom(sourceAction)), + BlobMissingException c => Err(HttpStatus.NotFound.WithAppend(c.Message).WithAddFrom(sourceAction)), + _ => Err(HttpStatus.ServerError.WithAppend(exception.Message).WithAddFrom(sourceAction)) + }; + } + + public static CodeResult FromException(Exception exception) => FromException(exception, null); + + public override string ToString() + { + return IsOk ? $"Ok({Value})" : $"Err({Error})"; + } +} + + +[StructLayout(LayoutKind.Explicit)] +public readonly struct ResultValue : IResult +{ + + private ResultValue(T value) + { + _ok = true; + _value = value; + } + private ResultValue(bool ignored, TE error) + { + _ok = false; + _error = error; + } + [FieldOffset(0)] private readonly bool _ok; + [FieldOffset(1)] private readonly TE? _error; + [FieldOffset(2)] private readonly T? _value; + + public bool IsOk => _ok; + public bool IsError => !_ok; + public T? Value => _value; + public TE? Error => _error; + + public static implicit operator ResultValue(T value) => new ResultValue(value); + + public static implicit operator ResultValue(TE error) => new ResultValue(false, error); + + public static ResultValue Ok(T value) => new ResultValue(value); + + public static ResultValue Err(TE error) => new ResultValue(false, error); + +} + + +public class DisposableResult : Result, IDisposableResult +{ + + protected DisposableResult(T okValue, bool disposeValue) : base(okValue) + { + DisposeValue = disposeValue; + } + + protected DisposableResult(bool ignored, TE errorValue, bool disposeError) : base(false, default(T), errorValue) + { + DisposeError = disposeError; + } + + private bool DisposeValue { get; } + private bool DisposeError { get; } + public void Dispose() + { + try + { + if (DisposeValue && Value is IDisposable d) d.Dispose(); + } + finally + { + if (DisposeError && Error is IDisposable e) e.Dispose(); + } + } + + public static DisposableResult Err(TE errorValue, bool disposeError) => new DisposableResult(false, errorValue, disposeError); + + public static DisposableResult Ok(T okValue, bool disposeValue) => new DisposableResult(okValue, disposeValue); + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Resulting/ResultIsErrorException.cs b/src/Imazen.Abstractions/Resulting/ResultIsErrorException.cs new file mode 100644 index 00000000..09b59a4f --- /dev/null +++ b/src/Imazen.Abstractions/Resulting/ResultIsErrorException.cs @@ -0,0 +1,24 @@ +using System.Collections; + +namespace Imazen.Abstractions.Resulting; + +public class ResultIsErrorException : Exception +{ + public ResultIsErrorException(TE? errorValue, string? message = null) : base(message ?? "Result does not contain expected OK value") + { + ErrorValue = errorValue; + } + + public TE? ErrorValue { get; } + + public override IDictionary Data =>ErrorValue == null ? base.Data : new Dictionary + { + {"ErrorValue", ErrorValue} + }; + + public override string ToString() + { + return $"{base.ToString()}: {ErrorValue}"; + } + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Resulting/ResultIsOkException.cs b/src/Imazen.Abstractions/Resulting/ResultIsOkException.cs new file mode 100644 index 00000000..83e1e063 --- /dev/null +++ b/src/Imazen.Abstractions/Resulting/ResultIsOkException.cs @@ -0,0 +1,18 @@ +namespace Imazen.Abstractions.Resulting; + +public class ResultIsOkException : Exception +{ + public ResultIsOkException(T? okValue, string? message = null) : base(message ?? "Result does not contain the expected error value") + { + OkValue = okValue; + } + + public T? OkValue { get; } + + + public override string ToString() + { + return $"{base.ToString()}: {OkValue}"; + } + +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/SelfTestable/ISelfTestResult.cs b/src/Imazen.Abstractions/SelfTestable/ISelfTestResult.cs new file mode 100644 index 00000000..6f333b37 --- /dev/null +++ b/src/Imazen.Abstractions/SelfTestable/ISelfTestResult.cs @@ -0,0 +1,13 @@ +namespace Imazen.Abstractions.SelfTestable +{ + internal interface ISelfTestResult + { + DateTimeOffset CompletedAt { get; } + TimeSpan WallTime { get; } + bool Passed { get; } + string ResultSummary { get; } + string? ResultDetails { get; } + string TestTitle { get; } + string SourceObjectTitle { get; } + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/SelfTestable/ISelfTestable.cs b/src/Imazen.Abstractions/SelfTestable/ISelfTestable.cs new file mode 100644 index 00000000..a5fb2f56 --- /dev/null +++ b/src/Imazen.Abstractions/SelfTestable/ISelfTestable.cs @@ -0,0 +1,12 @@ +namespace Imazen.Abstractions.SelfTestable +{ + + internal interface ISelfTestable + { + /// + /// Should run self tests for the components and any sub-components it owns. + /// + /// + IList> RunSelfTests(CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Support/BlobStringValidator.cs b/src/Imazen.Abstractions/Support/BlobStringValidator.cs new file mode 100644 index 00000000..a0b74416 --- /dev/null +++ b/src/Imazen.Abstractions/Support/BlobStringValidator.cs @@ -0,0 +1,282 @@ +namespace Imazen.Common.Extensibility.Support +{ + + /// + /// Validates strings for use as Azure and S3 blob keys and container names. + /// + public static class BlobStringValidator + { + + /// + /// Validates that the string is not null or empty, and must be less than 1024 bytes when encoded as UTF-8 + /// + /// + /// + /// + internal static bool ValidateKeyByteLength(string key, out string? error) + { + error = null; + if (key == null || key.Length == 0) + { + error = "Blob keys cannot be null or empty"; + return false; + } + + // Byte encoded as UTF-8 must be <= 1024 bytes + if (System.Text.Encoding.UTF8.GetByteCount(key) > 1024) + { + error = "Blob keys cannot be longer than 1024 characters when encoded as UTF-8"; + return false; + } + + return true; + } + + /// + /// Returns true if the key meets the recommended standards for both S3 and Azure blob keys. + /// Unlike ValidateBlobKeySegmentStrict, leading and trailing slashes are prohibited. + /// Should only contain letters, numbers, (),/, *, ', ., _, and - + /// Should not contain //, start with /, ./, or ../, or end with /, \, /., ., or \. + /// Should not be longer than 1024 bytes when encoded as UTF-8 + /// Should not be null or emtpy. + /// + /// Returns false if + /// + /// + /// + public static bool ValidateBlobKeyStrict(string key, out string? error) + { + error = null; + if (!ValidateBlobKeySegmentStrict(key, out error)) + { + return false; + } + if (key.StartsWith("/") || key.EndsWith("/")) + { + error = "Key cannot start or end with /"; + return false; + } + return true; + } + /// + /// Returns true if the key meets the recommended standards for both S3 and Azure path segments. + /// Full keys should not end or start with /, but this method permits it. + /// Should not contain //, ./, start with /, ./, or ../, or end with /, \, /., ., or \. + /// Should only contain letters, numbers, (),/, *, ', ., _, and - + /// Should not be longer than 1024 bytes when encoded as UTF-8 + /// Should not be null or emtpy. + /// + /// Returns false if + /// + /// + /// + public static bool ValidateBlobKeySegmentStrict(string key, out string? error) + { + error = null; + + if (!ValidateKeyByteLength(key, out error)) + { + return false; + } + + if (key.Contains("./")) + { + error = "Blob keys cannot contain ./"; + return false; + } + if (key.StartsWith("../")) + { + error = "Blob keys cannot start with ../"; + return false; + } + if (key.EndsWith("/.")) + { + error = "Blob keys cannot end with /."; + return false; + } + if (key.EndsWith(".")) + { + error = "Blob keys cannot end with ."; + return false; + } + if (key.Contains("//")) + { + error = "Blob keys cannot contain //"; + return false; + } + + //Also includes: Avoid blob names that end with a dot (.), a forward slash (/), a backslash (\), or a sequence or combination of the two. + // No path segments should end with a dot (.). + + // only allow 0-9a-zA-Z()/'*._-! in keys + if (!key.All(c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '(' || c == ')' || c == '\'' || c == '/' || c == '*' || c == '.' || c == '_' || c == '-' || c == '!')) + { + error = "Blob keys should only contain a-z, A-Z, 0-9, (),/, *, ', ., _, !, and -"; + // also satisfies unicode limits https://learn.microsoft.com/en-us/rest/api/storageservices/Naming-and-Referencing-Containers--Blobs--and-Metadata + return false; + } + return true; + } + + /// + /// Validates that the string is a valid S3/Azure key prefix. + /// Same as ValidateBlobKeySegmentStrict, but leading slashes are prohibited. + /// Can be null or empty, but must be less than 1024 bytes when encoded as UTF-8 + /// + /// + /// + /// + internal static bool ValidateBlobKeyPrefixStrict(string key, out string? error) + { + error = null; + if (!ValidateBlobKeySegmentStrict(key, out error)) + { + return false; + } + if (key.StartsWith("/")) + { + error = "Key prefix cannot start with /"; + return false; + } + return true; + } + + /// + /// Validates that the string is a valid Azure container name. + /// Container names must be between 3 (min) and 63 (max) characters long. + /// Container names can consist only of lowercase letters, numbers, and hyphens. + /// Container names must begin and end with a letter. + /// Container names must not contain two consecutive hyphens. + /// + /// + /// + /// + public static bool ValidateAzureContainerName(string bucket, out string? error) + { + error = null; + if (bucket == null || bucket.Length == 0) + { + error = "Azure container names cannot be null or empty"; + return false; + } + // must start or end with a-z + if (!char.IsLetter(bucket[0]) && !char.IsLetter(bucket[bucket.Length - 1])) + { + error = "Azure container names must start or end with a letter"; + return false; + } + + if (!bucket.All(c => (c >= 'a' && c <= 'z') || (c > '0' && c <= '9') || c == '-')) + { + error = "Azure container names can only contain [a-z0-9-]"; + return false; + } + + if (bucket.Length < 3) + { + error = "Azure container names must be at least 3 characters long"; + return false; + } + + if (bucket.Length > 63) + { + error = "Azure container names cannot be longer than 63 characters"; + return false; + } + + if (bucket.Contains("--")) + { + // For Azure only + error = "Azure container names cannot contain two dashes -- in a row"; + return false; + } + return true; + } + + /// + /// Validates that the string is a valid S3 bucket name + /// + /// + /// Bucket names must be between 3 (min) and 63 (max) characters long. + /// Bucket names can consist only of lowercase letters, numbers, dots (.), and hyphens (-). + /// Bucket names must begin and end with a letter or number. + /// Bucket names must not contain two adjacent periods. + /// Bucket names must not be formatted as an IP address (for example, 192.168.5.4). + /// Bucket names must not start with the prefix xn--. + /// Bucket names must not start with the prefix sthree- and the prefix sthree-configurator. + /// Bucket names must not end with the suffix -s3alias. This suffix is reserved for access point alias names. For more information, see Using a bucket-style alias for your S3 bucket access point. + /// Bucket names must not end with the suffix --ol-s3. This suffix is reserved for Object Lambda Access Point alias names. For more information, see How to use a bucket-style alias for your S3 bucket Object Lambda Access Point. + /// + /// The error message + /// + public static bool ValidateS3BucketName(string bucket, out string? error) + { + error = null; + if (bucket == null || bucket.Length == 0) + { + error = "Container/bucket names cannot be null or empty"; + return false; + } + // Bucket names must not be formatted as an IP address (for example, 192.168.5.4). + if (System.Net.IPAddress.TryParse(bucket, out _)) + { + error = "Container/bucket names cannot be an IP address"; + return false; + } + // Bucket names can consist only of lowercase letters, numbers, dots (.), and hyphens (-). + if (!bucket.All(c => (c >= 'a' && c <= 'z') || char.IsDigit(c) || c == '.' || c == '-')) + { + error = "Container/bucket names can only contain lowercase letters, numbers, dots, and hyphens"; + return false; + } + + if (bucket.Length < 3) + { + error = "Container/bucket names must be at least 3 characters long"; + return false; + } + + if (bucket.Length > 63) + { + error = "Container/bucket names cannot be longer than 63 characters"; + return false; + } + if (bucket.Contains("..")) + { + error = "Container/bucket names cannot contain two periods in a row"; + return false; + } + if (!char.IsLetterOrDigit(bucket[0])) + { + error = "Container/bucket names must start with a letter or number"; + return false; + } + + if (!char.IsLetterOrDigit(bucket[bucket.Length - 1])) + { + error = "Container/bucket names must end with a letter or number"; + return false; + } + + if (bucket.StartsWith("xn--")) + { + error = "Container/bucket names cannot start with xn--"; + return false; + } + + if (bucket.EndsWith("-s3alias") || bucket.EndsWith("--ol-s3")) + { + error = "Bucket names cannot end with -s3alias or --ol-s3"; + return false; + } + + if (bucket.StartsWith("sthree-") || bucket.StartsWith("sthree-configurator")) + { + error = "Bucket names cannot start with sthree- or sthree-configurator"; + return false; + } + + return true; + } + } +} diff --git a/src/Imazen.HybridCache/HashBasedPathBuilder.cs b/src/Imazen.Abstractions/Support/HashBasedPathBuilder.cs similarity index 69% rename from src/Imazen.HybridCache/HashBasedPathBuilder.cs rename to src/Imazen.Abstractions/Support/HashBasedPathBuilder.cs index dff3c2f1..603c43b5 100644 --- a/src/Imazen.HybridCache/HashBasedPathBuilder.cs +++ b/src/Imazen.Abstractions/Support/HashBasedPathBuilder.cs @@ -1,10 +1,9 @@ -using System; using System.Globalization; -using System.IO; using System.Text; -namespace Imazen.HybridCache +namespace Imazen.Common.Extensibility.Support { + public class HashBasedPathBuilder { private readonly int subfolderBits; @@ -13,32 +12,48 @@ public class HashBasedPathBuilder private char RelativeDirSeparator { get; } private string FileExtension { get; } private string PhysicalCacheDir { get; } - public HashBasedPathBuilder(string physicalCacheDir, int subfolders, char relativeDirSeparator, string fileExtension) + + public HashBasedPathBuilder(string physicalCacheDir, int subfolders, char relativeDirSeparator, + string fileExtension) { PhysicalCacheDir = physicalCacheDir.TrimEnd('\\', '/'); FileExtension = fileExtension; RelativeDirSeparator = relativeDirSeparator; - subfolderBits = (int) Math.Ceiling(Math.Log(subfolders, 2)); //Log2 to find the number of bits. round up. + subfolderBits = (int)Math.Ceiling(Math.Log(subfolders, 2)); //Log2 to find the number of bits. round up. if (subfolderBits < 1) subfolderBits = 1; } + + public byte[] HashKeyBasis(byte[] keyBasis) => HashKeyBasisStatic(keyBasis); - public byte[] HashKeyBasis(byte[] keyBasis) + internal static byte[] HashKeyBasisStatic(byte[] keyBasis) { using (var h = System.Security.Cryptography.SHA256.Create()) { return h.ComputeHash(keyBasis); } } - + internal static byte[] HashKeyBasisStatic(string keyBasis) + { + using (var h = System.Security.Cryptography.SHA256.Create()) + { + return h.ComputeHash(Encoding.UTF8.GetBytes(keyBasis)); + } + } public string GetStringFromHash(byte[] hash) { return Convert.ToBase64String(hash); } + + internal static string GetStringFromHashStatic(byte[] hash) + { + return Convert.ToBase64String(hash); + } + public byte[] GetHashFromString(string hashString) { return Convert.FromBase64String(hashString); } - + public string GetPhysicalPathFromHash(byte[] hash) { var relativePath = GetRelativePathFromHash(hash); @@ -50,7 +65,7 @@ public string GetPhysicalPathFromRelativePath(string relativePath) var fixSlashes = relativePath.Replace(RelativeDirSeparator, Path.DirectorySeparatorChar); return Path.Combine(PhysicalCacheDir, fixSlashes); } - + /// /// Builds a key for the cached version, using the hashcode of `data`. @@ -63,57 +78,58 @@ public string GetPhysicalPathFromRelativePath(string relativePath) /// public string GetRelativePathFromHash(byte[] hash) { + if (hash == null) throw new ArgumentNullException(nameof(hash)); + if (hash.Length != 32) throw new ArgumentException("Hash must be 32 bytes", nameof(hash)); var allBits = GetTrailingBits(hash, subfolderBits); var sb = new StringBuilder(75 + FileExtension.Length); //Start with the subfolder distribution in bits, so we can easily delete old folders //When we change the subfolder size - sb.AppendFormat(NumberFormatInfo.InvariantInfo, "{0:D}",subfolderBits); + sb.AppendFormat(NumberFormatInfo.InvariantInfo, "{0:D}", subfolderBits); sb.Append(RelativeDirSeparator); //If subfolders is set above 256, it will nest files in multiple directories, one for each byte foreach (var b in allBits) { - sb.AppendFormat(NumberFormatInfo.InvariantInfo, "{0:x2}",b); + sb.AppendFormat(NumberFormatInfo.InvariantInfo, "{0:x2}", b); sb.Append(RelativeDirSeparator); } - sb.AppendFormat(NumberFormatInfo.InvariantInfo, + sb.AppendFormat(NumberFormatInfo.InvariantInfo, "{0:x2}{1:x2}{2:x2}{3:x2}{4:x2}{5:x2}{6:x2}{7:x2}{8:x2}{9:x2}{10:x2}{11:x2}{12:x2}{13:x2}{14:x2}{15:x2}{16:x2}{17:x2}{18:x2}{19:x2}{20:x2}{21:x2}{22:x2}{23:x2}{24:x2}{25:x2}{26:x2}{27:x2}{28:x2}{29:x2}{30:x2}{31:x2}", - hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8], hash[9], hash[10], hash[11] - , hash[12], hash[13], hash[14], hash[15], hash[16], hash[17], hash[18], hash[19], hash[20], hash[21], hash[22], hash[23] + hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8], hash[9], hash[10], + hash[11] + , hash[12], hash[13], hash[14], hash[15], hash[16], hash[17], hash[18], hash[19], hash[20], hash[21], + hash[22], hash[23] , hash[24], hash[25], hash[26], hash[27], hash[28], hash[29], hash[30], hash[31]); sb.Append(FileExtension); return sb.ToString(); } - public long GetDirectoryEntriesBytesTotal() - { - return GetDirectoryEntriesCount() * CleanupManager.DirectoryEntrySize() + CleanupManager.DirectoryEntrySize(); - } - private long GetDirectoryEntriesCount() + + public long CalculatePotentialDirectoryEntriesFromSubfolderBitCount() { - var allBits = GetTrailingBits(new byte[]{255,255,255,255,255,255}, subfolderBits); + var allBits = GetTrailingBits(new byte[] { 255, 255, 255, 255, 255, 255 }, subfolderBits); var totalDirs = 1; foreach (var b in allBits) { totalDirs = totalDirs * (b + 1); } - + totalDirs = totalDirs + totalDirs / (256 * 256 * 256); totalDirs = totalDirs + totalDirs / (256 * 256); totalDirs = totalDirs + totalDirs / 256; - + return totalDirs; } - - internal static byte[] GetTrailingBits(byte[] data, int bits) + + public static byte[] GetTrailingBits(byte[] data, int bits) { - var trailingBytes = new byte[(int) Math.Ceiling(bits / 8.0)]; //Round up to bytes. + var trailingBytes = new byte[(int)Math.Ceiling(bits / 8.0)]; //Round up to bytes. Array.Copy(data, data.Length - trailingBytes.Length, trailingBytes, 0, trailingBytes.Length); var bitsToClear = (trailingBytes.Length * 8) - bits; - trailingBytes[0] = (byte) ((byte)(trailingBytes[0] << bitsToClear) >> bitsToClear); //Set extra bits to 0. + trailingBytes[0] = (byte)((byte)(trailingBytes[0] << bitsToClear) >> bitsToClear); //Set extra bits to 0. return trailingBytes; } @@ -123,6 +139,9 @@ public string GetDisplayPathForKeyBasis(byte[] keyBasis) return GetRelativePathFromHash(hash); } - + // public long GetDirectoryEntriesBytesTotal() + // { + // return CalculatePotentialDirectoryEntriesFromSubfolderBitCount() * CleanupManager.DirectoryEntrySize() + CleanupManager.DirectoryEntrySize(); + // } } } \ No newline at end of file diff --git a/src/Imazen.Abstractions/Support/HostedServiceProxy.cs b/src/Imazen.Abstractions/Support/HostedServiceProxy.cs new file mode 100644 index 00000000..a7fd41d9 --- /dev/null +++ b/src/Imazen.Abstractions/Support/HostedServiceProxy.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Hosting; + +namespace Imazen.Common.Extensibility.Support +{ + public class HostedServiceProxy: IHostedService + { + private readonly List hostedServices; + public HostedServiceProxy(IEnumerable candidateInstances) + { + //filter to only IHostedService + hostedServices = candidateInstances.Where(c => c is IHostedService).Cast().ToList(); + } + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.WhenAll(hostedServices.Select(c => c.StartAsync(cancellationToken))); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + //TODO: we want errors to propagate, but we want to stop all services we can before that happens + var tasks = hostedServices.Select(c => c.StopAsync(cancellationToken)).ToList(); + return Task.WhenAll(tasks); + } + + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/Support/MemoryUsageEstimation.cs b/src/Imazen.Abstractions/Support/MemoryUsageEstimation.cs new file mode 100644 index 00000000..bb8591ed --- /dev/null +++ b/src/Imazen.Abstractions/Support/MemoryUsageEstimation.cs @@ -0,0 +1,71 @@ +using System.Runtime.InteropServices; + +namespace Imazen.Common.Extensibility.Support; + +public interface IEstimateAllocatedBytesRecursive +{ + /// + /// Implementers should be able to track the estimated memory pressure of their allocation. + /// Interned strings don't need to be counted byte-wise, for example. + /// + int EstimateAllocatedBytesRecursive { get; } +} + +internal static class MemoryUsageEstimation +{ + + public static int EstimateMemorySize(this List? obj, bool includeReference) where T: IEstimateAllocatedBytesRecursive + { + return (includeReference ? 8 : 0) + (obj == null + ? 0 + : (16 + 8 + (obj.Capacity == 0 ? 0 : (24 + obj.Capacity * 8)) + + obj.Sum(x => x.EstimateAllocatedBytesRecursive))); + } + + public static int EstimateMemorySize(this IReadOnlyCollection? obj, bool includeReference) where T: IEstimateAllocatedBytesRecursive + { + return (includeReference ? 8 : 0) + (obj == null + ? 0 + : ((16 + 8 + obj.Count * 8) + + obj.Sum(x => x.EstimateAllocatedBytesRecursive))); + } + + + /// + /// Can crash on structs, should only be used on primitive numeric types + /// + /// + /// + /// + /// + public static int EstimateMemorySize(this T? obj, bool includeReference) where T : unmanaged + { + return Marshal.SizeOf() + 1 + (includeReference ? 8 : 0); + } + /// + /// + /// + /// + /// + /// + /// + public static int EstimateMemorySize(this T obj, bool includeReference) where T : unmanaged + { + return Marshal.SizeOf() + 1 + (includeReference ? 8 : 0); + } + + + public static int EstimateMemorySize(this DateTimeOffset obj, bool includeReference) + { + return 8 + 8 + (includeReference ? 8 : 0); + } + public static int EstimateMemorySize(this DateTimeOffset? obj, bool includeReference) + { + return 1 + 8 + 8 + (includeReference ? 8 : 0); + } + + public static int EstimateMemorySize(this string? s, bool includeReference) + { + return (includeReference ? 8 : 0) + (s == null ? 0 : (s.Length + 1) * sizeof(char) + 8 + 4); + } +} \ No newline at end of file diff --git a/src/Imazen.Abstractions/VisibleToTests.cs b/src/Imazen.Abstractions/VisibleToTests.cs new file mode 100644 index 00000000..1bbe84d4 --- /dev/null +++ b/src/Imazen.Abstractions/VisibleToTests.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; + +// We are exposing PolySharp once +[assembly: InternalsVisibleTo("ImazenShared.Tests")] +[assembly: InternalsVisibleTo("Imazen.Common")] +[assembly: InternalsVisibleTo("Imazen.Routing")] +[assembly: InternalsVisibleTo("Imageflow.Server.Storage.AzureBlob")] +[assembly: InternalsVisibleTo("Imageflow.Server.Storage.S3")] +[assembly: InternalsVisibleTo("Imageflow.Server.Storage.AzureBlob.Tests")] +[assembly: InternalsVisibleTo("Imageflow.Server.Storage.RemoteReader")] +[assembly: InternalsVisibleTo("Imazen.HybridCache")] + + +[assembly: InternalsVisibleTo("Imageflow.Server")] +[assembly: InternalsVisibleTo("Imageflow.Server.Tests")] +[assembly: InternalsVisibleTo("ImageResizer")] +[assembly: InternalsVisibleTo("ImageResizer.LicensingTests")] +[assembly: InternalsVisibleTo("ImageResizer.HybridCache.Tests")] +[assembly: InternalsVisibleTo("ImageResizer.HybridCache.Benchmark")] diff --git a/src/Imazen.Abstractions/packages.lock.json b/src/Imazen.Abstractions/packages.lock.json new file mode 100644 index 00000000..f8f387c9 --- /dev/null +++ b/src/Imazen.Abstractions/packages.lock.json @@ -0,0 +1,344 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[2.*, )", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.*, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "System.Buffers": { + "type": "Direct", + "requested": "[4.*, )", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Direct", + "requested": "[4.*, )", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Text.Encodings.Web": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Direct", + "requested": "[4.*, )", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + } + }, + "net6.0": { + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[2.*, )", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.*, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "System.Text.Encodings.Web": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + } + }, + "net8.0": { + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[2.*, )", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.*, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "System.Text.Encodings.Web": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + } + } + } +} \ No newline at end of file diff --git a/src/Imazen.Common/Collections/ReverseEnumerable.cs b/src/Imazen.Common/Collections/ReverseEnumerable.cs index 48620441..a5fa206e 100644 --- a/src/Imazen.Common/Collections/ReverseEnumerable.cs +++ b/src/Imazen.Common/Collections/ReverseEnumerable.cs @@ -3,8 +3,6 @@ // propagated, or distributed except as permitted in COPYRIGHT.txt. // Licensed under the Apache License, Version 2.0. -using System; -using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections; @@ -30,38 +28,46 @@ IEnumerator IEnumerable.GetEnumerator() { /// Enumerates the collection from end to beginning /// /// - public class ReverseEnumerator : IEnumerator { + public sealed class ReverseEnumerator : IEnumerator + { private readonly ReadOnlyCollection collection; - private int curIndex; + private int currentIndex; - - public ReverseEnumerator(ReadOnlyCollection collection) { + public ReverseEnumerator(ReadOnlyCollection collection) + { this.collection = collection; - curIndex = this.collection.Count; - Current = default; - + currentIndex = collection.Count; // Start just after the last element } - public bool MoveNext() { - curIndex--; - //Avoids going beyond the beginning of the collection. - if (curIndex < 0) { - Current = default; - return false; + public T Current + { + get + { + if (currentIndex < 0 || currentIndex >= collection.Count) + { + throw new InvalidOperationException("The collection was modified after the enumerator was created."); + } + return collection[currentIndex]; } - - // Set current box to next item in collection. - Current = collection[curIndex]; - return true; } - public void Reset() { curIndex = collection.Count; Current = default; } - - void IDisposable.Dispose() { } + object IEnumerator.Current => Current!; - public T Current { get; private set; } + public bool MoveNext() + { + currentIndex--; + return currentIndex >= 0; + } + public void Reset() + { + currentIndex = collection.Count; + } - object IEnumerator.Current => Current; + public void Dispose() + { + // No resources to dispose in this example + } } + } \ No newline at end of file diff --git a/src/Imazen.Common/Concurrency/AsyncLockProvider.cs b/src/Imazen.Common/Concurrency/AsyncLockProvider.cs index 085b47ec..a897eca5 100644 --- a/src/Imazen.Common/Concurrency/AsyncLockProvider.cs +++ b/src/Imazen.Common/Concurrency/AsyncLockProvider.cs @@ -1,13 +1,12 @@ /* Copyright (c) 2014 Imazen See license.txt for your rights. */ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; + +using Imazen.Abstractions.Resulting; +using InvalidOperationException = System.InvalidOperationException; namespace Imazen.Common.Concurrency { /// - /// Provides locking based on a string key. + /// Provides lightweight locking based on a string key. Useful for fixing the thundering herd problem. /// Locks are local to the LockProvider instance. /// The class handles disposing of unused locks. Generally used for /// coordinating writes to files (of which there can be millions). @@ -16,7 +15,8 @@ namespace Imazen.Common.Concurrency { /// Uses SemaphoreSlim instead of locks to be thread-context agnostic. /// public class AsyncLockProvider { - + + /// /// The only objects in this collection should be for open files. @@ -76,8 +76,31 @@ public bool TryExecuteSynchronous(string key, int timeoutMs, CancellationToken c /// /// /// - /// - public async Task TryExecuteAsync(string key, int timeoutMs, CancellationToken cancellationToken, Func success) + /// + public async Task TryExecuteAsync(string key, int lockTimeoutMs, CancellationToken cancellationToken, + Func success) + { + var result = await TryExecuteAsync(key, lockTimeoutMs, cancellationToken, Empty.Value ,async (Empty e, CancellationToken ct) => { + await success(); + return Empty.Value; + }); + return result.IsOk; + } + + + /// + /// Attempts to execute the 'T work(TP param, CancellationToken ct)' callback inside a lock based on 'key'. If successful, returns an OK result wrapping the return + /// value. CancellationToken is passed through. + /// If the lock cannot be acquired within 'timeoutMs', returns false + /// In a worst-case scenario, it could take up to twice as long as 'timeoutMs' to return false. + /// + /// + /// + /// + /// + /// + /// + public async Task> TryExecuteAsync(string key, int lockTimeoutMs, CancellationToken cancellationToken, TP workParameter, Func> work) { //Record when we started. We don't want an infinite loop. DateTime startedAt = DateTime.UtcNow; @@ -85,7 +108,7 @@ public async Task TryExecuteAsync(string key, int timeoutMs, CancellationT // Tracks whether the lock acquired is still correct bool validLock = true; // The lock corresponding to 'key' - SemaphoreSlim itemLock = null; + SemaphoreSlim? itemLock = null; try { //We have to loop until we get a valid lock and it stays valid until we lock it. @@ -105,7 +128,7 @@ public async Task TryExecuteAsync(string key, int timeoutMs, CancellationT // insert a new value for 'itemLock' into the dictionary... etc, etc.. // 2) Execute phase - if (await itemLock.WaitAsync(timeoutMs, cancellationToken)) { + if (await itemLock.WaitAsync(lockTimeoutMs, cancellationToken)) { try { // May take minutes to acquire this lock. @@ -117,23 +140,22 @@ public async Task TryExecuteAsync(string key, int timeoutMs, CancellationT } // Only run the callback if the lock is valid if (validLock) { - await success(); // Extremely long-running callback, perhaps throwing exceptions - return true; + return Result.Ok(await work(workParameter, cancellationToken)); // Extremely long-running callback, perhaps throwing exceptions } - + // if control reaches here, the lock is invalid, and we should try again via the outer loop } finally { itemLock.Release(); } } else { validLock = false; //So the finally clause doesn't try to clean up the lock, someone else will do that. - return false; //Someone else had the lock, they can clean it up. + return Result.Err(); //Someone else had the lock, they can clean it up. } //Are we out of time, still having an invalid lock? // ReSharper disable once ConditionIsAlwaysTrueOrFalse - if (!validLock && Math.Abs(DateTime.UtcNow.Subtract(startedAt).TotalMilliseconds) > timeoutMs) { + if (!validLock && Math.Abs(DateTime.UtcNow.Subtract(startedAt).TotalMilliseconds) > lockTimeoutMs) { //We failed to get a valid lock in time. - return false; + return Result.Err(); } @@ -167,7 +189,10 @@ public async Task TryExecuteAsync(string key, int timeoutMs, CancellationT } } // Ideally the only objects in 'locks' will be open operations now. - return true; + //This should be impossible to reach, the loop only exits when validLock is true. + //And if validLock equals true, then the function will return + // otherwise an exception is thrown. + throw new InvalidOperationException("Unreachable code"); } } } diff --git a/src/Imazen.Common/Concurrency/BasicAsyncLock.cs b/src/Imazen.Common/Concurrency/BasicAsyncLock.cs index 84f62c8e..403919c8 100644 --- a/src/Imazen.Common/Concurrency/BasicAsyncLock.cs +++ b/src/Imazen.Common/Concurrency/BasicAsyncLock.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Imazen.Common.Concurrency +namespace Imazen.Common.Concurrency { public sealed class BasicAsyncLock { @@ -16,16 +10,25 @@ public BasicAsyncLock() releaser = Task.FromResult((IDisposable)new Releaser(this)); } - public Task LockAsync() + public Task LockAsync(CancellationToken cancellationToken = default) { - var wait = semaphore.WaitAsync(); + var wait = semaphore.WaitAsync(cancellationToken); return wait.IsCompleted ? releaser : - wait.ContinueWith((_, state) => (IDisposable)state, + wait.ContinueWith((_, state) => (IDisposable)state!, releaser.Result, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } - + public Task LockAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + var wait = semaphore.WaitAsync(timeout, cancellationToken); + return wait.IsCompleted ? + releaser : + wait.ContinueWith((_, state) => (IDisposable)state!, + releaser.Result, CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + private sealed class Releaser : IDisposable { private readonly BasicAsyncLock asyncLock; diff --git a/src/Imazen.Common/Concurrency/BoundedTaskCollection/BlobTaskItem.cs b/src/Imazen.Common/Concurrency/BoundedTaskCollection/BlobTaskItem.cs new file mode 100644 index 00000000..e9abdff4 --- /dev/null +++ b/src/Imazen.Common/Concurrency/BoundedTaskCollection/BlobTaskItem.cs @@ -0,0 +1,58 @@ +// Copyright (c) Imazen LLC. +// No part of this project, including this file, may be copied, modified, +// propagated, or distributed except as permitted in COPYRIGHT.txt. +// Licensed under the GNU Affero General Public License, Version 3.0. +// Commercial licenses available at http://imageresizing.net/ + +using Imazen.Abstractions.Blobs; + +namespace Imazen.Common.Concurrency.BoundedTaskCollection { + public class BlobTaskItem : IBoundedTaskItem { + + /// + /// Throws an exception if the blob is not natively reusable (call EnsureReusable first) + /// + /// + /// + /// + public BlobTaskItem(string key, IBlobWrapper data) { + if (!data.IsNativelyReusable) throw new System.ArgumentException("Blob must be natively reusable", nameof(data)); + this.data = data; + UniqueKey = key; + JobCreatedAt = DateTime.UtcNow; + } + private readonly IBlobWrapper data; + + public IBlobWrapper Blob => data; + + + private Task? RunningTask { get; set; } + public void StoreStartedTask(Task task) + { + if (RunningTask != null) throw new InvalidOperationException("Task already stored"); + RunningTask = task; + } + + public Task? GetTask() + { + return RunningTask; + } + + /// + /// Returns the UTC time this AsyncWrite object was created. + /// + public DateTime JobCreatedAt { get; } + + public string UniqueKey { get; } + + /// + /// Estimates the allocation size of this object structure + /// + /// + public long GetTaskSizeInMemory() + { + return (data.EstimateAllocatedBytes ?? 0) + 100; + } + + } +} diff --git a/src/Imazen.Common/Concurrency/BoundedTaskCollection/BoundedTaskCollection.cs b/src/Imazen.Common/Concurrency/BoundedTaskCollection/BoundedTaskCollection.cs new file mode 100644 index 00000000..44f6d90f --- /dev/null +++ b/src/Imazen.Common/Concurrency/BoundedTaskCollection/BoundedTaskCollection.cs @@ -0,0 +1,129 @@ +// Copyright (c) Imazen LLC. +// No part of this project, including this file, may be copied, modified, +// propagated, or distributed except as permitted in COPYRIGHT.txt. +// Licensed under the GNU Affero General Public License, Version 3.0. +// Commercial licenses available at http://imageresizing.net/ + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Hosting; + +namespace Imazen.Common.Concurrency.BoundedTaskCollection { + public class BoundedTaskCollection: IHostedService where T: class, IBoundedTaskItem { + + public BoundedTaskCollection(long maxQueueBytes, CancellationTokenSource? cts =null) { + MaxQueueBytes = maxQueueBytes; + this.cts = cts ?? new CancellationTokenSource(); + } + + private CancellationTokenSource cts; + + private readonly object syncStopping = new object(); + + private volatile bool stopped = false; + + private readonly ConcurrentDictionary c = new ConcurrentDictionary(); + + /// + /// How many bytes of data to hold in memory before refusing further queue requests and forcing them to be executed synchronously. + /// + public long MaxQueueBytes { get; } + + private long queuedBytes; + /// + /// If the collection contains the specified item, it is returned. Otherwise, null is returned. + /// + /// + /// + public T? Get(string key) { + c.TryGetValue(key, out var result); + return result; + } + public bool TryGet(string key, [NotNullWhen(true)]out T? result) { + return c.TryGetValue(key, out result); + } + + public enum EnqueueResult + { + Enqueued, + AlreadyPresent, + QueueFull, + Stopped + } + /// + /// Tries to enqueue the given task and callback + /// + /// + /// + /// + public EnqueueResult Queue(T taskItem, Func taskProcessor){ + if (stopped) return EnqueueResult.Stopped; + if (queuedBytes < 0) throw new InvalidOperationException(); + + // Deal with maximum queue size + var taskSize = taskItem.GetTaskSizeInMemory(); + Interlocked.Add(ref queuedBytes, taskSize); + if (queuedBytes > MaxQueueBytes) { + Interlocked.Add(ref queuedBytes, -taskSize); + return EnqueueResult.QueueFull; //Because we would use too much ram. + } + if (stopped) return EnqueueResult.Stopped; + lock (syncStopping) + { + if (stopped) return EnqueueResult.Stopped; + // Deal with duplicates + if (!c.TryAdd(taskItem.UniqueKey, taskItem)) + { + Interlocked.Add(ref queuedBytes, -taskSize); + return EnqueueResult.AlreadyPresent; + } + + + // Allocate the task + taskItem.StoreStartedTask(Task.Run( + async () => + { + try + { + if (cts.IsCancellationRequested) return; + await taskProcessor(taskItem, cts.Token); + } + finally + { + if (c.TryRemove(taskItem.UniqueKey, out var removed)) + { + Interlocked.Add(ref queuedBytes, -taskItem.GetTaskSizeInMemory()); + } + } + })); + } + + return EnqueueResult.Enqueued; + } + + /// + /// Awaits all current tasks (more tasks can be added while this is running) + /// + /// + public Task AwaitAllCurrentTasks() + { + var tasks = c.Values.Select(w => w.GetTask()).Where(w => w != null).Cast().ToArray(); + return Task.WhenAll(tasks); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + stopped = true; + lock (syncStopping) + { + cts.Cancel(); + return AwaitAllCurrentTasks(); + } + } + } +} diff --git a/src/Imazen.Common/Concurrency/BoundedTaskCollection/IBoundedTaskItem.cs b/src/Imazen.Common/Concurrency/BoundedTaskCollection/IBoundedTaskItem.cs new file mode 100644 index 00000000..dd583a37 --- /dev/null +++ b/src/Imazen.Common/Concurrency/BoundedTaskCollection/IBoundedTaskItem.cs @@ -0,0 +1,11 @@ +namespace Imazen.Common.Concurrency.BoundedTaskCollection { + + public interface IBoundedTaskItem{ + string UniqueKey { get; } + + // Can only be called once + void StoreStartedTask(Task task); + Task? GetTask(); + long GetTaskSizeInMemory(); + } +} \ No newline at end of file diff --git a/src/Imazen.Common/Extensibility/ClassicDiskCache/AsyncWriteResult.cs b/src/Imazen.Common/Extensibility/ClassicDiskCache/AsyncWriteResult.cs index 72121c68..617d9b6a 100644 --- a/src/Imazen.Common/Extensibility/ClassicDiskCache/AsyncWriteResult.cs +++ b/src/Imazen.Common/Extensibility/ClassicDiskCache/AsyncWriteResult.cs @@ -1,8 +1,6 @@ -using System.IO; -using System.Threading.Tasks; - namespace Imazen.Common.Extensibility.ClassicDiskCache { + [Obsolete("Use the IBlobCache system instead")] public delegate Task AsyncWriteResult(Stream output); diff --git a/src/Imazen.Common/Extensibility/ClassicDiskCache/CacheQueryResult.cs b/src/Imazen.Common/Extensibility/ClassicDiskCache/CacheQueryResult.cs index 05a1e57c..ba7f1c3a 100644 --- a/src/Imazen.Common/Extensibility/ClassicDiskCache/CacheQueryResult.cs +++ b/src/Imazen.Common/Extensibility/ClassicDiskCache/CacheQueryResult.cs @@ -1,5 +1,6 @@ namespace Imazen.Common.Extensibility.ClassicDiskCache { + [Obsolete("Use the IBlobCache system instead")] public enum CacheQueryResult { /// diff --git a/src/Imazen.Common/Extensibility/ClassicDiskCache/ICacheResult.cs b/src/Imazen.Common/Extensibility/ClassicDiskCache/ICacheResult.cs index fef67956..15d2a4f8 100644 --- a/src/Imazen.Common/Extensibility/ClassicDiskCache/ICacheResult.cs +++ b/src/Imazen.Common/Extensibility/ClassicDiskCache/ICacheResult.cs @@ -1,7 +1,6 @@ -using System.IO; - namespace Imazen.Common.Extensibility.ClassicDiskCache { + [Obsolete("Implement IBlobCacheResult instead")] public interface ICacheResult { /// diff --git a/src/Imazen.Common/Extensibility/ClassicDiskCache/IClassicDiskCache.cs b/src/Imazen.Common/Extensibility/ClassicDiskCache/IClassicDiskCache.cs index 40801d79..ef38138f 100644 --- a/src/Imazen.Common/Extensibility/ClassicDiskCache/IClassicDiskCache.cs +++ b/src/Imazen.Common/Extensibility/ClassicDiskCache/IClassicDiskCache.cs @@ -1,9 +1,9 @@ -using System.Threading.Tasks; using Imazen.Common.Issues; using Microsoft.Extensions.Hosting; namespace Imazen.Common.Extensibility.ClassicDiskCache { + [Obsolete("Implement IBlobCacheProvider and IBlobCache instead")] public interface IClassicDiskCache: IIssueProvider, IHostedService { Task GetOrCreate(string key, string fileExtension, AsyncWriteResult writeCallback); diff --git a/src/Imazen.Common/Extensibility/StreamCache/IStreamCache.cs b/src/Imazen.Common/Extensibility/StreamCache/IStreamCache.cs index 989bdd89..b04b4b48 100644 --- a/src/Imazen.Common/Extensibility/StreamCache/IStreamCache.cs +++ b/src/Imazen.Common/Extensibility/StreamCache/IStreamCache.cs @@ -1,16 +1,12 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; using Imazen.Common.Issues; using Microsoft.Extensions.Hosting; namespace Imazen.Common.Extensibility.StreamCache { - // A tuple + [Obsolete("Switch to IBlobCache; a callback system is no longer used")] public delegate Task AsyncBytesResult(CancellationToken cancellationToken); + [Obsolete("Implement IBlobCacheProvider and IBlobCache instead")] public interface IStreamCache : IIssueProvider, IHostedService { /// diff --git a/src/Imazen.Common/Extensibility/StreamCache/IStreamCacheInput.cs b/src/Imazen.Common/Extensibility/StreamCache/IStreamCacheInput.cs index 7a5911b0..f71193af 100644 --- a/src/Imazen.Common/Extensibility/StreamCache/IStreamCacheInput.cs +++ b/src/Imazen.Common/Extensibility/StreamCache/IStreamCacheInput.cs @@ -1,7 +1,7 @@ -using System; - namespace Imazen.Common.Extensibility.StreamCache { + + [Obsolete("Implement IBlobReusable instead")] public interface IStreamCacheInput { string ContentType { get; } diff --git a/src/Imazen.Common/Extensibility/StreamCache/IStreamCacheResult.cs b/src/Imazen.Common/Extensibility/StreamCache/IStreamCacheResult.cs index bfdb96e9..2b035c0c 100644 --- a/src/Imazen.Common/Extensibility/StreamCache/IStreamCacheResult.cs +++ b/src/Imazen.Common/Extensibility/StreamCache/IStreamCacheResult.cs @@ -1,8 +1,7 @@ -using System.IO; -using Imazen.Common.Extensibility.ClassicDiskCache; - +#pragma warning disable CS0618 // Type or member is obsolete namespace Imazen.Common.Extensibility.StreamCache { + [Obsolete("Implement IBlobCacheResult instead")] public interface IStreamCacheResult { /// @@ -13,11 +12,25 @@ public interface IStreamCacheResult /// /// null, or the content type if one was requested. /// - string ContentType { get; } + string? ContentType { get; } /// /// The result of the cache check. /// string Status { get; } } + + internal class StreamCacheResult : IStreamCacheResult + { + public StreamCacheResult(Stream data, string? contentType, string status) + { + Data = data; + ContentType = contentType; + Status = status; + } + + public Stream Data { get; } + public string? ContentType { get; } + public string Status { get; } + } } \ No newline at end of file diff --git a/src/Imazen.Common/Extensibility/StreamCache/StreamCacheInput.cs b/src/Imazen.Common/Extensibility/StreamCache/StreamCacheInput.cs index fdf1b511..a076cb59 100644 --- a/src/Imazen.Common/Extensibility/StreamCache/StreamCacheInput.cs +++ b/src/Imazen.Common/Extensibility/StreamCache/StreamCacheInput.cs @@ -1,7 +1,7 @@ -using System; - +#pragma warning disable CS0618 // Type or member is obsolete namespace Imazen.Common.Extensibility.StreamCache { + [Obsolete("Implement IBlobReusable instead")] public class StreamCacheInput : IStreamCacheInput { public StreamCacheInput(string contentType, ArraySegment bytes) @@ -17,5 +17,26 @@ public IStreamCacheInput ToIStreamCacheInput() { return this; } + public IStreamCacheResult ToIStreamCacheInResult() + { + return new StreamCacheInputResult(this); + } + } + + public class StreamCacheInputResult : IStreamCacheResult + { + public StreamCacheInputResult(IStreamCacheInput input) + { + this.Input = input; + if (input.Bytes.Array == null) throw new ArgumentNullException("input","Input byte array null"); + Data = new MemoryStream(input.Bytes.Array, input.Bytes.Offset, input.Bytes.Count); + + } + + internal IStreamCacheInput Input { get; private set; } + + public Stream Data { get; private set; } + public string ContentType => Input.ContentType; + public string Status => "pass"; } } \ No newline at end of file diff --git a/src/Imazen.Common/ExtensionMethods/DateTimeExtensions.cs b/src/Imazen.Common/ExtensionMethods/DateTimeExtensions.cs index 00db1098..05e79580 100644 --- a/src/Imazen.Common/ExtensionMethods/DateTimeExtensions.cs +++ b/src/Imazen.Common/ExtensionMethods/DateTimeExtensions.cs @@ -1,5 +1,3 @@ -using System; - namespace Imazen.Common.ExtensionMethods { public static class DateTimeExtensions diff --git a/src/Imazen.Common/ExtensionMethods/GenericExtensions.cs b/src/Imazen.Common/ExtensionMethods/GenericExtensions.cs index b6e7f7b1..5884bbd3 100644 --- a/src/Imazen.Common/ExtensionMethods/GenericExtensions.cs +++ b/src/Imazen.Common/ExtensionMethods/GenericExtensions.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace Imazen.Common.ExtensionMethods { internal static class GenericExtensions diff --git a/src/Imazen.Common/FileTypeDetection/FileTypeDetector.cs b/src/Imazen.Common/FileTypeDetection/FileTypeDetector.cs index 0f0f8ed9..793b9738 100644 --- a/src/Imazen.Common/FileTypeDetection/FileTypeDetector.cs +++ b/src/Imazen.Common/FileTypeDetection/FileTypeDetector.cs @@ -1,5 +1,3 @@ -using System; - namespace Imazen.Common.FileTypeDetection{ public class FileTypeDetector @@ -16,7 +14,12 @@ public class FileTypeDetector /// /// /// - public string GuessMimeType(byte[] first12Bytes) + public static string? GuessMimeType(byte[] first12Bytes) + { + return MagicBytes.GetImageContentType(first12Bytes); + } + + public static string? GuessMimeType(Span first12Bytes) { return MagicBytes.GetImageContentType(first12Bytes); } @@ -77,7 +80,7 @@ internal enum ImageFormat /// First 12 or more bytes of the file /// /// - private static ImageFormat? GetImageFormat(byte[] first12Bytes) + private static ImageFormat? GetImageFormat(Span first12Bytes) { // Useful resources: https://chromium.googlesource.com/chromium/src/+/HEAD/net/base/mime_sniffer.cc @@ -393,8 +396,7 @@ internal enum ImageFormat return null; } - - internal static string GetImageContentType(byte[] first12Bytes) + internal static string? GetImageContentType(Span first12Bytes) { switch (GetImageFormat(first12Bytes)) { diff --git a/src/Imazen.Common/Helpers/EncodingUtils.cs b/src/Imazen.Common/Helpers/EncodingUtils.cs index 6dff5a1d..f3e8aeba 100644 --- a/src/Imazen.Common/Helpers/EncodingUtils.cs +++ b/src/Imazen.Common/Helpers/EncodingUtils.cs @@ -1,5 +1,4 @@ -using System; -using System.Text; +using System.Text; namespace Imazen.Common.Helpers { diff --git a/src/Imazen.Common/Helpers/Signatures.cs b/src/Imazen.Common/Helpers/Signatures.cs index 43d8fbe2..0e1d5878 100644 --- a/src/Imazen.Common/Helpers/Signatures.cs +++ b/src/Imazen.Common/Helpers/Signatures.cs @@ -1,8 +1,6 @@ -using System; using System.Net; using System.Security.Cryptography; using System.Text; -using System.Web; namespace Imazen.Common.Helpers { @@ -19,6 +17,7 @@ public static string SignString(string data, string key, int signatureLengthInBy return EncodingUtils.ToBase64U(shorterHash); } + // URL decodes the path, querystring keys, and querystring values, removes the &signature= query pair, and re-concatenates without re-encoding public static string NormalizePathAndQueryForSigning(string pathAndQuery) { diff --git a/src/Imazen.Common/Imazen.Common.csproj b/src/Imazen.Common/Imazen.Common.csproj index cc1e257e..340426d7 100644 --- a/src/Imazen.Common/Imazen.Common.csproj +++ b/src/Imazen.Common/Imazen.Common.csproj @@ -3,22 +3,24 @@ Imageflow.Common - Interfaces and utilites shared by Imageflow and ImageResizer. true - net472;netstandard2.0 - 8.0 + enable + net8.0;netstandard2.0;net6.0 + latest enable + true + true + true - - runtime; build; native; contentfiles; analyzers; buildtransitive all + - diff --git a/src/Imazen.Common/Instrumentation/BasicProcessInfo.cs b/src/Imazen.Common/Instrumentation/BasicProcessInfo.cs index 44967724..8040a09c 100644 --- a/src/Imazen.Common/Instrumentation/BasicProcessInfo.cs +++ b/src/Imazen.Common/Instrumentation/BasicProcessInfo.cs @@ -1,6 +1,4 @@ -using System; -using System.Reflection; -using Imazen.Common.Instrumentation.Support; +using Imazen.Common.Instrumentation.Support; using Imazen.Common.Instrumentation.Support.InfoAccumulators; namespace Imazen.Common.Instrumentation diff --git a/src/Imazen.Common/Instrumentation/GlobalPerf.cs b/src/Imazen.Common/Instrumentation/GlobalPerf.cs index 3beccbd5..7056a599 100644 --- a/src/Imazen.Common/Instrumentation/GlobalPerf.cs +++ b/src/Imazen.Common/Instrumentation/GlobalPerf.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.Diagnostics; -using System.Linq; using Imazen.Common.Instrumentation.Support; using Imazen.Common.Instrumentation.Support.InfoAccumulators; using Imazen.Common.Instrumentation.Support.PercentileSinks; @@ -17,23 +14,24 @@ namespace Imazen.Common.Instrumentation // https://github.com/maiwald/lz-string-ruby internal class GlobalPerf { - readonly IssueSink sink = new IssueSink("GlobalPerf"); + readonly IssueSink sink = new("GlobalPerf"); - public static GlobalPerf Singleton { get; } = new GlobalPerf(); + public static GlobalPerf Singleton { get; } = new(); - Lazy Process { get; } = new Lazy(); + Lazy Process { get; } = new(); Lazy Hardware { get; } - private static ICollection _lastInfoProviders = null; + private static ICollection? _lastInfoProviders; - NamedInterval[] Intervals { get; } = { - new NamedInterval { Unit="second", Name="Per Second", TicksDuration = Stopwatch.Frequency}, - new NamedInterval { Unit="minute", Name="Per Minute", TicksDuration = Stopwatch.Frequency * 60}, - new NamedInterval { Unit="15_mins", Name="Per 15 Minutes", TicksDuration = Stopwatch.Frequency * 60 * 15}, - new NamedInterval { Unit="hour", Name="Per Hour", TicksDuration = Stopwatch.Frequency * 60 * 60}, - }; + NamedInterval[] Intervals { get; } = + [ + new() { Unit="second", Name="Per Second", TicksDuration = Stopwatch.Frequency}, + new() { Unit="minute", Name="Per Minute", TicksDuration = Stopwatch.Frequency * 60}, + new() { Unit="15_mins", Name="Per 15 Minutes", TicksDuration = Stopwatch.Frequency * 60 * 15}, + new() { Unit="hour", Name="Per Hour", TicksDuration = Stopwatch.Frequency * 60 * 60} + ]; - readonly ConcurrentDictionary rates = new ConcurrentDictionary(); + readonly ConcurrentDictionary rates = new(); readonly MultiIntervalStats blobReadEvents; readonly MultiIntervalStats blobReadBytes; @@ -41,16 +39,16 @@ internal class GlobalPerf readonly MultiIntervalStats decodedPixels; readonly MultiIntervalStats encodedPixels; - readonly ConcurrentDictionary percentiles = new ConcurrentDictionary(); + readonly ConcurrentDictionary percentiles = new(); - readonly IPercentileProviderSink job_times; - readonly IPercentileProviderSink decode_times; - readonly IPercentileProviderSink encode_times; - readonly IPercentileProviderSink job_other_time; - readonly IPercentileProviderSink blob_read_times; - readonly IPercentileProviderSink collect_info_times; + readonly IPercentileProviderSink jobTimes; + readonly IPercentileProviderSink decodeTimes; + readonly IPercentileProviderSink encodeTimes; + readonly IPercentileProviderSink jobOtherTime; + readonly IPercentileProviderSink blobReadTimes; + readonly IPercentileProviderSink collectInfoTimes; - readonly DictionaryCounter counters = new DictionaryCounter("counter_update_failed"); + readonly DictionaryCounter counters = new("counter_update_failed"); IEnumerable Percentiles { get; } = new[] { 5, 25, 50, 75, 95, 100 }; @@ -74,12 +72,12 @@ internal class GlobalPerf decodedPixels = rates.GetOrAdd("decoded_pixels", new MultiIntervalStats(Intervals)); encodedPixels = rates.GetOrAdd("encoded_pixels", new MultiIntervalStats(Intervals)); - job_times = percentiles.GetOrAdd("job_times", new TimingsSink()); - decode_times = percentiles.GetOrAdd("decode_times", new TimingsSink()); - encode_times = percentiles.GetOrAdd("encode_times", new TimingsSink()); - job_other_time = percentiles.GetOrAdd("job_other_time", new TimingsSink()); - blob_read_times = percentiles.GetOrAdd("blob_read_times", new TimingsSink()); - collect_info_times = percentiles.GetOrAdd("collect_info_times", new TimingsSink()); + jobTimes = percentiles.GetOrAdd("job_times", new TimingsSink()); + decodeTimes = percentiles.GetOrAdd("decode_times", new TimingsSink()); + encodeTimes = percentiles.GetOrAdd("encode_times", new TimingsSink()); + jobOtherTime = percentiles.GetOrAdd("job_other_time", new TimingsSink()); + blobReadTimes = percentiles.GetOrAdd("blob_read_times", new TimingsSink()); + collectInfoTimes = percentiles.GetOrAdd("collect_info_times", new TimingsSink()); sourceMegapixels = percentiles.GetOrAdd("source_pixels", new PixelCountSink()); outputMegapixels = percentiles.GetOrAdd("output_pixels", new PixelCountSink()); @@ -106,36 +104,36 @@ public void JobComplete(IImageJobInstrumentation job) IncrementCounter("image_jobs"); var timestamp = Stopwatch.GetTimestamp(); - var s_w = job.SourceWidth.GetValueOrDefault(0); - var s_h = job.SourceHeight.GetValueOrDefault(0); - var f_w = job.FinalWidth.GetValueOrDefault(0); - var f_h = job.FinalHeight.GetValueOrDefault(0); + var sW = job.SourceWidth.GetValueOrDefault(0); + var sH = job.SourceHeight.GetValueOrDefault(0); + var fW = job.FinalWidth.GetValueOrDefault(0); + var fH = job.FinalHeight.GetValueOrDefault(0); - if (job.SourceWidth.HasValue && job.SourceHeight.HasValue) + if (job is { SourceWidth: not null, SourceHeight: not null }) { var prefix = "source_multiple_"; - if (s_w % 4 == 0 && s_h % 4 == 0) + if (sW % 4 == 0 && sH % 4 == 0) { counters.Increment(prefix + "4x4"); } - if (s_w % 8 == 0 && s_h % 8 == 0) + if (sW % 8 == 0 && sH % 8 == 0) { counters.Increment(prefix + "8x8"); } - if (s_w % 8 == 0) + if (sW % 8 == 0) { counters.Increment(prefix + "8x"); } - if (s_h % 8 == 0) + if (sH % 8 == 0) { counters.Increment(prefix + "x8"); } - if (s_w % 16 == 0 && s_h % 16 == 0) + if (sW % 16 == 0 && sH % 16 == 0) { counters.Increment(prefix + "16x16"); } @@ -153,10 +151,10 @@ public void JobComplete(IImageJobInstrumentation job) { sourceMegapixels.Report(readPixels); - sourceWidths.Report(s_w); - sourceHeights.Report(s_h); + sourceWidths.Report(sW); + sourceHeights.Report(sH); - sourceAspectRatios.Report(s_w * 100 / s_h); + sourceAspectRatios.Report(sW * 100 / sH); } if (wrotePixels > 0) @@ -164,15 +162,15 @@ public void JobComplete(IImageJobInstrumentation job) outputMegapixels.Report(wrotePixels); - outputWidths.Report(f_w); - outputHeights.Report(f_h); - outputAspectRatios.Report(f_w * 100 / f_h); + outputWidths.Report(fW); + outputHeights.Report(fH); + outputAspectRatios.Report(fW * 100 / fH); } if (readPixels > 0 && wrotePixels > 0) { - scalingRatios.Report(s_w * 100 / f_w); - scalingRatios.Report(s_h * 100 / f_h); + scalingRatios.Report(sW * 100 / fW); + scalingRatios.Report(sH * 100 / fH); } jobs.Record(timestamp, 1); @@ -180,10 +178,10 @@ public void JobComplete(IImageJobInstrumentation job) encodedPixels.Record(timestamp, wrotePixels); - job_times.Report(job.TotalTicks); - decode_times.Report(job.DecodeTicks); - encode_times.Report(job.EncodeTicks); - job_other_time.Report(job.TotalTicks - job.DecodeTicks - job.EncodeTicks); + jobTimes.Report(job.TotalTicks); + decodeTimes.Report(job.DecodeTicks); + encodeTimes.Report(job.EncodeTicks); + jobOtherTime.Report(job.TotalTicks - job.DecodeTicks - job.EncodeTicks); if (job.SourceFileExtension != null) { @@ -198,19 +196,19 @@ public void JobComplete(IImageJobInstrumentation job) } - readonly ConcurrentDictionary> uniques - = new ConcurrentDictionary>(StringComparer.Ordinal); - private long CountLimitedUniqueValuesIgnoreCase(string category, string value, int limit, string otherBucketValue) + readonly ConcurrentDictionary> uniques = new(StringComparer.Ordinal); + + // ReSharper disable once UnusedMethodReturnValue.Local + private long CountLimitedUniqueValuesIgnoreCase(string category, string? value, int limit, string otherBucketValue) { - return uniques.GetOrAdd(category, (k) => + return uniques.GetOrAdd(category, (_) => new DictionaryCounter(limit, otherBucketValue, StringComparer.OrdinalIgnoreCase)) .Increment(value ?? "null"); } private IEnumerable GetPopularUniqueValues(string category, int limit) { - DictionaryCounter v; - return uniques.TryGetValue(category, out v) ? + return uniques.TryGetValue(category, out DictionaryCounter? v) ? v.GetCounts() .Where(pair => pair.Value > 0) .OrderByDescending(pair => pair.Value) @@ -218,7 +216,7 @@ private IEnumerable GetPopularUniqueValues(string category, int limit) Enumerable.Empty(); } - private void NoticeDomains(string imageDomain, string pageDomain) + private void NoticeDomains(string? imageDomain, string? pageDomain) { if (imageDomain != null) { CountLimitedUniqueValuesIgnoreCase("image_domains", imageDomain, 45, "_other_"); @@ -229,8 +227,9 @@ private void NoticeDomains(string imageDomain, string pageDomain) } } - private void PostJobQuery(IEnumerable querystringKeys) + private void PostJobQuery(IEnumerable? querystringKeys) { + if (querystringKeys == null) return; foreach (var key in querystringKeys) { if (key != null) @@ -240,8 +239,9 @@ private void PostJobQuery(IEnumerable querystringKeys) } } - public void PreRewriteQuery(IEnumerable querystringKeys) + public void PreRewriteQuery(IEnumerable? querystringKeys) { + if (querystringKeys == null) return; foreach (var key in querystringKeys) { if (key != null) @@ -255,7 +255,7 @@ public static void BlobRead(long ticks, long bytes) { Singleton.blobReadEvents.Record(Stopwatch.GetTimestamp(), 1); Singleton.blobReadBytes.Record(Stopwatch.GetTimestamp(), bytes); - Singleton.blob_read_times.Report(ticks); + Singleton.blobReadTimes.Report(ticks); } public IInfoAccumulator GetReportPairs() @@ -285,8 +285,8 @@ public IInfoAccumulator GetReportPairs() q.Add(rate.Key + "_total", rate.Value.RecordedTotal); foreach (var pair in rate.Value.GetStats()) { - var basekey = rate.Key + "_per_" + pair.Interval.Unit; - q.Add(basekey + "_max", pair.Max); + var baseKey = rate.Key + "_per_" + pair.Interval.Unit; + q.Add(baseKey + "_max", pair.Max); } } @@ -315,13 +315,13 @@ public IInfoAccumulator GetReportPairs() string.Join(",", GetPopularUniqueValues("job_query_keys", 40).Except(originalKeys).Take(2))); timeThis.Stop(); - collect_info_times.Report(timeThis.ElapsedTicks); + collectInfoTimes.Report(timeThis.ElapsedTicks); return q; } public void TrackRate(string eventCategoryKey, long count) { - rates.GetOrAdd(eventCategoryKey, (k) => new MultiIntervalStats(Intervals)).Record(Stopwatch.GetTimestamp(), count); + rates.GetOrAdd(eventCategoryKey, (_) => new MultiIntervalStats(Intervals)).Record(Stopwatch.GetTimestamp(), count); } public void IncrementCounter(string key) diff --git a/src/Imazen.Common/Instrumentation/HardwareInfo.cs b/src/Imazen.Common/Instrumentation/HardwareInfo.cs index 73ed49fc..bf56abca 100644 --- a/src/Imazen.Common/Instrumentation/HardwareInfo.cs +++ b/src/Imazen.Common/Instrumentation/HardwareInfo.cs @@ -1,9 +1,4 @@ - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.NetworkInformation; +using System.Net.NetworkInformation; using Imazen.Common.Instrumentation.Support; using Imazen.Common.Instrumentation.Support.InfoAccumulators; using Imazen.Common.Issues; diff --git a/src/Imazen.Common/Instrumentation/IImageJobInstrumentation.cs b/src/Imazen.Common/Instrumentation/IImageJobInstrumentation.cs index c38a6005..dd33554c 100644 --- a/src/Imazen.Common/Instrumentation/IImageJobInstrumentation.cs +++ b/src/Imazen.Common/Instrumentation/IImageJobInstrumentation.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace Imazen.Common.Instrumentation { internal interface IImageJobInstrumentation @@ -17,18 +15,18 @@ internal interface IImageJobInstrumentation /// /// var ext = PathUtils.GetExtension(job.SourcePathData).ToLowerInvariant().TrimStart('.'); /// - string SourceFileExtension { get; } + string? SourceFileExtension { get; } /// /// request?.Url?.DnsSafeHost /// - string ImageDomain { get; } + string? ImageDomain { get; } /// /// request?.UrlReferrer?.DnsSafeHost /// - string PageDomain { get; } + string? PageDomain { get; } - IEnumerable FinalCommandKeys { get; } + IEnumerable? FinalCommandKeys { get; } } } \ No newline at end of file diff --git a/src/Imazen.Common/Instrumentation/Support/AddMulModHash.cs b/src/Imazen.Common/Instrumentation/Support/AddMulModHash.cs index 3f3e2b22..2de56e37 100644 --- a/src/Imazen.Common/Instrumentation/Support/AddMulModHash.cs +++ b/src/Imazen.Common/Instrumentation/Support/AddMulModHash.cs @@ -1,6 +1,4 @@ -using System; - -namespace Imazen.Common.Instrumentation.Support +namespace Imazen.Common.Instrumentation.Support { readonly struct AddMulModHash : IHash { diff --git a/src/Imazen.Common/Instrumentation/Support/Clamping/SegmentClamping.cs b/src/Imazen.Common/Instrumentation/Support/Clamping/SegmentClamping.cs index a06a9d0f..45a1f497 100644 --- a/src/Imazen.Common/Instrumentation/Support/Clamping/SegmentClamping.cs +++ b/src/Imazen.Common/Instrumentation/Support/Clamping/SegmentClamping.cs @@ -1,15 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Imazen.Common.Instrumentation.Support.Clamping +namespace Imazen.Common.Instrumentation.Support.Clamping { internal class SegmentClamping { public long MaxPossibleValues { get; set; } = 100000; public long MinValue { get; set; } = 0; public long MaxValue { get; set; } = long.MaxValue; - public SegmentPrecision[] Segments { get; set; } + public required SegmentPrecision[] Segments { get; set; } public void Sort() { diff --git a/src/Imazen.Common/Instrumentation/Support/Clamping/SignificantDigitsClamping.cs b/src/Imazen.Common/Instrumentation/Support/Clamping/SignificantDigitsClamping.cs index af10749f..68469211 100644 --- a/src/Imazen.Common/Instrumentation/Support/Clamping/SignificantDigitsClamping.cs +++ b/src/Imazen.Common/Instrumentation/Support/Clamping/SignificantDigitsClamping.cs @@ -1,6 +1,4 @@ -using System; - -namespace Imazen.Common.Instrumentation.Support.Clamping +namespace Imazen.Common.Instrumentation.Support.Clamping { internal class SignificantDigitsClamping diff --git a/src/Imazen.Common/Instrumentation/Support/CountMinSketch.cs b/src/Imazen.Common/Instrumentation/Support/CountMinSketch.cs index 3f67e642..002abc0f 100644 --- a/src/Imazen.Common/Instrumentation/Support/CountMinSketch.cs +++ b/src/Imazen.Common/Instrumentation/Support/CountMinSketch.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; +using System.Text; using Imazen.Common.Instrumentation.Support.Clamping; namespace Imazen.Common.Instrumentation.Support diff --git a/src/Imazen.Common/Instrumentation/Support/Counter.cs b/src/Imazen.Common/Instrumentation/Support/Counter.cs index e142e5a5..1e91cac3 100644 --- a/src/Imazen.Common/Instrumentation/Support/Counter.cs +++ b/src/Imazen.Common/Instrumentation/Support/Counter.cs @@ -1,12 +1,4 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace Imazen.Common.Instrumentation.Support +namespace Imazen.Common.Instrumentation.Support { internal class Counter { diff --git a/src/Imazen.Common/Instrumentation/Support/DictionaryCounter.cs b/src/Imazen.Common/Instrumentation/Support/DictionaryCounter.cs index 237b6234..e65a3b05 100644 --- a/src/Imazen.Common/Instrumentation/Support/DictionaryCounter.cs +++ b/src/Imazen.Common/Instrumentation/Support/DictionaryCounter.cs @@ -1,10 +1,8 @@ using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; namespace Imazen.Common.Instrumentation.Support { - class DictionaryCounter + class DictionaryCounter where T : notnull { readonly ConcurrentDictionary dict; readonly Counter count; @@ -38,8 +36,7 @@ public DictionaryCounter(int maxKeyCount, T limitExceededKey, IEnumerable> GetInfo(); + IEnumerable> GetInfo(); } } diff --git a/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/IInfoAccumulatorExtensions.cs b/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/IInfoAccumulatorExtensions.cs index 562910f5..192a875b 100644 --- a/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/IInfoAccumulatorExtensions.cs +++ b/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/IInfoAccumulatorExtensions.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Text; using Imazen.Common.Helpers; namespace Imazen.Common.Instrumentation.Support.InfoAccumulators @@ -32,7 +29,7 @@ public static void Add(this IInfoAccumulator a, string key, long? value) a.AddString(key, value?.ToString()); } - public static void Add(this IInfoAccumulator a, string key, string value) + public static void Add(this IInfoAccumulator a, string key, string? value) { a.AddString(key, value); } @@ -40,8 +37,8 @@ public static string ToQueryString(this IInfoAccumulator a, int characterLimit) { const string truncated = "truncated=true"; var limit = characterLimit - truncated.Length; - var pairs = a.GetInfo().Where(pair => pair.Value != null && pair.Key != null) - .Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value)); + var pairs = a.GetInfo().Where(pair => pair is { Value: not null, Key: not null }) + .Select(pair => Uri.EscapeDataString(pair.Key) + "=" + (pair.Value == null ? "" : Uri.EscapeDataString(pair.Value))); var sb = new StringBuilder(1000); sb.Append("?"); foreach (var s in pairs) diff --git a/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/ProxyAccumulator.cs b/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/ProxyAccumulator.cs index 5a6697e6..38bafcb1 100644 --- a/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/ProxyAccumulator.cs +++ b/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/ProxyAccumulator.cs @@ -1,22 +1,19 @@ -using System; -using System.Collections.Generic; - -namespace Imazen.Common.Instrumentation.Support.InfoAccumulators +namespace Imazen.Common.Instrumentation.Support.InfoAccumulators { internal class ProxyAccumulator : IInfoAccumulator { - readonly Action add; - readonly Action prepend; + readonly Action add; + readonly Action prepend; readonly bool usePrepend; - readonly Func>> fetch; - public ProxyAccumulator(bool usePrepend, Action add, Action prepend, Func>> fetch) + readonly Func>> fetch; + public ProxyAccumulator(bool usePrepend, Action add, Action prepend, Func>> fetch) { this.usePrepend = usePrepend; this.add = add; this.prepend = prepend; this.fetch = fetch; } - public void AddString(string key, string value) + public void AddString(string key, string? value) { if (usePrepend) { @@ -28,7 +25,7 @@ public void AddString(string key, string value) } } - public IEnumerable> GetInfo() + public IEnumerable> GetInfo() { return fetch(); } diff --git a/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/QueryAccumulator.cs b/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/QueryAccumulator.cs index 25be7a2f..6fa25718 100644 --- a/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/QueryAccumulator.cs +++ b/src/Imazen.Common/Instrumentation/Support/InfoAccumulators/QueryAccumulator.cs @@ -1,17 +1,15 @@ -using System.Collections.Generic; - -namespace Imazen.Common.Instrumentation.Support.InfoAccumulators +namespace Imazen.Common.Instrumentation.Support.InfoAccumulators { internal class QueryAccumulator { - readonly List> pairs = new List>(); + readonly List> pairs = new List>(); public IInfoAccumulator Object { get; } public QueryAccumulator() { Object = new ProxyAccumulator(false, - (k, v) => pairs.Add(new KeyValuePair(k, v)), - (k, v) => pairs.Insert(0, new KeyValuePair(k, v)), + (k, v) => pairs.Add(new KeyValuePair(k, v)), + (k, v) => pairs.Insert(0, new KeyValuePair(k, v)), () => pairs); } } diff --git a/src/Imazen.Common/Instrumentation/Support/MachineStorage.cs b/src/Imazen.Common/Instrumentation/Support/MachineStorage.cs index 40ff3f67..d195b6fb 100644 --- a/src/Imazen.Common/Instrumentation/Support/MachineStorage.cs +++ b/src/Imazen.Common/Instrumentation/Support/MachineStorage.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Imazen.Common.Issues; +using Imazen.Common.Issues; using Imazen.Common.Persistence; namespace Imazen.Common.Instrumentation.Support diff --git a/src/Imazen.Common/Instrumentation/Support/MersenneTwister.cs b/src/Imazen.Common/Instrumentation/Support/MersenneTwister.cs index d93f7188..2fc5e8d1 100644 --- a/src/Imazen.Common/Instrumentation/Support/MersenneTwister.cs +++ b/src/Imazen.Common/Instrumentation/Support/MersenneTwister.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Imazen.Common.Instrumentation.Support +namespace Imazen.Common.Instrumentation.Support { /* Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura, diff --git a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/FlatSink.cs b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/FlatSink.cs index cf64b776..c63f03c1 100644 --- a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/FlatSink.cs +++ b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/FlatSink.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using Imazen.Common.Instrumentation.Support.Clamping; +using Imazen.Common.Instrumentation.Support.Clamping; namespace Imazen.Common.Instrumentation.Support.PercentileSinks { diff --git a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/IPercentileProviderSink.cs b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/IPercentileProviderSink.cs index d491e3cf..15cef456 100644 --- a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/IPercentileProviderSink.cs +++ b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/IPercentileProviderSink.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace Imazen.Common.Instrumentation.Support.PercentileSinks +namespace Imazen.Common.Instrumentation.Support.PercentileSinks { interface IPercentileProviderSink { diff --git a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/PixelCountSink.cs b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/PixelCountSink.cs index 67616962..30f5f027 100644 --- a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/PixelCountSink.cs +++ b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/PixelCountSink.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using Imazen.Common.Instrumentation.Support.Clamping; +using Imazen.Common.Instrumentation.Support.Clamping; namespace Imazen.Common.Instrumentation.Support.PercentileSinks { diff --git a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/ResolutionsSink.cs b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/ResolutionsSink.cs index 3e6171cd..282ff455 100644 --- a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/ResolutionsSink.cs +++ b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/ResolutionsSink.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using Imazen.Common.Instrumentation.Support.Clamping; +using Imazen.Common.Instrumentation.Support.Clamping; namespace Imazen.Common.Instrumentation.Support.PercentileSinks { diff --git a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/TimingsSink.cs b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/TimingsSink.cs index a56e74c0..8a226d07 100644 --- a/src/Imazen.Common/Instrumentation/Support/PercentileSinks/TimingsSink.cs +++ b/src/Imazen.Common/Instrumentation/Support/PercentileSinks/TimingsSink.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using Imazen.Common.Instrumentation.Support.Clamping; +using Imazen.Common.Instrumentation.Support.Clamping; namespace Imazen.Common.Instrumentation.Support.PercentileSinks { diff --git a/src/Imazen.Common/Instrumentation/Support/RateTracking/CircularTimeBuffer.cs b/src/Imazen.Common/Instrumentation/Support/RateTracking/CircularTimeBuffer.cs index 10a09caa..6fb46d7c 100644 --- a/src/Imazen.Common/Instrumentation/Support/RateTracking/CircularTimeBuffer.cs +++ b/src/Imazen.Common/Instrumentation/Support/RateTracking/CircularTimeBuffer.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; - -namespace Imazen.Common.Instrumentation.Support.RateTracking +namespace Imazen.Common.Instrumentation.Support.RateTracking { internal class CircularTimeBuffer { diff --git a/src/Imazen.Common/Instrumentation/Support/RateTracking/MultiIntervalStats.cs b/src/Imazen.Common/Instrumentation/Support/RateTracking/MultiIntervalStats.cs index 211aba07..6330d6f8 100644 --- a/src/Imazen.Common/Instrumentation/Support/RateTracking/MultiIntervalStats.cs +++ b/src/Imazen.Common/Instrumentation/Support/RateTracking/MultiIntervalStats.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; +using System.Diagnostics; namespace Imazen.Common.Instrumentation.Support.RateTracking { diff --git a/src/Imazen.Common/Instrumentation/Support/RateTracking/PerIntervalSampling.cs b/src/Imazen.Common/Instrumentation/Support/RateTracking/PerIntervalSampling.cs index 323769dc..76cba017 100644 --- a/src/Imazen.Common/Instrumentation/Support/RateTracking/PerIntervalSampling.cs +++ b/src/Imazen.Common/Instrumentation/Support/RateTracking/PerIntervalSampling.cs @@ -1,5 +1,4 @@ -using System; -using System.Diagnostics; +using System.Diagnostics; namespace Imazen.Common.Instrumentation.Support.RateTracking { diff --git a/src/Imazen.Common/Instrumentation/Support/Utilities.cs b/src/Imazen.Common/Instrumentation/Support/Utilities.cs index a488e252..d3a379cd 100644 --- a/src/Imazen.Common/Instrumentation/Support/Utilities.cs +++ b/src/Imazen.Common/Instrumentation/Support/Utilities.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; +using System.Reflection; using System.Text; -using System.Threading; using Imazen.Common.Helpers; // ReSharper disable LoopVariableIsNeverChangedInsideLoop @@ -76,15 +71,13 @@ static class StringExtensions /// public static string ToLowerOrdinal(this string s) { - StringBuilder b = null; + StringBuilder? b = null; for (var i = 0; i < s.Length; i++) { var c = s[i]; - if (c >= 'A' && c <= 'Z') - { - if (b == null) b = new StringBuilder(s); - b[i] = (char)(c + 0x20); - } + if (c is < 'A' or > 'Z') continue; + b ??= new StringBuilder(s); + b[i] = (char)(c + 0x20); } return b?.ToString() ?? s; } @@ -94,7 +87,7 @@ static class AssemblyExtensions { public static string IntoString(this IEnumerable c) => string.Concat(c); - public static T GetFirstAttribute(this Assembly a) + public static T? GetFirstAttribute(this Assembly a) { try { @@ -104,14 +97,18 @@ public static T GetFirstAttribute(this Assembly a) catch(FileNotFoundException) { //Missing dependencies } - catch (Exception) { } + catch (Exception) + { + // ignored + } + return default(T); } - public static Exception GetExceptionForReading(this Assembly a) + public static Exception? GetExceptionForReading(this Assembly a) { try { - var nah = a.GetCustomAttributes(typeof(T), false); + var _ = a.GetCustomAttributes(typeof(T), false); } catch (Exception e) { return e; } @@ -125,11 +122,11 @@ public static Exception GetExceptionForReading(this Assembly a) // public static string GetEditionCode(this Assembly a) => // GetFirstAttribute(a)?.Value; - public static string GetInformationalVersion(this Assembly a) + public static string? GetInformationalVersion(this Assembly a) { return GetFirstAttribute(a)?.InformationalVersion; } - public static string GetFileVersion(this Assembly a) + public static string? GetFileVersion(this Assembly a) { return GetFirstAttribute(a)?.Version; } diff --git a/src/Imazen.Common/Instrumentation/WindowsCpuInfo.cs b/src/Imazen.Common/Instrumentation/WindowsCpuInfo.cs index 02eee3d1..71cd8772 100644 --- a/src/Imazen.Common/Instrumentation/WindowsCpuInfo.cs +++ b/src/Imazen.Common/Instrumentation/WindowsCpuInfo.cs @@ -1,4 +1,4 @@ -using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; namespace Imazen.Common.Instrumentation @@ -23,8 +23,7 @@ public static byte[] Invoke(int level) Marshal.Copy(codeBytes, 0, codePointer, codeBytes.Length); - var cpuIdDelg = - (CpuIdDelegate) Marshal.GetDelegateForFunctionPointer(codePointer, typeof(CpuIdDelegate)); + var cpuIdDelg =Marshal.GetDelegateForFunctionPointer(codePointer); // invoke var handle = default(GCHandle); diff --git a/src/Imazen.Common/Issues/IIssue.cs b/src/Imazen.Common/Issues/IIssue.cs index 8c6ae818..1acec1b1 100644 --- a/src/Imazen.Common/Issues/IIssue.cs +++ b/src/Imazen.Common/Issues/IIssue.cs @@ -5,9 +5,9 @@ namespace Imazen.Common.Issues { public interface IIssue { - string Source { get; } - string Summary { get; } - string Details { get; } + string? Source { get; } + string? Summary { get; } + string? Details { get; } IssueSeverity Severity { get; } int Hash(); } diff --git a/src/Imazen.Common/Issues/IIssueProvider.cs b/src/Imazen.Common/Issues/IIssueProvider.cs index 226481cb..391c042d 100644 --- a/src/Imazen.Common/Issues/IIssueProvider.cs +++ b/src/Imazen.Common/Issues/IIssueProvider.cs @@ -3,8 +3,6 @@ // propagated, or distributed except as permitted in COPYRIGHT.txt. // Licensed under the Apache License, Version 2.0. -using System.Collections.Generic; - namespace Imazen.Common.Issues { public interface IIssueProvider { diff --git a/src/Imazen.Common/Issues/Issue.cs b/src/Imazen.Common/Issues/Issue.cs index 41ca6f21..56c10079 100644 --- a/src/Imazen.Common/Issues/Issue.cs +++ b/src/Imazen.Common/Issues/Issue.cs @@ -10,31 +10,31 @@ namespace Imazen.Common.Issues public class Issue : IIssue { public Issue() { } - public Issue(string message) { + public Issue(string? message) { Summary = message; } - public Issue(string message, string details, IssueSeverity severity) { + public Issue(string? message, string? details, IssueSeverity severity) { Summary = message; Details = details; Severity = severity; } - public Issue(string message, IssueSeverity severity) { + public Issue(string? message, IssueSeverity severity) { Summary = message; Severity = severity; } - public Issue(string source, string message, string details, IssueSeverity severity) { + public Issue(string? source, string? message, string? details, IssueSeverity severity) { Source = source; Summary = message; Details = details; Severity = severity; } - public string Source { get; set; } + public string? Source { get; set; } - public string Summary { get; private set; } = null; + public string? Summary { get; private set; } = null; - public string Details { get; private set; } = null; + public string? Details { get; private set; } = null; public IssueSeverity Severity { get; private set; } = IssueSeverity.Warning; @@ -51,8 +51,7 @@ public int Hash() { } public override string ToString() { - return Source + "(" + Severity.ToString() + "):\t" + Summary + - ("\n" + Details).Replace("\n", "\n\t\t\t") + "\n"; + return $"{Source}({Severity}):\t{Summary}\n{Details?.Replace("\n", "\n\t\t\t")}\n"; } } } diff --git a/src/Imazen.Common/Issues/IssueSink.cs b/src/Imazen.Common/Issues/IssueSink.cs index ec8c5498..272ca568 100644 --- a/src/Imazen.Common/Issues/IssueSink.cs +++ b/src/Imazen.Common/Issues/IssueSink.cs @@ -3,8 +3,6 @@ // propagated, or distributed except as permitted in COPYRIGHT.txt. // Licensed under the Apache License, Version 2.0. -using System.Collections.Generic; - namespace Imazen.Common.Issues { public class IssueSink: IIssueProvider,IIssueReceiver { diff --git a/src/Imazen.Common/Licensing/BuildDateAttribute.cs b/src/Imazen.Common/Licensing/BuildDateAttribute.cs index 559ea0bd..7bb02e0c 100644 --- a/src/Imazen.Common/Licensing/BuildDateAttribute.cs +++ b/src/Imazen.Common/Licensing/BuildDateAttribute.cs @@ -1,34 +1,12 @@ -using System; - namespace Imazen.Common.Licensing { [AttributeUsage(AttributeTargets.Assembly)] - public class BuildDateAttribute : Attribute + [Obsolete("Use Imazen.Abstractions.AssemblyAttributes.BuildDateAttribute instead")] + public class BuildDateAttribute : Abstractions.AssemblyAttributes.BuildDateAttribute { - public BuildDateAttribute() { Value = string.Empty; } - public BuildDateAttribute(string buildDateStringRoundTrip) { Value = buildDateStringRoundTrip; } - - public string Value { get; } - - public DateTimeOffset? ValueDate - { - get - { - DateTimeOffset v; - if (DateTimeOffset.TryParse(Value, null, System.Globalization.DateTimeStyles.RoundtripKind, out v)) - { - return v; - }else - { - return null; - } - } - } - - public override string ToString() - { - return Value; - } - + public BuildDateAttribute() + { } + public BuildDateAttribute(string buildDateStringRoundTrip):base(buildDateStringRoundTrip) { } + } } \ No newline at end of file diff --git a/src/Imazen.Common/Licensing/CommitAttribute.cs b/src/Imazen.Common/Licensing/CommitAttribute.cs index 4add2275..4de167ca 100644 --- a/src/Imazen.Common/Licensing/CommitAttribute.cs +++ b/src/Imazen.Common/Licensing/CommitAttribute.cs @@ -1,28 +1,13 @@ -using System; - namespace Imazen.Common.Licensing { [AttributeUsage(AttributeTargets.Assembly)] - public class CommitAttribute : Attribute + [Obsolete("Use Imazen.Abstractions.AssemblyAttributes.CommitAttribute instead")] + public class CommitAttribute : Abstractions.AssemblyAttributes.CommitAttribute { - private readonly string commitId; - public CommitAttribute() - { - commitId = string.Empty; - } - - public CommitAttribute(string commitId) - { - this.commitId = commitId; - } - - public string Value => commitId; + {} - public override string ToString() - { - return commitId; - } + public CommitAttribute(string commitId):base(commitId){} } } \ No newline at end of file diff --git a/src/Imazen.Common/Licensing/Computation.cs b/src/Imazen.Common/Licensing/Computation.cs index 2d3129ab..fc7dccf5 100644 --- a/src/Imazen.Common/Licensing/Computation.cs +++ b/src/Imazen.Common/Licensing/Computation.cs @@ -4,18 +4,12 @@ // Licensed under the GNU Affero General Public License, Version 3.0. // Commercial licenses available at http://imageresizing.net/ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Security.Cryptography.X509Certificates; using System.Text; using Imazen.Common.ExtensionMethods; using Imazen.Common.Instrumentation; -using Imazen.Common.Instrumentation.Support; using Imazen.Common.Instrumentation.Support.InfoAccumulators; using Imazen.Common.Issues; @@ -52,7 +46,9 @@ internal class Computation : IssueSink bool EnforcementEnabled { get; } IDictionary KnownDomainStatus { get; } public DateTimeOffset? ComputationExpires { get; } + // ReSharper disable once UnusedAutoPropertyAccessor.Local LicenseAccess Scope { get; } + // ReSharper disable once ArrangeTypeMemberModifiers LicenseErrorAction LicenseError { get; } private ILicenseConfig LicenseConfig { get; } @@ -73,7 +69,7 @@ public Computation(ILicenseConfig c, IReadOnlyCollection trust // What features are installed on this instance? // For a license to be OK, it must have one of each of this nested list; - IEnumerable> pluginFeaturesUsed = c.GetFeaturesUsed(); + IReadOnlyCollection> pluginFeaturesUsed = c.GetFeaturesUsed(); // Create or fetch all relevant license chains; ignore the empty/invalid ones, they're logged to the manager instance chains = c.GetLicenses() @@ -83,6 +79,7 @@ public Computation(ILicenseConfig c, IReadOnlyCollection trust ? mgr.GetSharedLicenses() : Enumerable.Empty()) .Distinct() + .Cast() .ToList(); @@ -147,10 +144,10 @@ private bool IsLicenseExpired(ILicenseDetails details) { return details.ImageflowExpires != null && details.ImageflowExpires < clock.GetUtcNow(); - } else { - return details.Expires != null && - details.Expires < clock.GetUtcNow(); } + + return details.Expires != null && + details.Expires < clock.GetUtcNow(); } private bool HasLicenseBegun(ILicenseDetails details) => details.Issued != null && @@ -161,7 +158,7 @@ public IEnumerable GetMessages(ILicenseDetails d) => new[] { d.GetMessage(), IsLicenseExpired(d) ? d.GetExpiryMessage() : null, d.GetRestrictions() - }.Where(s => !string.IsNullOrWhiteSpace(s)); + }.Where(s => !string.IsNullOrWhiteSpace(s)).Cast(); @@ -250,9 +247,9 @@ private bool IsPendingLicense(ILicenseChain chain) // Success will automatically replace this instance. Warn immediately. Debug.Assert(mgr.FirstHeartbeat != null, "mgr.FirstHeartbeat != null"); - + var firstHeartbeat = mgr.FirstHeartbeat ?? clock.GetUtcNow(); // NetworkGraceMinutes Expired? - var expires = mgr.FirstHeartbeat.Value.AddMinutes(graceMinutes); + var expires = firstHeartbeat.AddMinutes(graceMinutes); if (expires < clock.GetUtcNow()) { permanentIssues.AcceptIssue(new Issue($"Grace period of {graceMinutes}m expired for license {chain.Id}", $"License {chain.Id} was not found in the disk cache and could not be retrieved from the remote server within {graceMinutes} minutes.", @@ -261,7 +258,7 @@ private bool IsPendingLicense(ILicenseChain chain) } // Less than 30 seconds since boot time? - var thirtySeconds = mgr.FirstHeartbeat.Value.AddSeconds(30); + var thirtySeconds = firstHeartbeat.AddSeconds(30); if (thirtySeconds > clock.GetUtcNow()) { AcceptIssue(new Issue($"Fetching license {chain.Id} (not found in disk cache).", $"Network grace period expires in {graceMinutes} minutes", IssueSeverity.Warning)); @@ -297,12 +294,12 @@ public bool LicensedForSomething() public bool UpgradeNeeded() => !AllDomainsLicensed || KnownDomainStatus.Values.Contains(false); - public string ManageSubscriptionUrl => chains + public string? ManageSubscriptionUrl => chains .SelectMany(c => c.Licenses()) .Select(l => l.Fields.Get("ManageYourSubscription")) .FirstOrDefault(v => !string.IsNullOrEmpty(v)); - public bool LicensedForRequestUrl(Uri url) + public bool LicensedForRequestUrl(Uri? url) { if (EverythingDenied) { return false; @@ -383,8 +380,8 @@ string GetPublicLicenseHeader() var sb = new StringBuilder(); var hr = EnforcementEnabled - ? $"---------------------- License Validation ON ----------------------\r\n" - : $"---------------------- License Validation OFF -----------------------\r\n"; + ? "---------------------- License Validation ON ----------------------\r\n" + : "---------------------- License Validation OFF -----------------------\r\n"; sb.Append(hr); if (!EnforcementEnabled) @@ -464,7 +461,7 @@ string GetPublicLicenseHeader() } - public string ProvideDiagnostics() => ListLicensesInternal(c => c.ToString()); + public string ProvideDiagnostics() => ListLicensesInternal(c => c.ToString() ?? "(null)"); public string ProvidePublicLicensesPage() => GetPublicLicenseHeader() + ListLicensesInternal(c => c.ToPublicString()); diff --git a/src/Imazen.Common/Licensing/DomainLookup.cs b/src/Imazen.Common/Licensing/DomainLookup.cs index 9f61753c..f4118713 100644 --- a/src/Imazen.Common/Licensing/DomainLookup.cs +++ b/src/Imazen.Common/Licensing/DomainLookup.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; +using System.Collections.Concurrent; using Imazen.Common.ExtensionMethods; using Imazen.Common.Issues; @@ -115,7 +111,7 @@ public string ExplainNormalizations() /// /// /// - public string FindKnownDomain(string similarDomain) + public string? FindKnownDomain(string similarDomain) { // Bound ConcurrentDictionary growth; fail on new domains instead if (lookupTableSize <= LookupTableLimit) { diff --git a/src/Imazen.Common/Licensing/EditionAttribute.cs b/src/Imazen.Common/Licensing/EditionAttribute.cs index 75b9fa40..e49ca1d2 100644 --- a/src/Imazen.Common/Licensing/EditionAttribute.cs +++ b/src/Imazen.Common/Licensing/EditionAttribute.cs @@ -1,5 +1,3 @@ -using System; - namespace Imazen.Common.Licensing { [AttributeUsage(AttributeTargets.Assembly)] diff --git a/src/Imazen.Common/Licensing/ILicenseConfig.cs b/src/Imazen.Common/Licensing/ILicenseConfig.cs index a791d414..e3a2fc42 100644 --- a/src/Imazen.Common/Licensing/ILicenseConfig.cs +++ b/src/Imazen.Common/Licensing/ILicenseConfig.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace Imazen.Common.Licensing { internal delegate void LicenseConfigEvent(object sender, ILicenseConfig forConfig); @@ -28,7 +26,7 @@ internal interface ILicenseConfig /// For each item, at least of of the feature codes in the sub-list must be present /// /// - IEnumerable> GetFeaturesUsed(); + IReadOnlyCollection> GetFeaturesUsed(); // c.Plugins.GetAll() diff --git a/src/Imazen.Common/Licensing/ILicenseSupport.cs b/src/Imazen.Common/Licensing/ILicenseSupport.cs index 7d0bfd76..360fe27a 100644 --- a/src/Imazen.Common/Licensing/ILicenseSupport.cs +++ b/src/Imazen.Common/Licensing/ILicenseSupport.cs @@ -1,7 +1,4 @@ - -using System; -using System.Collections.Generic; -using Imazen.Common.Issues; +using Imazen.Common.Issues; using Imazen.Common.Persistence; namespace Imazen.Common.Licensing @@ -45,7 +42,7 @@ internal interface ILicenseManager : IIssueProvider /// /// /// - ILicenseChain GetOrAdd(string license, LicenseAccess access); + ILicenseChain? GetOrAdd(string license, LicenseAccess access); /// /// Returns all shared license chains (a chain is shared if any relevant license is marked shared) @@ -103,13 +100,13 @@ public interface ILicenseChain /// Returns null until a fresh license has been fetched (within process lifetime) /// /// - ILicenseBlob FetchedLicense(); + ILicenseBlob? FetchedLicense(); ///// ///// Returns the (presumably) disk cached license ///// ///// - ILicenseBlob CachedLicense(); + ILicenseBlob? CachedLicense(); /// @@ -118,7 +115,7 @@ public interface ILicenseChain /// string ToPublicString(); - string LastFetchUrl(); + string? LastFetchUrl(); } /// @@ -137,7 +134,7 @@ public interface ILicenseDetails string Id { get; } IReadOnlyDictionary Pairs { get; } - string Get(string key); + string? Get(string key); DateTimeOffset? Issued { get; } DateTimeOffset? Expires { get; } DateTimeOffset? ImageflowExpires { get; } diff --git a/src/Imazen.Common/Licensing/LicenseAccess.cs b/src/Imazen.Common/Licensing/LicenseAccess.cs index 76c9a5be..3af6b15d 100644 --- a/src/Imazen.Common/Licensing/LicenseAccess.cs +++ b/src/Imazen.Common/Licensing/LicenseAccess.cs @@ -1,11 +1,9 @@ -using System; - namespace Imazen.Common.Licensing { /// /// Sharing of license keys. /// - [Flags()] + [Flags] internal enum LicenseAccess { /// diff --git a/src/Imazen.Common/Licensing/LicenseChain.cs b/src/Imazen.Common/Licensing/LicenseChain.cs index cc5aa336..f18a4427 100644 --- a/src/Imazen.Common/Licensing/LicenseChain.cs +++ b/src/Imazen.Common/Licensing/LicenseChain.cs @@ -1,351 +1,356 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using System.Collections.Concurrent; using Imazen.Common.Instrumentation.Support.InfoAccumulators; using Imazen.Common.Issues; -namespace Imazen.Common.Licensing +namespace Imazen.Common.Licensing; + +/// +/// A chain of licenses can consist of +/// a) 1 or more offline-domain licenses that may or may not enable different feature codes +/// b) 1 or more ID licenses, and (optionally) a cached OR remote license for that ID +/// +class LicenseChain : ILicenseChain { + static readonly string[] DefaultLicenseServers = { + "https://s3.us-west-2.amazonaws.com/licenses.imazen.net/", + "https://licenses-redirect.imazen.net/", + "https://licenses.imazen.net/", + "https://licenses2.imazen.net" + }; + /// - /// A chain of licenses can consist of - /// a) 1 or more offline-domain licenses that may or may not enable different feature codes - /// b) 1 or more ID licenses, and (optionally) a cached OR remote license for that ID + /// Key is a hash of the license signature /// - class LicenseChain : ILicenseChain - { - static readonly string[] DefaultLicenseServers = { - "https://s3-us-west-2.amazonaws.com/licenses.imazen.net/", - "https://licenses-redirect.imazen.net/", - "https://licenses.imazen.net/", - "https://licenses2.imazen.net" - }; - - /// - /// Key is a hash of the license signature - /// - readonly ConcurrentDictionary dict = new ConcurrentDictionary(); + readonly ConcurrentDictionary dict = new ConcurrentDictionary(); - /// - /// License Servers - /// - string[] licenseServerStack = DefaultLicenseServers; - - int licenseIntervalMinutes = 60 * 60; + /// + /// License Servers + /// + string[] licenseServerStack = DefaultLicenseServers; - readonly LicenseManagerSingleton parent; + int licenseIntervalMinutes = 60 * 60; - /// - /// Cache for .Licenses() - /// - List cache; + readonly LicenseManagerSingleton parent; - /// - /// The current fetcher. Invalidated when URLs are changed - /// - LicenseFetcher fetcher; + /// + /// Cache for .Licenses() + /// + List? cache; + /// + /// The current fetcher. Invalidated when URLs are changed + /// + LicenseFetcher? fetcher; - //public string Explain() - //{ - // string.Format("License fetch: last 200 {}, last 404 {}, last timeout {}, last exception {}, ) - // // Explain history of license fetching - //} + //public string Explain() + //{ + // string.Format("License fetch: last 200 {}, last 404 {}, last timeout {}, last exception {}, ) + // // Explain history of license fetching + //} - long lastBeatCount; - Uri lastWorkingUri; + long lastBeatCount; - /// - /// The fresh/local (not from cache) remote license - /// - LicenseBlob remoteLicense; + Uri? lastWorkingUri; - /// - /// The last time when we got the HTTP response. - /// - public DateTimeOffset? Last200 { get; private set; } + /// + /// The fresh/local (not from cache) remote license + /// + LicenseBlob? remoteLicense; - public DateTimeOffset? LastSuccess { get; private set; } - public DateTimeOffset? Last404 { get; private set; } - public DateTimeOffset? LastException { get; private set; } - public DateTimeOffset? LastTimeout { get; private set; } - string Secret { get; set; } + /// + /// The last time when we got the HTTP response. + /// + public DateTimeOffset? Last200 { get; private set; } + public DateTimeOffset? LastSuccess { get; private set; } + public DateTimeOffset? Last404 { get; private set; } + public DateTimeOffset? LastException { get; private set; } + public DateTimeOffset? LastTimeout { get; private set; } + string? Secret { get; set; } - public string CacheKey => Id + "_" + LicenseFetcher.Fnv1a32.HashToInt(Secret).ToString("x"); + public string RemoteCacheKey => Secret == null ? Id : Id + "_" + LicenseFetcher.Fnv1a32.HashToInt(Secret).ToString("x"); - // Actually needs an issue receiver? (or should *it* track?) And an HttpClient and Cache - public LicenseChain(LicenseManagerSingleton parent, string licenseId) - { - this.parent = parent; - Id = licenseId; - LocalLicenseChange(); - } + // Actually needs an issue receiver? (or should *it* track?) And an HttpClient and Cache + public LicenseChain(LicenseManagerSingleton parent, string licenseId, LicenseBlob licenseBlob) + { + this.parent = parent; + Id = licenseId; + LocalLicenseChange(); + TryAdd(licenseBlob); + } - public string Id { get; } - public bool IsRemote { get; set; } + public string Id { get; } + public bool IsRemote { get; set; } - public bool Shared { get; set; } + public bool Shared { get; set; } - /// - /// Returns null until a fresh license has been fetched (within process lifetime) - /// - /// - public ILicenseBlob FetchedLicense() => remoteLicense; + /// + /// Returns null until a fresh license has been fetched (within process lifetime) + /// + /// + public ILicenseBlob? FetchedLicense() => remoteLicense; - public ILicenseBlob CachedLicense() + public ILicenseBlob? CachedLicense() + { + if (fetcher == null) { - if (fetcher == null) - { - return null; - } - var cached = parent.Cache.Get(fetcher.CacheKey); - if (cached != null && cached.TryParseInt() == null) - { - return parent.TryDeserialize(cached, "disk cache", false); - } return null; } - - public IEnumerable Licenses() + var cached = parent.Cache.Get(fetcher.CacheKey); + if (cached != null && cached.TryParseInt() == null) { - if (cache == null) - { - LocalLicenseChange(); - } - return cache; + return parent.TryDeserialize(cached, "disk cache", false); } + return null; + } + + public IEnumerable Licenses() + { + return cache ?? LocalLicenseChange(); + } - public string ToPublicString() + public string ToPublicString() + { + if (Licenses().All(b => !b.Fields.IsPublic())) { - if (Licenses().All(b => !b.Fields.IsPublic())) - { - return "(license hidden)\n"; // None of these are public - } - var cached = fetcher != null ? parent.Cache.Get(fetcher.CacheKey) : null; - Func freshness = b => b == remoteLicense + return "(license hidden)\n"; // None of these are public + } + var cached = fetcher != null ? parent.Cache.Get(fetcher.CacheKey) : null; + + string Freshness(ILicenseBlob? b) => + (b as LicenseBlob) == remoteLicense ? "(fresh)\n" - : b.Original == cached + : b != null && b.Original == cached ? "(from cache)\n" : ""; - return RedactSecret( - $"License {Id}{(IsRemote ? " (remote)" : "")}\n{string.Join("\n\n", Licenses().Where(b => b.Fields.IsPublic()).Select(b => freshness(b) + b.ToRedactedString()))}\n"); - } + return RedactSecret( + $"License {Id}{(IsRemote ? " (remote)" : "")}\n{string.Join("\n\n", Licenses().Where(b => b.Fields.IsPublic()).Select(b => Freshness(b) + b.ToRedactedString()))}\n")!; + } - void OnFetchResult(string body, IReadOnlyCollection results) + void OnFetchResult(string? body, IReadOnlyCollection results) + { + if (fetcher == null) + { + return; // Should be unreachable + } + if (body != null) { - if (body != null) + Last200 = parent.Clock.GetUtcNow(); + var license = parent.TryDeserialize(body, "remote server", false); + if (license != null) { - Last200 = parent.Clock.GetUtcNow(); - var license = parent.TryDeserialize(body, "remote server", false); - if (license != null) + var newId = license.Fields.Id; + if (newId == Id) { - var newId = license.Fields.Id; - if (newId == Id) - { - remoteLicense = license; - // Victory! (we're ignoring failed writes/duplicates) - parent.Cache.TryPut(fetcher.CacheKey, body); - - LastSuccess = parent.Clock.GetUtcNow(); - - lastWorkingUri = results.Last().FullUrl; - } - else - { - parent.AcceptIssue(new Issue( - "Remote license file does not match. Please contact support@imageresizing.net", - "Local: " + Id + " Remote: " + newId, IssueSeverity.Error)); - } - } - // TODO: consider logging a failed deserialization remotely - } - else - { - var licenseName = Id; + remoteLicense = license; + // Victory! (we're ignoring failed writes/duplicates) + parent.Cache.TryPut(fetcher.CacheKey, body); - if (results.All(r => r.HttpCode == 404 || r.HttpCode == 403)) - { - parent.AcceptIssue(new Issue("No such license (404/403): " + licenseName, - string.Join("\n", results.Select(r => "HTTP 404/403 fetching " + RedactSecret(r.ShortUrl))), - IssueSeverity.Error)); - // No such subscription key.. but don't downgrade it if exists. - var cachedString = parent.Cache.Get(fetcher.CacheKey); - int temp; - if (cachedString == null || !int.TryParse(cachedString, out temp)) - { - parent.Cache.TryPut(fetcher.CacheKey, results.First().HttpCode.ToString()); - } - Last404 = parent.Clock.GetUtcNow(); - } - else if (results.All(r => r.LikelyNetworkFailure)) - { - // Network failure. Make sure the server can access the remote server - parent.AcceptIssue(fetcher.FirewallIssue(licenseName, results.FirstOrDefault())); - LastTimeout = parent.Clock.GetUtcNow(); + LastSuccess = parent.Clock.GetUtcNow(); + + lastWorkingUri = results.Last().FullUrl; } else { - parent.AcceptIssue(new Issue("Exception(s) occurred fetching license " + licenseName, - RedactSecret(string.Join("\n", - results.Select(r => - $"{r.HttpCode} {r.FullUrl} LikelyTimeout: {r.LikelyNetworkFailure} Error: {r.FetchError?.ToString()}"))), - IssueSeverity.Error)); - LastException = parent.Clock.GetUtcNow(); + parent.AcceptIssue(new Issue( + "Remote license file does not match. Please contact support@imageresizing.net", + "Local: " + Id + " Remote: " + newId, IssueSeverity.Error)); } } - LocalLicenseChange(); + // TODO: consider logging a failed deserialization remotely } - - string RedactSecret(string s) => Secret != null ? s?.Replace(Secret, "[redacted secret]") : s; - - void RecreateFetcher() + else { - if (!IsRemote) + var licenseName = Id; + + if (results.All(r => r.HttpCode == 404 || r.HttpCode == 403)) { - return; + parent.AcceptIssue(new Issue("No such license (404/403): " + licenseName, + string.Join("\n", results.Select(r => "HTTP 404/403 fetching " + RedactSecret(r.ShortUrl))), + IssueSeverity.Error)); + // No such subscription key.. but don't downgrade it if exists. + var cachedString = parent.Cache.Get(fetcher.CacheKey); + int temp; + if (cachedString == null || !int.TryParse(cachedString, out temp)) + { + parent.Cache.TryPut(fetcher.CacheKey, "404"); + } + Last404 = parent.Clock.GetUtcNow(); } - fetcher = new LicenseFetcher( - parent.Clock, - () => parent.HttpClient, - OnFetchResult, - GetReportPairs, - parent, - Id, - Secret, - licenseServerStack, - (long?)licenseIntervalMinutes); - - if (parent.AllowFetching()) + else if (results.All(r => r.LikelyNetworkFailure)) { - fetcher.Heartbeat(); + // Network failure. Make sure the server can access the remote server + parent.AcceptIssue(fetcher.FirewallIssue(licenseName, results.FirstOrDefault())); + LastTimeout = parent.Clock.GetUtcNow(); + } + else + { + parent.AcceptIssue(new Issue("Exception(s) occurred fetching license " + licenseName, + RedactSecret(string.Join("\n", + results.Select(r => + $"{r.HttpCode} {r.FullUrl} LikelyTimeout: {r.LikelyNetworkFailure} Error: {r.FetchError}"))), + IssueSeverity.Error)); + LastException = parent.Clock.GetUtcNow(); } } + LocalLicenseChange(); + } + string? RedactSecret(string? s) => Secret != null ? s?.Replace(Secret, "[redacted secret]") : s; - string[] GetLicenseServers(ILicenseBlob blob) + void RecreateFetcher() + { + if (!IsRemote) + { + return; + } + if (Secret == null) { - var oldStack = licenseServerStack ?? new string[0]; - var newList = blob.Fields.GetValidLicenseServers().ToArray(); - return newList.Concat(oldStack.Except(newList)).Take(10).ToArray(); + throw new InvalidOperationException("Secret is null for a remote license"); } + fetcher = new LicenseFetcher( + parent.Clock, + () => parent.HttpClient, + OnFetchResult, + GetReportPairs, + parent, + Id, + Secret, + licenseServerStack, + licenseIntervalMinutes); + + if (parent.AllowFetching()) + { + fetcher.Heartbeat(); + } + } - /// - /// Returns false if the blob is null, - /// if there were no license servers in the blob, - /// or if the servers were identical to what we already have. - /// - /// - /// - bool TryUpdateLicenseServersInfo(ILicenseBlob blob) + + string[] GetLicenseServers(ILicenseBlob blob) + { + var oldStack = licenseServerStack ?? new string[0]; + var newList = blob.Fields.GetValidLicenseServers().ToArray(); + return newList.Concat(oldStack.Except(newList)).Take(10).ToArray(); + } + + /// + /// Returns false if the blob is null, + /// if there were no license servers in the blob, + /// or if the servers were identical to what we already have. + /// + /// + /// + bool TryUpdateLicenseServersInfo(ILicenseBlob? blob) + { + if (blob == null) { - if (blob == null) - { - return false; - } - var interval = blob.Fields.CheckLicenseIntervalMinutes(); - var intervalChanged = interval != null && interval != licenseIntervalMinutes; + return false; + } + var interval = blob.Fields.CheckLicenseIntervalMinutes(); + var intervalChanged = interval != null && interval != licenseIntervalMinutes; - var oldStack = licenseServerStack ?? new string[0]; - var newStack = GetLicenseServers(blob); - var stackChanged = !newStack.SequenceEqual(oldStack); + var oldStack = licenseServerStack ?? new string[0]; + var newStack = GetLicenseServers(blob); + var stackChanged = !newStack.SequenceEqual(oldStack); - if (stackChanged) - { - licenseServerStack = newStack; - } - if (intervalChanged) - { - licenseIntervalMinutes = interval.Value; - } - return stackChanged || intervalChanged; + if (stackChanged) + { + licenseServerStack = newStack; } + if (intervalChanged && interval != null) + { + licenseIntervalMinutes = interval.Value; + } + return stackChanged || intervalChanged; + } - /// - /// We have a layer of caching by string. This does not need to be fast. - /// - /// - public void Add(LicenseBlob b) + /// + /// We have a layer of caching by string. This does not need to be fast. + /// + /// + public void TryAdd(LicenseBlob b) + { + // Prevent duplicate signatures + if (dict.TryAdd(BitConverter.ToString(b.Signature), b)) { - // Prevent duplicate signatures - if (dict.TryAdd(BitConverter.ToString(b.Signature), b)) + //New/unique - ensure fetcher is created + if (b.Fields.IsRemotePlaceholder()) { - //New/unique - ensure fetcher is created - if (b.Fields.IsRemotePlaceholder()) - { - Secret = b.Fields.GetSecret(); - IsRemote = true; + Secret = b.Fields.GetSecret(); + IsRemote = true; - TryUpdateLicenseServersInfo(b); - RecreateFetcher(); - } - LocalLicenseChange(); + TryUpdateLicenseServersInfo(b); + RecreateFetcher(); } + LocalLicenseChange(); } + } - List CollectLicenses() - { - return Enumerable.Repeat(FetchedLicense() ?? CachedLicense(), 1) - .Concat(dict.Values) - .Where(b => b != null) - .ToList(); - } + List CollectLicenses() + { + return Enumerable.Repeat(FetchedLicense() ?? CachedLicense(), 1) + .Concat(dict.Values) + .Where(b => b != null).Cast() + .ToList(); + } - public IEnumerable GetAsyncTasksSnapshot() => - fetcher?.GetAsyncTasksSnapshot() ?? Enumerable.Empty(); + public IEnumerable GetAsyncTasksSnapshot() => + fetcher?.GetAsyncTasksSnapshot() ?? Enumerable.Empty(); - void LocalLicenseChange() + List LocalLicenseChange() + { + if (TryUpdateLicenseServersInfo(FetchedLicense() ?? CachedLicense())) { - if (TryUpdateLicenseServersInfo(FetchedLicense() ?? CachedLicense())) - { - RecreateFetcher(); - } - cache = CollectLicenses(); - parent.FireLicenseChange(); + RecreateFetcher(); } + cache = CollectLicenses(); + parent.FireLicenseChange(); + return cache; + } - IInfoAccumulator GetReportPairs() - { - var q = parent.GetReportPairs(); + IInfoAccumulator GetReportPairs() + { + var q = parent.GetReportPairs(); - var beatCount = parent.HeartbeatCount; - var netBeats = beatCount - lastBeatCount; - lastBeatCount = beatCount; + var beatCount = parent.HeartbeatCount; + var netBeats = beatCount - lastBeatCount; + lastBeatCount = beatCount; - var prepending = q.WithPrepend(true); - prepending.Add("new_heartbeats", netBeats.ToString()); - return q; - } + var prepending = q.WithPrepend(true); + prepending.Add("new_heartbeats", netBeats.ToString()); + return q; + } - public override string ToString() - { - var cached = fetcher != null ? parent.Cache.Get(fetcher.CacheKey) : null; - Func freshness = b => b == remoteLicense + public override string? ToString() + { + var cached = fetcher != null ? parent.Cache.Get(fetcher.CacheKey) : null; + + string Freshness(ILicenseBlob b) => + b as LicenseBlob == remoteLicense ? "(fresh from license server)\n" : b.Original == cached ? "(from cache)\n" : ""; - // TODO: this.Last200, this.Last404, this.LastException, this.LastSuccess, this.LastTimeout - return RedactSecret( - $"License {Id} (remote={IsRemote})\n {string.Join("\n\n", Licenses().Select(b => freshness(b) + b.ToRedactedString())).Replace("\n", "\n ")}\n"); - } - public string LastFetchUrl() { return RedactSecret(lastWorkingUri?.ToString()); } - internal void Heartbeat() + // TODO: this.Last200, this.Last404, this.LastException, this.LastSuccess, this.LastTimeout + return RedactSecret( + $"License {Id} (remote={IsRemote})\n {string.Join("\n\n", Licenses().Select(b => Freshness(b) + b.ToRedactedString())).Replace("\n", "\n ")}\n"); + } + + public string? LastFetchUrl() { return RedactSecret(lastWorkingUri?.ToString()); } + internal void Heartbeat() + { + if (parent.AllowFetching()) { - if (parent.AllowFetching()) - { - fetcher?.Heartbeat(); - } + fetcher?.Heartbeat(); } } -} +} \ No newline at end of file diff --git a/src/Imazen.Common/Licensing/LicenseErrorAction.cs b/src/Imazen.Common/Licensing/LicenseErrorAction.cs index 8d14074e..8f6446e3 100644 --- a/src/Imazen.Common/Licensing/LicenseErrorAction.cs +++ b/src/Imazen.Common/Licensing/LicenseErrorAction.cs @@ -1,11 +1,9 @@ -using System; - namespace Imazen.Common.Licensing { /// /// How to notify the user that license validation has failed /// - [Flags()] + [Flags] internal enum LicenseErrorAction { /// diff --git a/src/Imazen.Common/Licensing/LicenseFetcher.cs b/src/Imazen.Common/Licensing/LicenseFetcher.cs index 12f9d4c8..9d4effed 100644 --- a/src/Imazen.Common/Licensing/LicenseFetcher.cs +++ b/src/Imazen.Common/Licensing/LicenseFetcher.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Concurrent; using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; +using System.Text; using Imazen.Common.Instrumentation.Support.InfoAccumulators; using Imazen.Common.Issues; @@ -36,7 +31,7 @@ class LicenseFetcher readonly Func getInfo; readonly string id; - readonly Action> licenseResult; + readonly Action> licenseResult; readonly ImperfectDebounce regular; readonly string secret; @@ -48,7 +43,7 @@ class LicenseFetcher public string CacheKey => id + "_" + Fnv1a32.HashToInt(secret).ToString("x"); public LicenseFetcher(ILicenseClock clock, Func getClient, - Action> licenseResult, + Action> licenseResult, Func getInfo, IIssueReceiver sink, string licenseId, string licenseSecret, string[] baseUrls, long? licenseFetchIntervalSeconds) { @@ -102,7 +97,7 @@ async Task FetchLicense(CancellationToken? cancellationToken, bool fromErrorSche foreach (var prefix in baseUrls) { var baseUrl = prefix + LicenseFetchPath + ".txt"; - Uri url = null; + Uri? url = null; using (var tokenSource = new CancellationTokenSource()) { var token = cancellationToken ?? tokenSource.Token; try { @@ -118,7 +113,7 @@ async Task FetchLicense(CancellationToken? cancellationToken, bool fromErrorSche if (httpResponse.IsSuccessStatusCode) { var bodyBytes = await httpResponse.Content.ReadAsByteArrayAsync(); - var bodyStr = System.Text.Encoding.UTF8.GetString(bodyBytes); + var bodyStr = Encoding.UTF8.GetString(bodyBytes); // Exit task early if canceled (process shutdown?) if (token.IsCancellationRequested) { @@ -142,7 +137,7 @@ async Task FetchLicense(CancellationToken? cancellationToken, bool fromErrorSche var networkFailure = NetworkFailures.Any(s => s == status); results.Add(new FetchResult { - FetchError = (Exception) web ?? rex, + FetchError = web as Exception ?? rex, FullUrl = url, ShortUrl = baseUrl, FailureKind = status, @@ -172,7 +167,7 @@ async Task FetchLicense(CancellationToken? cancellationToken, bool fromErrorSche public IIssue FirewallIssue(string licenseName) => FirewallIssue(licenseName, null); - public IIssue FirewallIssue(string licenseName, FetchResult r) + public IIssue FirewallIssue(string licenseName, FetchResult? r) { string resultMessage = r != null ? $"\n\nHTTP={r.HttpCode}. Exception status={r.FailureKind}. Exception: {r.FetchError}" : ""; return new Issue("Check firewall; cannot reach Amazon S3 to validate license " + licenseName, @@ -217,7 +212,7 @@ string ConstructQuerystring() return $"?license_id={id}"; } - bool InvokeResultCallback(string body, IReadOnlyCollection results) + bool InvokeResultCallback(string? body, IReadOnlyCollection results) { try { licenseResult(body, results); @@ -232,10 +227,10 @@ bool InvokeResultCallback(string body, IReadOnlyCollection results) internal class FetchResult { public int? HttpCode { get; set; } - public string ShortUrl { get; set; } - public Uri FullUrl { get; set; } - public Exception FetchError { get; set; } - public Exception ParsingError { get; set; } + public string? ShortUrl { get; set; } + public Uri? FullUrl { get; set; } + public Exception? FetchError { get; set; } + public Exception? ParsingError { get; set; } public bool LikelyNetworkFailure { get; set; } public WebExceptionStatus? FailureKind { get; set; } } @@ -247,7 +242,7 @@ internal class Fnv1a32 const uint FnvPrime = 16777619; const uint FnvOffsetBasis = 2166136261; - public static uint HashToInt(string s) => HashToInt(System.Text.Encoding.UTF8.GetBytes(s)); + public static uint HashToInt(string s) => HashToInt(Encoding.UTF8.GetBytes(s)); public static uint HashToInt(byte[] array) { diff --git a/src/Imazen.Common/Licensing/LicenseManager.cs b/src/Imazen.Common/Licensing/LicenseManager.cs index fdd490d8..72c65f69 100644 --- a/src/Imazen.Common/Licensing/LicenseManager.cs +++ b/src/Imazen.Common/Licensing/LicenseManager.cs @@ -1,15 +1,10 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; +using System.Collections.Concurrent; using Imazen.Common.Instrumentation; using Imazen.Common.Instrumentation.Support.InfoAccumulators; using Imazen.Common.Issues; - using Imazen.Common.Persistence; +using Imazen.Common.Persistence; - namespace Imazen.Common.Licensing +namespace Imazen.Common.Licensing { /// /// A license manager can serve as a per-process (per app-domain, at least) hub for license fetching @@ -19,8 +14,8 @@ class LicenseManagerSingleton : ILicenseManager, IIssueReceiver /// /// Connects all variants of each license to the relevant chain /// - readonly ConcurrentDictionary aliases = - new ConcurrentDictionary(StringComparer.Ordinal); + readonly ConcurrentDictionary aliases = + new ConcurrentDictionary(StringComparer.Ordinal); /// /// By license id/domain, lowercase invariant. @@ -89,7 +84,7 @@ public bool AllowFetching() if (fetcherCount > 0) { var now = DateTime.UtcNow; - var oldestWrite = chains.Values.Where(c => c.IsRemote).Select(c => Cache.GetWriteTimeUtc(c.CacheKey)).Min(); + var oldestWrite = chains.Values.Where(c => c.IsRemote).Select(c => Cache.GetWriteTimeUtc(c.RemoteCacheKey)).Min(); if (oldestWrite.HasValue && now.Subtract(oldestWrite.Value) < TimeSpan.FromMinutes(60)) { SkipHeartbeats = SkipHeartbeatsIfDiskCacheIsFresh; @@ -110,15 +105,15 @@ public bool AllowFetching() public IReadOnlyCollection TrustedKeys { get; } private static readonly object SingletonLock = new object(); - private static LicenseManagerSingleton _singleton; + private static LicenseManagerSingleton? _singleton; public static LicenseManagerSingleton GetOrCreateSingleton(string keyPrefix, string[] candidateCacheFolders) { lock (SingletonLock) { - return _singleton ?? (_singleton = new LicenseManagerSingleton( + return _singleton ??= new LicenseManagerSingleton( ImazenPublicKeys.Production, new RealClock(), - new PersistentGlobalStringCache(keyPrefix, candidateCacheFolders))); + new PersistentGlobalStringCache(keyPrefix, candidateCacheFolders)); } } @@ -127,7 +122,7 @@ internal LicenseManagerSingleton(IReadOnlyCollection trustedKe { TrustedKeys = trustedKeys; Clock = clock; - SetHttpMessageHandler(null, true); + HttpClient = SetHttpMessageHandler(null, true); Cache = cache; } @@ -184,10 +179,11 @@ public void MonitorLicenses(ILicenseConfig c) /// /// Registers the license and (if relevant) signs it up for periodic updates from S3. Can also make existing private /// licenses shared. + /// Returns null if the license fails to parse /// /// /// - public ILicenseChain GetOrAdd(string license, LicenseAccess access) + public ILicenseChain? GetOrAdd(string license, LicenseAccess access) { var chain = aliases.GetOrAdd(license, GetChainFor); // We may want to share a previously unshared license @@ -220,10 +216,10 @@ public LicenseManagerEvent AddLicenseChangeHandler(TTarget target, { var weakTarget = new WeakReference(target, false); // ReSharper disable once ConvertToLocalFunction - LicenseManagerEvent handler = null; - handler = mgr => + LicenseManagerEvent? handler = null; + handler = _ => { - var t = (TTarget) weakTarget.Target; + var t = (TTarget?) weakTarget.Target; if (t != null) { action(t, this); } else { @@ -247,7 +243,7 @@ public void RemoveLicenseChangeHandler(LicenseManagerEvent handler) /// /// When there is a material change or addition to a license chain (whether private or shared) /// - event LicenseManagerEvent LicenseChange; + event LicenseManagerEvent? LicenseChange; void Pipeline_Heartbeat(object sender, ILicenseConfig c) { Heartbeat(); } @@ -259,15 +255,15 @@ void Plugins_LicensingChange(object sender, ILicenseConfig c) Heartbeat(); } - LicenseChain GetChainFor(string license) + LicenseChain? GetChainFor(string license) { var blob = TryDeserialize(license, "configuration", true); if (blob == null) { return null; } - var chain = chains.GetOrAdd(blob.Fields.Id, k => new LicenseChain(this, k)); - chain.Add(blob); + var chain = chains.GetOrAdd(blob.Fields.Id, k => new LicenseChain(this, k, blob)); + chain.TryAdd(blob); FireLicenseChange(); //Can only be triggered for new aliases anyway; we don't really need to debounce on signature return chain; @@ -281,10 +277,11 @@ public void FireLicenseChange() LicenseChange?.Invoke(this); } - public void SetHttpMessageHandler(HttpMessageHandler handler, bool disposeHandler) + public HttpClient SetHttpMessageHandler(HttpMessageHandler? handler, bool disposeHandler) { var newClient = handler == null ? new HttpClient() : new HttpClient(handler, disposeHandler); HttpClient = newClient; + return newClient; } @@ -315,7 +312,7 @@ public async Task AwaitTasks() return tasks.Length; } - public LicenseBlob TryDeserialize(string license, string licenseSource, bool locallySourced) + public LicenseBlob? TryDeserialize(string license, string licenseSource, bool locallySourced) { LicenseBlob blob; try { diff --git a/src/Imazen.Common/Licensing/LicenseParsing.cs b/src/Imazen.Common/Licensing/LicenseParsing.cs index 1813f976..938ed7ca 100644 --- a/src/Imazen.Common/Licensing/LicenseParsing.cs +++ b/src/Imazen.Common/Licensing/LicenseParsing.cs @@ -1,20 +1,16 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Numerics; +using System.Numerics; using System.Security.Cryptography; using System.Text; using static System.String; namespace Imazen.Common.Licensing { - class LicenseBlob : ILicenseBlob + record LicenseBlob : ILicenseBlob { - public string Original { get; private set; } - public byte[] Signature { get; private set; } - public byte[] Data { get; private set; } - public ILicenseDetails Fields { get; private set; } + public required string Original { get; init; } + public required byte[] Signature { get; init; } + public required byte[] Data { get; init; } + public required ILicenseDetails Fields { get; init; } public static LicenseBlob Deserialize(string license) { @@ -25,12 +21,12 @@ public static LicenseBlob Deserialize(string license) nameof(license)); } - var dataBytes = Convert.FromBase64String(parts[parts.Count - 2]); + var dataBytes = Convert.FromBase64String(parts[^2]); var b = new LicenseBlob { Original = license, - Signature = Convert.FromBase64String(parts[parts.Count - 1]), + Signature = Convert.FromBase64String(parts[^1]), Data = dataBytes, - Fields = new LicenseDetails(System.Text.Encoding.UTF8.GetString(dataBytes)) + Fields = new LicenseDetails(Encoding.UTF8.GetString(dataBytes)) }; // b.Info = System.Text.Encoding.UTF8.GetString(b.Data); // b.Comments = parts.Take(parts.Count - 2); @@ -40,14 +36,14 @@ public static LicenseBlob Deserialize(string license) public static string TryRedact(string license) { var segments = license.Split(':'); - return segments.Count() > 1 ? string.Join(":", + return segments.Count() > 1 ? Join(":", segments.Take(segments.Count() - 2).Concat(new[] { "****redacted****", segments.Last() })) : license; } public override string ToString() => Original; } - class LicenseDetails : ILicenseDetails + record LicenseDetails : ILicenseDetails { public LicenseDetails(string plaintext) { @@ -76,8 +72,11 @@ public LicenseDetails(string plaintext) Pairs = pairs; - Id = (Get("Id") ?? Get("Domain"))?.Trim().ToLowerInvariant(); - if (IsNullOrEmpty(Id)) { + var id = (Get("Id") ?? Get("Domain"))?.Trim().ToLowerInvariant(); + if (!IsNullOrEmpty(id) && id != null) + { + Id = id; + }else{ throw new ArgumentException("No 'Id' or 'Domain' fields found! At least one of the two is required"); } if (IsNullOrEmpty(this.GetSecret()) && this.IsRemotePlaceholder()) { @@ -85,17 +84,16 @@ public LicenseDetails(string plaintext) } } - public string Id { get; } + public string Id { get; } public DateTimeOffset? Issued { get; } public DateTimeOffset? Expires { get; } public DateTimeOffset? ImageflowExpires { get; } public DateTimeOffset? SubscriptionExpirationDate { get; } public IReadOnlyDictionary Pairs { get; } - public string Get(string key) + public string? Get(string key) { - string s; - return Pairs.TryGetValue(key, out s) ? s : null; + return Pairs.TryGetValue(key, out var s) ? s : null; } public override string ToString() @@ -110,45 +108,45 @@ public override string ToString() static class StringIntParseExtensions { - public static int? TryParseInt(this string s) + public static int? TryParseInt(this string? s) { int temp; - return IsNullOrWhiteSpace(s) ? null : (int.TryParse(s, out temp) ? (int?) temp : null); + return IsNullOrWhiteSpace(s) ? null : (int.TryParse(s, out temp) ? temp : null); } } static class LicenseDetailsExtensions { - public static bool IsRemotePlaceholder(this ILicenseDetails details) + public static bool IsRemotePlaceholder(this ILicenseDetails? details) => "id".Equals(details?.Get("Kind"), StringComparison.OrdinalIgnoreCase); - public static bool IsRevoked(this ILicenseDetails details) + public static bool IsRevoked(this ILicenseDetails? details) => "false".Equals(details?.Get("Valid"), StringComparison.OrdinalIgnoreCase); - public static bool IsPublic(this ILicenseDetails details) + public static bool IsPublic(this ILicenseDetails? details) => "true".Equals(details?.Get("IsPublic"), StringComparison.OrdinalIgnoreCase); - public static bool MustBeFetched(this ILicenseDetails details) + public static bool MustBeFetched(this ILicenseDetails? details) => "true".Equals(details?.Get("MustBeFetched"), StringComparison.OrdinalIgnoreCase); - public static int? NetworkGraceMinutes(this ILicenseDetails details) + public static int? NetworkGraceMinutes(this ILicenseDetails? details) => details?.Get("NetworkGraceMinutes").TryParseInt(); - public static int? CheckLicenseIntervalMinutes(this ILicenseDetails details) + public static int? CheckLicenseIntervalMinutes(this ILicenseDetails? details) => details?.Get("CheckLicenseIntervalMinutes").TryParseInt(); - public static string GetSecret(this ILicenseDetails details) + public static string? GetSecret(this ILicenseDetails? details) => details?.Get("Secret"); - public static string GetMessage(this ILicenseDetails details) + public static string? GetMessage(this ILicenseDetails? details) => details?.Get("Message"); - public static string GetRestrictions(this ILicenseDetails details) + public static string? GetRestrictions(this ILicenseDetails? details) => details?.Get("Restrictions"); - public static string GetExpiryMessage(this ILicenseDetails details) + public static string? GetExpiryMessage(this ILicenseDetails? details) => details?.Get("ExpiryMessage"); @@ -157,7 +155,7 @@ public static string GetExpiryMessage(this ILicenseDetails details) /// /// /// - public static IEnumerable GetFeatures(this ILicenseDetails details) + public static IEnumerable GetFeatures(this ILicenseDetails? details) => details?.Get("Features") ?.Split(new[] {' ', '\t', ','}, StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim()) @@ -196,15 +194,11 @@ public static IEnumerable GetValidLicenseServers(this ILicenseDetails de return details.Get("LicenseServers") ? .Split(new[] {' ', '\t'}, StringSplitOptions.RemoveEmptyEntries) - .Where(s => - { - Uri t; - return Uri.TryCreate(s, UriKind.Absolute, out t) && t.Scheme == "https"; - }) + .Where(s => Uri.TryCreate(s, UriKind.Absolute, out var t) && t?.Scheme == "https") .Select(s => s.Trim()) ?? Enumerable.Empty(); } - public static bool DataMatches(this ILicenseDetails me, ILicenseDetails other) + public static bool DataMatches(this ILicenseDetails? me, ILicenseDetails? other) { return me != null && other != null && me.Id == other.Id && me.Issued == other.Issued && me.Expires == other.Expires && @@ -223,7 +217,7 @@ public static bool Revalidate(this ILicenseBlob b, IEnumerable } public static bool VerifySignature(this ILicenseBlob b, IEnumerable trustedKeys, - StringBuilder log) + StringBuilder? log) { return trustedKeys.Any(p => { @@ -235,8 +229,8 @@ public static bool VerifySignature(this ILicenseBlob b, IEnumerable ((BuildDateAttribute) a).ValueDate) - .FirstOrDefault(); + try + { + // Fall back to the old attribute + var type = GetType(); + return type.Assembly.GetFirstAttribute()?.ValueDate ?? +#pragma warning disable CS0618 // Type or member is obsolete + type.Assembly.GetFirstAttribute()?.ValueDate; +#pragma warning restore CS0618 // Type or member is obsolete + } catch { return null; } @@ -27,9 +29,16 @@ class RealClock : ILicenseClock public DateTimeOffset? GetAssemblyWriteDate() { - var path = GetType().Assembly.Location; try { - return path != null && File.Exists(path) + // .Location can throw, or be empty string (on AOT) +#pragma warning disable IL3000 + var path = GetType().Assembly.Location; +#pragma warning restore IL3000 + if (string.IsNullOrEmpty(path)) + { + return null; + } + return File.Exists(path) ? new DateTimeOffset?(File.GetLastWriteTimeUtc(path)) : null; } catch { diff --git a/src/Imazen.Common/Persistence/IPersistentStringCache.cs b/src/Imazen.Common/Persistence/IPersistentStringCache.cs index ed1fdccf..ffa84d9a 100644 --- a/src/Imazen.Common/Persistence/IPersistentStringCache.cs +++ b/src/Imazen.Common/Persistence/IPersistentStringCache.cs @@ -1,6 +1,4 @@ -using System; - -namespace Imazen.Common.Persistence +namespace Imazen.Common.Persistence { /// /// The result of the cache write @@ -27,7 +25,7 @@ internal enum StringCachePutResult internal interface IPersistentStringCache { StringCachePutResult TryPut(string key, string value); - string Get(string key); + string? Get(string key); DateTime? GetWriteTimeUtc(string key); diff --git a/src/Imazen.Common/Persistence/MultiFolderStorage.cs b/src/Imazen.Common/Persistence/MultiFolderStorage.cs index 205630bf..54f1aabd 100644 --- a/src/Imazen.Common/Persistence/MultiFolderStorage.cs +++ b/src/Imazen.Common/Persistence/MultiFolderStorage.cs @@ -1,10 +1,6 @@ using Imazen.Common.Issues; -using System; using System.Collections.Concurrent; -using System.IO; -using System.Linq; using System.Text; -using System.Threading; namespace Imazen.Common.Persistence { @@ -17,38 +13,28 @@ enum FolderOptions // TODO: use a friendlier ACL when creating directories and files? // Original version made files written world-readable (but not writable) - class MultiFolderStorage + internal class MultiFolderStorage( + string issueSource, + string dataKind, + IIssueReceiver sink, + string[] candidateFolders, + FolderOptions options) { - IIssueReceiver sink; - string issueSource; - string[] candidateFolders; - FolderOptions options; - string dataKind; - - public MultiFolderStorage(string issueSource, string dataKind, IIssueReceiver sink, string[] candidateFolders, FolderOptions options) - { - this.dataKind = dataKind; - this.issueSource = issueSource; - this.candidateFolders = candidateFolders; - this.sink = sink; - this.options = options; - } - - ConcurrentBag badReadLocations = new ConcurrentBag(); - void AddBadReadLocation(string path, IIssue i) + readonly ConcurrentBag badReadLocations = new(); + void AddBadReadLocation(string path, IIssue? i) { badReadLocations.Add(path); if (i != null) sink.AcceptIssue(i); } - ConcurrentBag badWriteLocations = new ConcurrentBag(); - void AddBadWriteLocation(string path, IIssue i) + readonly ConcurrentBag badWriteLocations = new(); + void AddBadWriteLocation(string path, IIssue? i) { badWriteLocations.Add(path); if (i != null) sink.AcceptIssue(i); } - ReaderWriterLockSlim filesystem = new ReaderWriterLockSlim(); + readonly ReaderWriterLockSlim filesystem = new(); /// /// Returns false if any copy of the file failed to delete. Doesn't reference failed directories @@ -70,7 +56,7 @@ public bool TryDelete(string filename) } catch (Exception e) { - sink.AcceptIssue(new Issue(this.issueSource, "Failed to delete " + dataKind + " at location " + path, e.ToString(), IssueSeverity.Warning)); + sink.AcceptIssue(new Issue(issueSource, "Failed to delete " + dataKind + " at location " + path, e.ToString(), IssueSeverity.Warning)); failedAtSomething = true; } } @@ -88,7 +74,7 @@ public bool TryDelete(string filename) /// /// /// - public bool TryDiskWrite(string filename, string value) + public bool TryDiskWrite(string filename, string? value) { if (value == null) { @@ -109,7 +95,7 @@ public bool TryDiskWrite(string filename, string value) } catch (Exception e) { - AddBadWriteLocation(dest, new Issue(this.issueSource, "Failed to create directory " + dest, e.ToString(), IssueSeverity.Warning)); + AddBadWriteLocation(dest, new Issue(issueSource, "Failed to create directory " + dest, e.ToString(), IssueSeverity.Warning)); } }else if (!Directory.Exists(dest)) { @@ -136,7 +122,7 @@ public bool TryDiskWrite(string filename, string value) } catch (Exception e) { - AddBadWriteLocation(dest, new Issue(this.issueSource, "Failed to write " + dataKind + " to location " + path, e.ToString(), IssueSeverity.Warning)); + AddBadWriteLocation(dest, new Issue(issueSource, "Failed to write " + dataKind + " to location " + path, e.ToString(), IssueSeverity.Warning)); } } } @@ -146,7 +132,7 @@ public bool TryDiskWrite(string filename, string value) { filesystem.ExitWriteLock(); } - sink.AcceptIssue(new Issue(this.issueSource,"Unable to cache " + dataKind + " to disk in any location.", null, IssueSeverity.Error)); + sink.AcceptIssue(new Issue(issueSource,"Unable to cache " + dataKind + " to disk in any location.", null, IssueSeverity.Error)); return false; } } @@ -156,7 +142,7 @@ public bool TryDiskWrite(string filename, string value) /// /// /// - public string TryDiskRead(string filename) + public string? TryDiskRead(string filename) { bool readFailed = false; //To tell non-existent files apart from I/O errors try @@ -174,7 +160,7 @@ public string TryDiskRead(string filename) catch (Exception e) { readFailed = true; - AddBadReadLocation(path, new Issue(this.issueSource, "Failed to read " + dataKind + " from location " + path, e.ToString(), IssueSeverity.Warning)); + AddBadReadLocation(path, new Issue(issueSource, "Failed to read " + dataKind + " from location " + path, e.ToString(), IssueSeverity.Warning)); } } } @@ -185,7 +171,7 @@ public string TryDiskRead(string filename) } if (readFailed) { - sink.AcceptIssue(new Issue(this.issueSource, "Unable to read " + dataKind + " from disk despite its existence.", null, IssueSeverity.Error)); + sink.AcceptIssue(new Issue(issueSource, "Unable to read " + dataKind + " from disk despite its existence.", null, IssueSeverity.Error)); } return null; } @@ -212,7 +198,7 @@ public string TryDiskRead(string filename) catch (Exception e) { readFailed = true; - AddBadReadLocation(path, new Issue(this.issueSource, "Failed to read write time of " + dataKind + " from location " + path, e.ToString(), IssueSeverity.Warning)); + AddBadReadLocation(path, new Issue(issueSource, "Failed to read write time of " + dataKind + " from location " + path, e.ToString(), IssueSeverity.Warning)); } } } @@ -223,7 +209,7 @@ public string TryDiskRead(string filename) } if (readFailed) { - sink.AcceptIssue(new Issue(this.issueSource, "Unable to read write time of " + dataKind + " from disk despite its existence.", null, IssueSeverity.Error)); + sink.AcceptIssue(new Issue(issueSource, "Unable to read write time of " + dataKind + " from disk despite its existence.", null, IssueSeverity.Error)); } return null; } diff --git a/src/Imazen.Common/Persistence/PersistentGlobalStringCache.cs b/src/Imazen.Common/Persistence/PersistentGlobalStringCache.cs index 87f756ce..472ad58c 100644 --- a/src/Imazen.Common/Persistence/PersistentGlobalStringCache.cs +++ b/src/Imazen.Common/Persistence/PersistentGlobalStringCache.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Text; using System.Collections.Concurrent; using Imazen.Common.Issues; using System.Security.Cryptography; @@ -17,15 +14,14 @@ namespace Imazen.Common.Persistence /// class WriteThroughCache : IIssueProvider { + private readonly string prefix = "resizer_key_"; + private readonly string sinkSource = "LicenseCache"; + private readonly string dataKind = "license"; - string prefix = "resizer_key_"; - string sinkSource = "LicenseCache"; - string dataKind = "license"; - - IIssueReceiver sink; - MultiFolderStorage store; - ConcurrentDictionary cache = new ConcurrentDictionary(); + private readonly IIssueReceiver sink; + private readonly MultiFolderStorage store; + private readonly ConcurrentDictionary cache = new(); /// /// Here's how the original version calculated candidateFolders. @@ -40,14 +36,14 @@ class WriteThroughCache : IIssueProvider /// /// /// - internal WriteThroughCache(string keyPrefix, string[] candidateFolders) + internal WriteThroughCache(string? keyPrefix, string[] candidateFolders) { prefix = keyPrefix ?? prefix; sink = new IssueSink(sinkSource); store = new MultiFolderStorage(sinkSource, dataKind, sink, candidateFolders, FolderOptions.Default); } - string hashToBase16(string data) + string HashToBase16(string data) { byte[] bytes = SHA256.Create().ComputeHash(new UTF8Encoding().GetBytes(data)); StringBuilder sb = new StringBuilder(bytes.Length * 2); @@ -61,7 +57,7 @@ string FilenameKeyFor(string key) { if (key.Any(c => !Char.IsLetterOrDigit(c) && c != '_') || key.Length + prefix.Length > 200) { - return this.prefix + hashToBase16(key) + ".txt"; + return this.prefix + HashToBase16(key) + ".txt"; } else { @@ -76,10 +72,9 @@ string FilenameKeyFor(string key) /// /// /// - internal StringCachePutResult TryPut(string key, string value) + internal StringCachePutResult TryPut(string key, string? value) { - string current; - if (cache.TryGetValue(key, out current) && current == value) + if (cache.TryGetValue(key, out var current) && current == value) { return StringCachePutResult.Duplicate; } @@ -92,21 +87,18 @@ internal StringCachePutResult TryPut(string key, string value) /// /// /// - internal string TryGet(string key) + internal string? TryGet(string key) { - string current; - if (cache.TryGetValue(key, out current)) + if (cache.TryGetValue(key, out var current)) { return current; - }else + } + var disk = store.TryDiskRead(FilenameKeyFor(key)); + if (disk != null) { - var disk = store.TryDiskRead(FilenameKeyFor(key)); - if (disk != null) - { - cache[key] = disk; - } - return disk; + cache[key] = disk; } + return disk; } @@ -126,23 +118,22 @@ public IEnumerable GetIssues() /// internal class PersistentGlobalStringCache : IPersistentStringCache, IIssueProvider { - private static WriteThroughCache _processCache; + private static WriteThroughCache? _processCache; - WriteThroughCache cache; + private readonly WriteThroughCache cache; public PersistentGlobalStringCache(string keyPrefix, string[] candidateFolders) { - if (_processCache == null) - _processCache = new WriteThroughCache(keyPrefix, candidateFolders); + _processCache ??= new WriteThroughCache(keyPrefix, candidateFolders); cache = _processCache; } - public string Get(string key) + public string? Get(string key) { return cache.TryGet(key); } - public StringCachePutResult TryPut(string key, string value) + public StringCachePutResult TryPut(string key, string? value) { return cache.TryPut(key, value); } diff --git a/src/Imazen.Common/Storage/BlobMissingException.cs b/src/Imazen.Common/Storage/BlobMissingException.cs index e5ac98f8..dd591eed 100644 --- a/src/Imazen.Common/Storage/BlobMissingException.cs +++ b/src/Imazen.Common/Storage/BlobMissingException.cs @@ -1,8 +1,7 @@ -using System; - namespace Imazen.Common.Storage { - public class BlobMissingException : Exception + [Obsolete("This type has moved to the Imazen.Abstractions.Blobs.LegacyProviders namespace.")] + public class BlobMissingException : Imazen.Abstractions.Blobs.LegacyProviders.BlobMissingException { public BlobMissingException() { diff --git a/src/Imazen.Common/Storage/Caching/BytesBlobData.cs b/src/Imazen.Common/Storage/Caching/BytesBlobData.cs deleted file mode 100644 index bfeeb5ff..00000000 --- a/src/Imazen.Common/Storage/Caching/BytesBlobData.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.IO; - -namespace Imazen.Common.Storage.Caching -{ - internal class BytesBlobData : IBlobData - { - public byte[] Bytes { get; private set; } - public BytesBlobData(byte[] bytes, DateTime? lastModifiedDateUtc = null) - { - Bytes = bytes; - LastModifiedDateUtc = lastModifiedDateUtc; - } - public bool? Exists => true; - - public DateTime? LastModifiedDateUtc { get; private set; } - - public void Dispose() - { - - } - - public Stream OpenRead() - { - return new MemoryStream(Bytes); - } - } -} \ No newline at end of file diff --git a/src/Imazen.Common/Storage/Caching/CacheBlobFetchResult.cs b/src/Imazen.Common/Storage/Caching/CacheBlobFetchResult.cs deleted file mode 100644 index f4072314..00000000 --- a/src/Imazen.Common/Storage/Caching/CacheBlobFetchResult.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Buffers; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Imazen.Common.Storage.Caching -{ - public class CacheBlobFetchResult : ICacheBlobFetchResult - { - public CacheBlobFetchResult(ICacheBlobData data, bool dataExists, int statusCode, string statusMessage) - { - Data = data; - DataExists = dataExists; - StatusCode = statusCode; - StatusMessage = statusMessage; - } - - public ICacheBlobData Data { get; private set; } - public bool DataExists { get; private set; } - - public int StatusCode { get; private set; } - public string StatusMessage { get; private set; } - public void Dispose() - { - if (Data != null) - { - Data.Dispose(); - Data = null; - } - } - - - - - - - } -} diff --git a/src/Imazen.Common/Storage/Caching/CacheBlobPutOptions.cs b/src/Imazen.Common/Storage/Caching/CacheBlobPutOptions.cs deleted file mode 100644 index 04675336..00000000 --- a/src/Imazen.Common/Storage/Caching/CacheBlobPutOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Imazen.Common.Storage.Caching -{ - public class CacheBlobPutOptions : ICacheBlobPutOptions - { - public CacheBlobPutOptions() - { - } - - public static CacheBlobPutOptions Default { get; } = new CacheBlobPutOptions(); - } -} diff --git a/src/Imazen.Common/Storage/Caching/CacheBlobPutResult.cs b/src/Imazen.Common/Storage/Caching/CacheBlobPutResult.cs deleted file mode 100644 index b31ac15d..00000000 --- a/src/Imazen.Common/Storage/Caching/CacheBlobPutResult.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Imazen.Common.Storage.Caching -{ - public class CacheBlobPutResult : ICacheBlobPutResult{ - public CacheBlobPutResult(int statusCode, string statusMessage, bool success) - { - StatusCode = statusCode; - StatusMessage = statusMessage; - Success = success; - } - public int StatusCode {get; private set;} - public string StatusMessage {get;private set;} - public bool Success {get;private set;} - - } -} diff --git a/src/Imazen.Common/Storage/Caching/IBlobCache.cs b/src/Imazen.Common/Storage/Caching/IBlobCache.cs deleted file mode 100644 index 3282abe4..00000000 --- a/src/Imazen.Common/Storage/Caching/IBlobCache.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace Imazen.Common.Storage.Caching -{ - public interface IBlobCache - { - Task Put(BlobGroup group, string key, IBlobData data, ICacheBlobPutOptions options, CancellationToken cancellationToken = default); - - Task MayExist(BlobGroup group, string key, CancellationToken cancellationToken = default); - - Task TryFetchBlob(BlobGroup group, string key, CancellationToken cancellationToken = default); - - } -} \ No newline at end of file diff --git a/src/Imazen.Common/Storage/Caching/IBlobCacheProvider.cs b/src/Imazen.Common/Storage/Caching/IBlobCacheProvider.cs deleted file mode 100644 index 6dbde6a9..00000000 --- a/src/Imazen.Common/Storage/Caching/IBlobCacheProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; - -namespace Imazen.Common.Storage.Caching -{ - - public interface IBlobCacheProvider - { - bool TryGetCache(string name, out IBlobCache cache); - IEnumerable GetCacheNames(); - } -} diff --git a/src/Imazen.Common/Storage/Caching/IBlobCacheWithRenwals.cs b/src/Imazen.Common/Storage/Caching/IBlobCacheWithRenwals.cs deleted file mode 100644 index 5d6dae99..00000000 --- a/src/Imazen.Common/Storage/Caching/IBlobCacheWithRenwals.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace Imazen.Common.Storage.Caching -{ - /// - /// Useful if your blob storage provider requires manual renewal of blobs to keep them from being evicted. Use with ICacheBlobDataExpiry. - /// - public interface IBlobCacheWithRenewals : IBlobCache - { - Task TryRenew(BlobGroup group, string key, CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/src/Imazen.Common/Storage/Caching/ICacheBlobData.cs b/src/Imazen.Common/Storage/Caching/ICacheBlobData.cs deleted file mode 100644 index 0968909f..00000000 --- a/src/Imazen.Common/Storage/Caching/ICacheBlobData.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Imazen.Common.Storage.Caching -{ - public interface ICacheBlobData : IBlobData - { - // TODO: may add metadata - } -} diff --git a/src/Imazen.Common/Storage/Caching/ICacheBlobDataExpiry.cs b/src/Imazen.Common/Storage/Caching/ICacheBlobDataExpiry.cs deleted file mode 100644 index 05b4d10a..00000000 --- a/src/Imazen.Common/Storage/Caching/ICacheBlobDataExpiry.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace Imazen.Common.Storage.Caching -{ - /// - /// Useful if your blob storage provider requires manual renewal of blobs to keep them from being evicted. Use with IBlobCacheWithRenewals. - /// - public interface ICacheBlobDataExpiry - { - DateTimeOffset? EstimatedExpiry { get; } - } -} diff --git a/src/Imazen.Common/Storage/Caching/ICacheBlobFetchResult.cs b/src/Imazen.Common/Storage/Caching/ICacheBlobFetchResult.cs deleted file mode 100644 index 0a591738..00000000 --- a/src/Imazen.Common/Storage/Caching/ICacheBlobFetchResult.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace Imazen.Common.Storage.Caching -{ - public interface ICacheBlobFetchResult: IDisposable - { - // We also need to include hints so we know the original request URI/key/bucket - - ICacheBlobData Data { get; } - bool DataExists { get; } - - int StatusCode { get; } - // HTTP Status Message - string StatusMessage { get; } - - } -} diff --git a/src/Imazen.Common/Storage/Caching/ICacheBlobPutOptions.cs b/src/Imazen.Common/Storage/Caching/ICacheBlobPutOptions.cs deleted file mode 100644 index 62c97891..00000000 --- a/src/Imazen.Common/Storage/Caching/ICacheBlobPutOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System; - -namespace Imazen.Common.Storage.Caching -{ - public interface ICacheBlobPutOptions - { - } -} diff --git a/src/Imazen.Common/Storage/Caching/ICacheBlobPutResult.cs b/src/Imazen.Common/Storage/Caching/ICacheBlobPutResult.cs deleted file mode 100644 index 152d17a1..00000000 --- a/src/Imazen.Common/Storage/Caching/ICacheBlobPutResult.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; - -namespace Imazen.Common.Storage.Caching -{ - public interface ICacheBlobPutResult - { - // HTTP Code - int StatusCode { get; } - // HTTP Status Message - string StatusMessage { get; } - - bool Success { get; } - - // List the full set of properties that can be returned from a Put operation on Azure and S3 - // https://docs.microsoft.com/en-us/rest/api/storageservices/put-blob - // https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPUT.html - - // Which result codes exist? List them below in a comment - // 200 OK - // 201 Created - // 202 Accepted - // 203 Non-Authoritative Information (since HTTP/1.1) - // 204 No Content - // 205 Reset Content - // 206 Partial Content (RFC 7233) - // 207 Multi-Status (WebDAV; RFC 4918) - - } -} diff --git a/src/Imazen.Common/Storage/IBlobData.cs b/src/Imazen.Common/Storage/IBlobData.cs index b25afc6d..91f0d516 100644 --- a/src/Imazen.Common/Storage/IBlobData.cs +++ b/src/Imazen.Common/Storage/IBlobData.cs @@ -1,10 +1,6 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; - -namespace Imazen.Common.Storage +namespace Imazen.Common.Storage { + [Obsolete("Use Imazen.Abstractions.Blobs.BlobWrapper instead")] public interface IBlobData : IDisposable { bool? Exists { get; } diff --git a/src/Imazen.Common/Storage/IBlobProvider.cs b/src/Imazen.Common/Storage/IBlobProvider.cs index 9973e481..bc8b6987 100644 --- a/src/Imazen.Common/Storage/IBlobProvider.cs +++ b/src/Imazen.Common/Storage/IBlobProvider.cs @@ -1,8 +1,6 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Imazen.Common.Storage +namespace Imazen.Common.Storage { + [Obsolete("Use Imazen.Abstractions.Blobs.LegacyProviders.IBlobWrapperProvider instead")] public interface IBlobProvider { IEnumerable GetPrefixes(); diff --git a/src/Imazen.Common/Storage/INamedBlobProvider.cs b/src/Imazen.Common/Storage/INamedBlobProvider.cs new file mode 100644 index 00000000..b716b194 --- /dev/null +++ b/src/Imazen.Common/Storage/INamedBlobProvider.cs @@ -0,0 +1,10 @@ +using Imazen.Abstractions; + +namespace Imazen.Common.Storage +{ + [Obsolete("This type has moved to the Imazen.Abstractions.Blobs.LegacyProviders namespace.")] + public interface INamedBlobProvider : IBlobProvider, IUniqueNamed + { + + } +} \ No newline at end of file diff --git a/src/Imazen.Common/VisibleToTests.cs b/src/Imazen.Common/VisibleToTests.cs index 435e1026..618177b7 100644 --- a/src/Imazen.Common/VisibleToTests.cs +++ b/src/Imazen.Common/VisibleToTests.cs @@ -1,7 +1,9 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Imazen.Common.Tests")] -[assembly: InternalsVisibleTo("Imageflow.Server")] +[assembly: InternalsVisibleTo("ImazenShared.Tests")] +[assembly: InternalsVisibleTo("Imazen.Routing")] [assembly: InternalsVisibleTo("Imageflow.Server.Tests")] [assembly: InternalsVisibleTo("ImageResizer")] -[assembly: InternalsVisibleTo("ImageResizer.LicensingTests")] \ No newline at end of file +[assembly: InternalsVisibleTo("ImageResizer.LicensingTests")] +[assembly: InternalsVisibleTo("ImageResizer.HybridCache.Tests")] +[assembly: InternalsVisibleTo("ImageResizer.HybridCache.Benchmark")] diff --git a/src/Imazen.Common/packages.lock.json b/src/Imazen.Common/packages.lock.json index 931e92c2..2e38a996 100644 --- a/src/Imazen.Common/packages.lock.json +++ b/src/Imazen.Common/packages.lock.json @@ -1,7 +1,7 @@ { "version": 1, "dependencies": { - ".NETFramework,Version=v4.7.2": { + ".NETStandard,Version=v2.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", "requested": "[3.3.4, )", @@ -20,29 +20,37 @@ "Microsoft.Extensions.Logging.Abstractions": "2.2.0" } }, - "Microsoft.NETFramework.ReferenceAssemblies": { + "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.0.3, )", - "resolved": "1.0.3", - "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.NETFramework.ReferenceAssemblies.net472": "1.0.3" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, - "Microsoft.SourceLink.GitHub": { + "NETStandard.Library": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" } }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -79,29 +87,29 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, - "Microsoft.NETFramework.ReferenceAssemblies.net472": { + "Microsoft.NETCore.Platforms": { "type": "Transitive", - "resolved": "1.0.3", - "contentHash": "0E7evZXHXaDYYiLRfpyXvCh+yzM2rNTyuZDI+ZO7UUqSc6GfjePiXTdqJGtgIKUwdI81tzQKmaWprnUiPj9hAw==" + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, "System.Buffers": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "AwarXzzoDwX6BgrhjoJsk6tUezZEozOT5Y9QKF94Gl4JK91I4PIIBkBco9068Y9/Dra8Dkbie99kXB8+1BaYKw==" + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" }, "System.Memory": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", "dependencies": { - "System.Buffers": "4.4.0", + "System.Buffers": "4.5.1", "System.Numerics.Vectors": "4.4.0", - "System.Runtime.CompilerServices.Unsafe": "4.5.0" + "System.Runtime.CompilerServices.Unsafe": "4.5.3" } }, "System.Numerics.Vectors": { @@ -111,11 +119,40 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } } }, - ".NETStandard,Version=v2.0": { + "net6.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", "requested": "[3.3.4, )", @@ -136,27 +173,18 @@ }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" - } - }, - "NETStandard.Library": { - "type": "Direct", - "requested": "[2.0.3, )", - "resolved": "2.0.3", - "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -193,40 +221,141 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, - "Microsoft.NETCore.Platforms": { + "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, - "Microsoft.SourceLink.Common": { + "System.Memory": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" }, - "System.Buffers": { + "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "AwarXzzoDwX6BgrhjoJsk6tUezZEozOT5Y9QKF94Gl4JK91I4PIIBkBco9068Y9/Dra8Dkbie99kXB8+1BaYKw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" }, - "System.Memory": { + "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", "dependencies": { - "System.Buffers": "4.4.0", - "System.Numerics.Vectors": "4.4.0", - "System.Runtime.CompilerServices.Unsafe": "4.5.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.Numerics.Vectors": { + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, - "System.Runtime.CompilerServices.Unsafe": { + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Memory": { "type": "Transitive", "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } } } } diff --git a/src/Imazen.DiskCache/Imazen.DiskCache.csproj b/src/Imazen.DiskCache/Imazen.DiskCache.csproj index c461d4d2..4e0a4cd8 100644 --- a/src/Imazen.DiskCache/Imazen.DiskCache.csproj +++ b/src/Imazen.DiskCache/Imazen.DiskCache.csproj @@ -3,7 +3,7 @@ true Imageflow.DiskCache - ClassicDiskCache implementation. - net472;netstandard2.0 + net8.0;netstandard2.0;net6.0 diff --git a/src/Imazen.DiskCache/packages.lock.json b/src/Imazen.DiskCache/packages.lock.json index a6f230ca..b5e75cbb 100644 --- a/src/Imazen.DiskCache/packages.lock.json +++ b/src/Imazen.DiskCache/packages.lock.json @@ -1,7 +1,7 @@ { "version": 1, "dependencies": { - ".NETFramework,Version=v4.7.2": { + ".NETStandard,Version=v2.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", "requested": "[3.3.4, )", @@ -14,29 +14,37 @@ "resolved": "3.1.3", "contentHash": "j6r0E+OVinD4s13CIZASYJLLLApStb1yh5Vig7moB2FE1UsMRj4TYJ/xioDjreVA0dyOFpbWny1/n2iSJMbmNg==" }, - "Microsoft.NETFramework.ReferenceAssemblies": { + "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.0.3, )", - "resolved": "1.0.3", - "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.NETFramework.ReferenceAssemblies.net472": "1.0.3" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, - "Microsoft.SourceLink.GitHub": { + "NETStandard.Library": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "System.Threading.Tasks.Extensions": "4.5.4" } }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -79,29 +87,29 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, - "Microsoft.NETFramework.ReferenceAssemblies.net472": { + "Microsoft.NETCore.Platforms": { "type": "Transitive", - "resolved": "1.0.3", - "contentHash": "0E7evZXHXaDYYiLRfpyXvCh+yzM2rNTyuZDI+ZO7UUqSc6GfjePiXTdqJGtgIKUwdI81tzQKmaWprnUiPj9hAw==" + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, "System.Buffers": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "AwarXzzoDwX6BgrhjoJsk6tUezZEozOT5Y9QKF94Gl4JK91I4PIIBkBco9068Y9/Dra8Dkbie99kXB8+1BaYKw==" + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" }, "System.Memory": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", "dependencies": { - "System.Buffers": "4.4.0", + "System.Buffers": "4.5.1", "System.Numerics.Vectors": "4.4.0", - "System.Runtime.CompilerServices.Unsafe": "4.5.0" + "System.Runtime.CompilerServices.Unsafe": "4.5.3" } }, "System.Numerics.Vectors": { @@ -111,17 +119,47 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } } }, - ".NETStandard,Version=v2.0": { + "net6.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", "requested": "[3.3.4, )", @@ -136,27 +174,18 @@ }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" - } - }, - "NETStandard.Library": { - "type": "Direct", - "requested": "[2.0.3, )", - "resolved": "2.0.3", - "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -199,44 +228,147 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, - "Microsoft.NETCore.Platforms": { + "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, - "Microsoft.SourceLink.Common": { + "System.Memory": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" }, - "System.Buffers": { + "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "AwarXzzoDwX6BgrhjoJsk6tUezZEozOT5Y9QKF94Gl4JK91I4PIIBkBco9068Y9/Dra8Dkbie99kXB8+1BaYKw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" }, - "System.Memory": { + "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", "dependencies": { - "System.Buffers": "4.4.0", - "System.Numerics.Vectors": "4.4.0", - "System.Runtime.CompilerServices.Unsafe": "4.5.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.Numerics.Vectors": { + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Direct", + "requested": "[3.1.3, )", + "resolved": "3.1.3", + "contentHash": "j6r0E+OVinD4s13CIZASYJLLLApStb1yh5Vig7moB2FE1UsMRj4TYJ/xioDjreVA0dyOFpbWny1/n2iSJMbmNg==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, - "System.Runtime.CompilerServices.Unsafe": { + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Memory": { "type": "Transitive", "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } } diff --git a/src/Imazen.HybridCache/AsyncCache.cs b/src/Imazen.HybridCache/AsyncCache.cs index 7d072486..e4618070 100644 --- a/src/Imazen.HybridCache/AsyncCache.cs +++ b/src/Imazen.HybridCache/AsyncCache.cs @@ -1,18 +1,18 @@ -using System; using System.Diagnostics; using System.Globalization; -using System.IO; -using System.Threading; -using System.Threading.Tasks; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; using Imazen.Common.Concurrency; -using Imazen.Common.Extensibility.StreamCache; +using Imazen.Common.Extensibility.Support; +using Imazen.HybridCache.MetaStore; using Microsoft.Extensions.Logging; namespace Imazen.HybridCache { - internal class AsyncCache + internal class AsyncCache : IBlobCache { - public enum AsyncCacheDetailResult + private enum AsyncCacheDetailResult { Unknown = 0, MemoryHit, @@ -27,65 +27,86 @@ public enum AsyncCacheDetailResult EvictAndWriteLockTimedOut, ContendedDiskHit } - public class AsyncCacheResult : IStreamCacheResult - { - public Stream Data { get; set; } - public string ContentType { get; set; } - public DateTime? CreatedAt { get; set; } - public string Status => Detail.ToString(); - - public AsyncCacheDetailResult Detail { get; set; } - } - - public AsyncCache(AsyncCacheOptions options, ICacheCleanupManager cleanupManager,HashBasedPathBuilder pathBuilder, ILogger logger) + public string UniqueName { get; } + + public AsyncCache(AsyncCacheOptions options, ICacheCleanupManager cleanupManager, + ICacheDatabase database, HashBasedPathBuilder pathBuilder, ILogger logger) { + UniqueName = options.UniqueName; + Database = database; Options = options; PathBuilder = pathBuilder; CleanupManager = cleanupManager; Logger = logger; FileWriteLocks = new AsyncLockProvider(); - QueueLocks = new AsyncLockProvider(); + //QueueLocks = new AsyncLockProvider(); EvictAndWriteLocks = new AsyncLockProvider(); - CurrentWrites = new AsyncWriteCollection(options.MaxQueuedBytes); + // CurrentWrites = new BoundedTaskCollection(options.MaxQueuedBytes); FileWriter = new CacheFileWriter(FileWriteLocks, Options.MoveFileOverwriteFunc, Options.MoveFilesIntoPlace); + InitialCacheCapabilities = new BlobCacheCapabilities + { + CanConditionalFetch = false, + CanConditionalPut = false, + CanFetchData = true, + CanFetchMetadata = true, + CanPurgeByTag = true, + CanSearchByTag = true, + CanDelete = true, + CanPut = true, + CanReceiveEvents = true, + SupportsHealthCheck = true, + SubscribesToExternalHits = true, + SubscribesToRecentRequest = true, + FixedSize = true, + SubscribesToFreshResults = true, + RequiresInlineExecution = false + }; + LatencyZone = new LatencyTrackingZone($"Hybrid Disk Cache ('{UniqueName}')", 30); } + ICacheDatabase Database { get; } + + private LatencyTrackingZone LatencyZone { get; set; } private AsyncCacheOptions Options { get; } private HashBasedPathBuilder PathBuilder { get; } private ILogger Logger { get; } private ICacheCleanupManager CleanupManager { get; } - + /// - /// Provides string-based locking for file write access. + /// Provides string-based locking for file write access. Note: this doesn't lock on relativepath in filewriter! /// - private AsyncLockProvider FileWriteLocks {get; } - - - private AsyncLockProvider EvictAndWriteLocks {get; } - + private AsyncLockProvider FileWriteLocks { get; } + + // TODO: carefully review all these locks to ensure the key construction mechanism is the same on all + private AsyncLockProvider EvictAndWriteLocks { get; } + private CacheFileWriter FileWriter { get; } - /// - /// Provides string-based locking for image resizing (not writing, just processing). Prevents duplication of efforts in asynchronous mode, where 'Locks' is not being used. - /// - private AsyncLockProvider QueueLocks { get; } + // /// + // /// Provides string-based locking for image resizing (not writing, just processing). Prevents duplication of efforts in asynchronous mode, where 'Locks' is not being used. + // /// + // [Obsolete] + // private AsyncLockProvider QueueLocks { get; } /// /// Contains all the queued and in-progress writes to the cache. /// - private AsyncWriteCollection CurrentWrites {get; } + // [Obsolete] + // private BoundedTaskCollection CurrentWrites {get; } public Task AwaitEnqueuedTasks() { - return CurrentWrites.AwaitAllCurrentTasks(); + //return CurrentWrites.AwaitAllCurrentTasks(); + return Task.CompletedTask; } - - private static bool IsFileLocked(IOException exception) { + + private static bool IsFileLocked(IOException exception) + { //For linux const int linuxEAgain = 11; const int linuxEBusy = 16; @@ -94,15 +115,17 @@ private static bool IsFileLocked(IOException exception) { { return true; } + //For windows // See https://docs.microsoft.com/en-us/dotnet/standard/io/handling-io-errors - const int errorSharingViolation = 0x20; + const int errorSharingViolation = 0x20; const int errorLockViolation = 0x21; - var errorCode = exception.HResult & 0x0000FFFF; + var errorCode = exception.HResult & 0x0000FFFF; return errorCode == errorSharingViolation || errorCode == errorLockViolation; } - private async Task TryWaitForLockedFile(string physicalPath, Stopwatch waitTime, int timeoutMs, CancellationToken cancellationToken) + private async Task TryWaitForLockedFile(string physicalPath, Stopwatch waitTime, int timeoutMs, + CancellationToken cancellationToken) { var waitForFile = waitTime; waitTime.Stop(); @@ -115,7 +138,8 @@ private async Task TryWaitForLockedFile(string physicalPath, Stopwat FileOptions.Asynchronous | FileOptions.SequentialScan); waitForFile.Stop(); - Logger?.LogInformation("Cache file locked, waited {WaitTime} to read {Path}", waitForFile.Elapsed, physicalPath); + Logger?.LogInformation("Cache file locked, waited {WaitTime} to read {Path}", waitForFile.Elapsed, + physicalPath); return fs; } catch (FileNotFoundException) @@ -128,47 +152,45 @@ private async Task TryWaitForLockedFile(string physicalPath, Stopwat } catch (UnauthorizedAccessException) { - + } - await Task.Delay((int)Math.Min(15, Math.Round(timeoutMs / 3.0)), cancellationToken); + + await Task.Delay((int)Math.Min(15, Math.Round(timeoutMs / 3.0)), cancellationToken); waitForFile.Stop(); } return null; } - private async Task TryWaitForLockedFile(CacheEntry entry, ICacheDatabaseRecord record, CancellationToken cancellationToken) + private async Task?> TryWaitForLockedFile(CacheEntry entry, + ICacheDatabaseRecord? record, CancellationToken cancellationToken) { - FileStream openedStream = null; + FileStream? openedStream = null; var waitTime = Stopwatch.StartNew(); - if (!await FileWriteLocks.TryExecuteAsync(entry.StringKey, Options.WaitForIdenticalDiskWritesMs, - cancellationToken, async () => - { - openedStream = await TryWaitForLockedFile(entry.PhysicalPath, waitTime, - Options.WaitForIdenticalDiskWritesMs, cancellationToken); - })) + if (!await FileWriteLocks.TryExecuteAsync(entry.HashString, Options.WaitForIdenticalDiskWritesMs, + cancellationToken, async () => + { + openedStream = await TryWaitForLockedFile(entry.PhysicalPath, waitTime, + Options.WaitForIdenticalDiskWritesMs, cancellationToken); + })) { return null; } if (openedStream != null) { - return new AsyncCacheResult - { - Detail = AsyncCacheDetailResult.ContendedDiskHit, - ContentType = record?.ContentType, - CreatedAt = record?.CreatedAt, - Data = openedStream - }; + //TODO: add contended hit detail + return AsyncCacheResult.FromHit(record, entry.RelativePath, PathBuilder, openedStream, LatencyZone, this, this); } return null; } - private async Task TryGetFileBasedResult(CacheEntry entry, bool waitForFile, bool retrieveAttributes, CancellationToken cancellationToken) + private async Task?> TryGetFileBasedResult(CacheEntry entry, + bool waitForFile, bool retrieveAttributes, CancellationToken cancellationToken) { if (!File.Exists(entry.PhysicalPath)) return null; - + if (cancellationToken.IsCancellationRequested) throw new OperationCanceledException(cancellationToken); @@ -181,14 +203,10 @@ private async Task TryGetFileBasedResult(CacheEntry entry, boo try { - return new AsyncCacheResult - { - Detail = AsyncCacheDetailResult.DiskHit, - ContentType = record?.ContentType, - CreatedAt = record?.CreatedAt, - Data = new FileStream(entry.PhysicalPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, - FileOptions.Asynchronous | FileOptions.SequentialScan) - }; + return AsyncCacheResult.FromHit(record, entry.RelativePath, PathBuilder, new FileStream( + entry.PhysicalPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan), LatencyZone, this, this); + } catch (FileNotFoundException) { @@ -204,7 +222,7 @@ private async Task TryGetFileBasedResult(CacheEntry entry, boo catch (IOException ioException) { if (!waitForFile) return null; - + if (IsFileLocked(ioException)) { return await TryWaitForLockedFile(entry, record, cancellationToken); @@ -215,260 +233,545 @@ private async Task TryGetFileBasedResult(CacheEntry entry, boo } } } - - /// - /// Tries to fetch the result from disk cache, the memory queue, or create it. If the memory queue has space, - /// the writeCallback() will be executed and the resulting bytes put in a queue for writing to disk. - /// If the memory queue is full, writing to disk will be attempted synchronously. - /// In either case, writing to disk can also fail if the disk cache is full and eviction fails. - /// If the memory queue is full, eviction will be done synchronously and can cause other threads to time out - /// while waiting for QueueLock - /// - /// - /// - /// - /// - /// - /// - /// - public async Task GetOrCreateBytes( - byte[] key, - AsyncBytesResult dataProviderCallback, - CancellationToken cancellationToken, - bool retrieveContentType) + + // /// + // /// Tries to fetch the result from disk cache, the memory queue, or create it. If the memory queue has space, + // /// the writeCallback() will be executed and the resulting bytes put in a queue for writing to disk. + // /// If the memory queue is full, writing to disk will be attempted synchronously. + // /// In either case, writing to disk can also fail if the disk cache is full and eviction fails. + // /// If the memory queue is full, eviction will be done synchronously and can cause other threads to time out + // /// while waiting for QueueLock + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // [Obsolete] + // public async Task GetOrCreateBytes( + // byte[] key, + // AsyncBytesResult dataProviderCallback, + // CancellationToken cancellationToken, + // bool retrieveContentType) + // { + // if (cancellationToken.IsCancellationRequested) + // throw new OperationCanceledException(cancellationToken); + // + // var swGetOrCreateBytes = Stopwatch.StartNew(); + // var entry = new CacheEntry(key, PathBuilder); + // + // // Tell cleanup what we're using + // CleanupManager.NotifyUsed(entry); + // + // // Fast path on disk hit + // var swFileExists = Stopwatch.StartNew(); + // + // var fileBasedResult = await TryGetFileBasedResult(entry, false, retrieveContentType, cancellationToken); + // if (fileBasedResult != null) + // { + // return fileBasedResult; + // } + // // Just continue on creating the file. It must have been deleted between the calls + // + // swFileExists.Stop(); + // + // + // + // var cacheResult = new AsyncCacheResult(); + // + // //Looks like a miss. Let's enter a lock for the creation of the file. This is a different locking system + // // than for writing to the file + // //This prevents two identical requests from duplicating efforts. Different requests don't lock. + // + // //Lock execution using relativePath as the sync basis. Ignore casing differences. This prevents duplicate entries in the write queue and wasted CPU/RAM usage. + // var queueLockComplete = await QueueLocks.TryExecuteAsync(entry.StringKey, + // Options.WaitForIdenticalRequestsTimeoutMs, cancellationToken, + // async () => + // { + // var swInsideQueueLock = Stopwatch.StartNew(); + // + // // Now, if the item we seek is in the queue, we have a memcached hit. + // // If not, we should check the filesystem. It's possible the item has been written to disk already. + // // If both are a miss, we should see if there is enough room in the write queue. + // // If not, switch to in-thread writing. + // + // var existingQueuedWrite = CurrentWrites.Get(entry.StringKey); + // + // if (existingQueuedWrite != null) + // { + // cacheResult.Data = existingQueuedWrite.GetReadonlyStream(); + // cacheResult.CreatedAt = null; // Hasn't been written yet + // cacheResult.ContentType = existingQueuedWrite.ContentType; + // cacheResult.Detail = AsyncCacheDetailResult.MemoryHit; + // return; + // } + // + // if (cancellationToken.IsCancellationRequested) + // throw new OperationCanceledException(cancellationToken); + // + // swFileExists.Start(); + // // Fast path on disk hit, now that we're in a synchronized state + // var fileBasedResult2 = await TryGetFileBasedResult(entry, true, retrieveContentType, cancellationToken); + // if (fileBasedResult2 != null) + // { + // cacheResult = fileBasedResult2; + // return; + // } + // // Just continue on creating the file. It must have been deleted between the calls + // + // swFileExists.Stop(); + // + // var swDataCreation = Stopwatch.StartNew(); + // //Read, resize, process, and encode the image. Lots of exceptions thrown here. + // var result = await dataProviderCallback(cancellationToken); + // swDataCreation.Stop(); + // + // //Create AsyncWrite object to enqueue + // var w = new BlobTaskItem(entry.StringKey, result.Bytes, result.ContentType); + // + // cacheResult.Detail = AsyncCacheDetailResult.Miss; + // cacheResult.ContentType = w.ContentType; + // cacheResult.CreatedAt = null; // Hasn't been written yet. + // cacheResult.Data = w.GetReadonlyStream(); + // + // // Create a lambda which we can call either in a spawned Task (if enqueued successfully), or + // // in this task, if our buffer is full. + // async Task EvictWriteAndLogUnsynchronized(bool queueFull, TimeSpan dataCreationTime, CancellationToken ct) + // { + // var delegateStartedAt = DateTime.UtcNow; + // var swReserveSpace = Stopwatch.StartNew(); + // //We only permit eviction proceedings from within the queue or if the queue is disabled + // var allowEviction = !queueFull || CurrentWrites.MaxQueueBytes <= 0; + // var reserveSpaceResult = await CleanupManager.TryReserveSpace(entry, w.ContentType, + // w.GetUsedBytes(), allowEviction, EvictAndWriteLocks, ct); + // swReserveSpace.Stop(); + // + // var syncString = queueFull ? "synchronous" : "async"; + // if (!reserveSpaceResult.Success) + // { + // Logger?.LogError( + // queueFull + // ? "HybridCache synchronous eviction failed; {Message}. Time taken: {1}ms - {2}" + // : "HybridCache async eviction failed; {Message}. Time taken: {1}ms - {2}", + // syncString, reserveSpaceResult.Message, swReserveSpace.ElapsedMilliseconds, + // entry.RelativePath); + // + // return AsyncCacheDetailResult.CacheEvictionFailed; + // } + // + // var swIo = Stopwatch.StartNew(); + // // We only force an immediate File.Exists check when running from the Queue + // // Otherwise it happens inside the lock + // var fileWriteResult = await FileWriter.TryWriteFile(entry, delegate(Stream s, CancellationToken ct2) + // { + // if (ct2.IsCancellationRequested) throw new OperationCanceledException(ct2); + // + // var fromStream = w.GetReadonlyStream(); + // return fromStream.CopyToAsync(s, 81920, ct2); + // }, !queueFull, Options.WaitForIdenticalDiskWritesMs, ct); + // swIo.Stop(); + // + // var swMarkCreated = Stopwatch.StartNew(); + // // Mark the file as created so it can be deleted + // await CleanupManager.MarkFileCreated(entry, + // w.ContentType, + // w.GetUsedBytes(), + // DateTime.UtcNow); + // swMarkCreated.Stop(); + // + // switch (fileWriteResult) + // { + // case CacheFileWriter.FileWriteStatus.LockTimeout: + // //We failed to lock the file. + // Logger?.LogWarning("HybridCache {Sync} write failed; disk lock timeout exceeded after {IoTime}ms - {Path}", + // syncString, swIo.ElapsedMilliseconds, entry.RelativePath); + // return AsyncCacheDetailResult.WriteTimedOut; + // case CacheFileWriter.FileWriteStatus.FileAlreadyExists: + // Logger?.LogTrace("HybridCache {Sync} write found file already exists in {IoTime}ms, after a {DelayTime}ms delay and {CreationTime}- {Path}", + // syncString, swIo.ElapsedMilliseconds, + // delegateStartedAt.Subtract(w.JobCreatedAt).TotalMilliseconds, + // dataCreationTime, entry.RelativePath); + // return AsyncCacheDetailResult.FileAlreadyExists; + // case CacheFileWriter.FileWriteStatus.FileCreated: + // if (queueFull) + // { + // Logger?.LogTrace(@"HybridCache synchronous write complete. Create: {CreateTime}ms. Write {WriteTime}ms. Mark Created: {MarkCreatedTime}ms. Eviction: {EvictionTime}ms - {Path}", + // Math.Round(dataCreationTime.TotalMilliseconds).ToString(CultureInfo.InvariantCulture).PadLeft(4), + // swIo.ElapsedMilliseconds.ToString().PadLeft(4), + // swMarkCreated.ElapsedMilliseconds.ToString().PadLeft(4), + // swReserveSpace.ElapsedMilliseconds.ToString().PadLeft(4), entry.RelativePath); + // } + // else + // { + // Logger?.LogTrace(@"HybridCache async write complete. Create: {CreateTime}ms. Write {WriteTime}ms. Mark Created: {MarkCreatedTime}ms Eviction {EvictionTime}ms. Delay {DelayTime}ms. - {Path}", + // Math.Round(dataCreationTime.TotalMilliseconds).ToString(CultureInfo.InvariantCulture).PadLeft(4), + // swIo.ElapsedMilliseconds.ToString().PadLeft(4), + // swMarkCreated.ElapsedMilliseconds.ToString().PadLeft(4), + // swReserveSpace.ElapsedMilliseconds.ToString().PadLeft(4), + // Math.Round(delegateStartedAt.Subtract(w.JobCreatedAt).TotalMilliseconds).ToString(CultureInfo.InvariantCulture).PadLeft(4), + // entry.RelativePath); + // } + // + // return AsyncCacheDetailResult.WriteSucceeded; + // default: + // throw new ArgumentOutOfRangeException(); + // } + // } + // + // async Task EvictWriteAndLogSynchronized(bool queueFull, + // TimeSpan dataCreationTime, CancellationToken ct) + // { + // var cacheDetailResult = AsyncCacheDetailResult.Unknown; + // var writeLockComplete = await EvictAndWriteLocks.TryExecuteAsync(entry.StringKey, + // Options.WaitForIdenticalRequestsTimeoutMs, cancellationToken, + // async () => + // { + // cacheDetailResult = + // await EvictWriteAndLogUnsynchronized(queueFull, dataCreationTime, ct); + // }); + // if (!writeLockComplete) + // { + // cacheDetailResult = AsyncCacheDetailResult.EvictAndWriteLockTimedOut; + // } + // + // return cacheDetailResult; + // } + // + // + // var swEnqueue = Stopwatch.StartNew(); + // var queueResult = CurrentWrites.Queue(w, async delegate + // { + // try + // { + // var unused = await EvictWriteAndLogSynchronized(false, swDataCreation.Elapsed, CancellationToken.None); + // } + // catch (Exception ex) + // { + // Logger?.LogError(ex, "HybridCache failed to flush async write, {Exception} {Path}\n{StackTrace}", ex.ToString(), + // entry.RelativePath, ex.StackTrace); + // } + // + // }); + // swEnqueue.Stop(); + // swInsideQueueLock.Stop(); + // swGetOrCreateBytes.Stop(); + // + // // if (queueResult == BoundedTaskCollection.EnqueueResult.QueueFull) + // // { + // // if (Options.WriteSynchronouslyWhenQueueFull) + // // { + // // var writerDelegateResult = await EvictWriteAndLogSynchronized(true, swDataCreation.Elapsed, cancellationToken); + // // cacheResult.Detail = writerDelegateResult; + // // } + // // } + // }); + // if (!queueLockComplete) + // { + // //On queue lock failure + // if (!Options.FailRequestsOnEnqueueLockTimeout) + // { + // // We run the callback with no intent of caching + // var cacheInputEntry = await dataProviderCallback(cancellationToken); + // + // cacheResult.Detail = AsyncCacheDetailResult.QueueLockTimeoutAndCreated; + // cacheResult.ContentType = cacheInputEntry.ContentType; + // cacheResult.CreatedAt = null; //Hasn't been written yet + // + // cacheResult.Data = new MemoryStream(cacheInputEntry.Bytes.Array ?? throw new NullReferenceException(), + // cacheInputEntry.Bytes.Offset, cacheInputEntry.Bytes.Count, false, true); + // } + // else + // { + // cacheResult.Detail = AsyncCacheDetailResult.QueueLockTimeoutAndFailed; + // } + // } + // return cacheResult; + // } + // + // + + + public async Task> CacheFetch(IBlobCacheRequest request, + CancellationToken cancellationToken = default) { if (cancellationToken.IsCancellationRequested) throw new OperationCanceledException(cancellationToken); - var swGetOrCreateBytes = Stopwatch.StartNew(); - var entry = new CacheEntry(key, PathBuilder); - - // Tell cleanup what we're using + var entry = CacheEntry.FromHash(request.CacheKeyHash, request.CacheKeyHashString, PathBuilder); + + // Notify cleanup that we're using this entry CleanupManager.NotifyUsed(entry); - - // Fast path on disk hit - var swFileExists = Stopwatch.StartNew(); - var fileBasedResult = await TryGetFileBasedResult(entry, false, retrieveContentType, cancellationToken); - if (fileBasedResult != null) + var waitForFile = !request.FailFast; + + // Fast path on disk hit, now that we're in a synchronized state + var fileBasedResult2 = + await TryGetFileBasedResult(entry, waitForFile, request.FetchAllMetadata, cancellationToken); + if (fileBasedResult2 != null) { - return fileBasedResult; + return fileBasedResult2; } - // Just continue on creating the file. It must have been deleted between the calls - - swFileExists.Stop(); - + return AsyncCacheResult.FromMiss(this, this); + } + + public async Task CachePut(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + var entry = CacheEntry.FromHash(e.OriginalRequest.CacheKeyHash, e.OriginalRequest.CacheKeyHashString, + PathBuilder); + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + if (e.Result == null) throw new InvalidOperationException("Result is null"); + using var blob = await e.Result.Unwrap().CreateConsumable(e.BlobFactory, cancellationToken); + if (blob == null) throw new InvalidOperationException("Blob is null"); + var record = new CacheDatabaseRecord + { + AccessCountKey = CleanupManager.GetAccessCountKey(entry), + CreatedAt = DateTimeOffset.UtcNow.AddDays(1), + LastDeletionAttempt = DateTime.MinValue, + EstDiskSize = CleanupManager.EstimateFileSizeOnDisk(blob.StreamLength ?? 0), + RelativePath = entry.RelativePath, + ContentType = blob.Attributes.ContentType, + Flags = (e.BlobCategory == BlobGroup.Essential) ? CacheEntryFlags.DoNotEvict : CacheEntryFlags.Unknown, + Tags = blob.Attributes.StorageTags + }; + + var result = await EvictWriteAndLogSynchronized(entry, record, blob, e.AsyncWriteJobCreatedAt, + false, + e.FreshResultGenerationTime, cancellationToken); + + if (result != AsyncCacheDetailResult.WriteSucceeded) + { + Logger?.LogError("HybridCache failed to complete async write, {Result} {Path}", result, + entry.RelativePath); + return CodeResult.Err((500, "Failed to flush async write")); + } - var cacheResult = new AsyncCacheResult(); - - //Looks like a miss. Let's enter a lock for the creation of the file. This is a different locking system - // than for writing to the file - //This prevents two identical requests from duplicating efforts. Different requests don't lock. + return CodeResult.Ok(); + } - //Lock execution using relativePath as the sync basis. Ignore casing differences. This prevents duplicate entries in the write queue and wasted CPU/RAM usage. - var queueLockComplete = await QueueLocks.TryExecuteAsync(entry.StringKey, - Options.WaitForIdenticalRequestsTimeoutMs, cancellationToken, - async () => + public Task>> CacheSearchByTag(SearchableBlobTag tag, + CancellationToken cancellationToken = default) + { + return CleanupManager.CacheSearchByTag(tag, cancellationToken); + } + + public Task>>> CachePurgeByTag(SearchableBlobTag tag, + CancellationToken cancellationToken = default) + { + // MAJOR FLAW - delete locks on relative path, other users of this use the string key + return CleanupManager.CachePurgeByTag(tag, EvictAndWriteLocks, cancellationToken); + } + + public Task CacheDelete(IBlobStorageReference reference, + CancellationToken cancellationToken = default) + { + // MAJOR FLAW - delete locks on relative path, other users of this use the string key + return CleanupManager.CacheDelete(((FileBlobStorageReference)reference).RelativePath, EvictAndWriteLocks, + cancellationToken); + } + + public Task OnCacheEvent(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + if (e.FreshResultGenerated || e.ExternalCacheHit != null) + { + var entry = CacheEntry.FromHash(e.OriginalRequest.CacheKeyHash, e.OriginalRequest.CacheKeyHashString, + PathBuilder); + + // Tell cleanup what we're using + CleanupManager.NotifyUsed(entry); + } + + return Task.FromResult(CodeResult.Ok()); + } + + public BlobCacheCapabilities InitialCacheCapabilities { get; } + + public async ValueTask CacheHealthCheck(CancellationToken cancellationToken = default) + { + try + { + // If checking the existence of the cache folder causes an exception (rather than just false/missing), + // fail fetching + + var rootDirResult = await Database.TestRootDirectory(); + if (!rootDirResult.IsOk) { - var swInsideQueueLock = Stopwatch.StartNew(); - - // Now, if the item we seek is in the queue, we have a memcached hit. - // If not, we should check the filesystem. It's possible the item has been written to disk already. - // If both are a miss, we should see if there is enough room in the write queue. - // If not, switch to in-thread writing. + return BlobCacheHealthDetails.Error(rootDirResult, BlobCacheCapabilities.OnlyHealthCheck) + with { SuggestedRecheckDelay = TimeSpan.FromMinutes(1) }; // Permissions/drive access issues don't resolve fast. + } - var existingQueuedWrite = CurrentWrites.Get(entry.StringKey); - if (existingQueuedWrite != null) - { - cacheResult.Data = existingQueuedWrite.GetReadonlyStream(); - cacheResult.CreatedAt = null; // Hasn't been written yet - cacheResult.ContentType = existingQueuedWrite.ContentType; - cacheResult.Detail = AsyncCacheDetailResult.MemoryHit; - return; - } - - if (cancellationToken.IsCancellationRequested) - throw new OperationCanceledException(cancellationToken); - - swFileExists.Start(); - // Fast path on disk hit, now that we're in a synchronized state - var fileBasedResult2 = await TryGetFileBasedResult(entry, true, retrieveContentType, cancellationToken); - if (fileBasedResult2 != null) - { - cacheResult = fileBasedResult2; - return; - } - // Just continue on creating the file. It must have been deleted between the calls - - swFileExists.Stop(); - - var swDataCreation = Stopwatch.StartNew(); - //Read, resize, process, and encode the image. Lots of exceptions thrown here. - var result = await dataProviderCallback(cancellationToken); - swDataCreation.Stop(); - - //Create AsyncWrite object to enqueue - var w = new AsyncWrite(entry.StringKey, result.Bytes, result.ContentType); - - cacheResult.Detail = AsyncCacheDetailResult.Miss; - cacheResult.ContentType = w.ContentType; - cacheResult.CreatedAt = null; // Hasn't been written yet. - cacheResult.Data = w.GetReadonlyStream(); - - // Create a lambda which we can call either in a spawned Task (if enqueued successfully), or - // in this task, if our buffer is full. - async Task EvictWriteAndLogUnsynchronized(bool queueFull, TimeSpan dataCreationTime, CancellationToken ct) - { - var delegateStartedAt = DateTime.UtcNow; - var swReserveSpace = Stopwatch.StartNew(); - //We only permit eviction proceedings from within the queue or if the queue is disabled - var allowEviction = !queueFull || CurrentWrites.MaxQueueBytes <= 0; - var reserveSpaceResult = await CleanupManager.TryReserveSpace(entry, w.ContentType, - w.GetUsedBytes(), allowEviction, EvictAndWriteLocks, ct); - swReserveSpace.Stop(); - - var syncString = queueFull ? "synchronous" : "async"; - if (!reserveSpaceResult.Success) - { - Logger?.LogError( - queueFull - ? "HybridCache synchronous eviction failed; {Message}. Time taken: {1}ms - {2}" - : "HybridCache async eviction failed; {Message}. Time taken: {1}ms - {2}", - syncString, reserveSpaceResult.Message, swReserveSpace.ElapsedMilliseconds, - entry.RelativePath); - - return AsyncCacheDetailResult.CacheEvictionFailed; - } - - var swIo = Stopwatch.StartNew(); - // We only force an immediate File.Exists check when running from the Queue - // Otherwise it happens inside the lock - var fileWriteResult = await FileWriter.TryWriteFile(entry, delegate(Stream s, CancellationToken ct2) - { - if (ct2.IsCancellationRequested) throw new OperationCanceledException(ct2); - - var fromStream = w.GetReadonlyStream(); - return fromStream.CopyToAsync(s, 81920, ct2); - }, !queueFull, Options.WaitForIdenticalDiskWritesMs, ct); - swIo.Stop(); - - var swMarkCreated = Stopwatch.StartNew(); - // Mark the file as created so it can be deleted - await CleanupManager.MarkFileCreated(entry, - w.ContentType, - w.GetUsedBytes(), - DateTime.UtcNow); - swMarkCreated.Stop(); - - switch (fileWriteResult) + // If the metastore cannot load, fail all write actions and specify an increasing retry interval. + var metaStoreResult = await Database.TestMetaStore(); + if (!metaStoreResult.IsOk) + { + return BlobCacheHealthDetails.Error(metaStoreResult, + BlobCacheCapabilities.OnlyHealthCheck with { - case CacheFileWriter.FileWriteStatus.LockTimeout: - //We failed to lock the file. - Logger?.LogWarning("HybridCache {Sync} write failed; disk lock timeout exceeded after {IoTime}ms - {Path}", - syncString, swIo.ElapsedMilliseconds, entry.RelativePath); - return AsyncCacheDetailResult.WriteTimedOut; - case CacheFileWriter.FileWriteStatus.FileAlreadyExists: - Logger?.LogTrace("HybridCache {Sync} write found file already exists in {IoTime}ms, after a {DelayTime}ms delay and {CreationTime}- {Path}", - syncString, swIo.ElapsedMilliseconds, - delegateStartedAt.Subtract(w.JobCreatedAt).TotalMilliseconds, - dataCreationTime, entry.RelativePath); - return AsyncCacheDetailResult.FileAlreadyExists; - case CacheFileWriter.FileWriteStatus.FileCreated: - if (queueFull) - { - Logger?.LogTrace(@"HybridCache synchronous write complete. Create: {CreateTime}ms. Write {WriteTime}ms. Mark Created: {MarkCreatedTime}ms. Eviction: {EvictionTime}ms - {Path}", - Math.Round(dataCreationTime.TotalMilliseconds).ToString(CultureInfo.InvariantCulture).PadLeft(4), - swIo.ElapsedMilliseconds.ToString().PadLeft(4), - swMarkCreated.ElapsedMilliseconds.ToString().PadLeft(4), - swReserveSpace.ElapsedMilliseconds.ToString().PadLeft(4), entry.RelativePath); - } - else - { - Logger?.LogTrace(@"HybridCache async write complete. Create: {CreateTime}ms. Write {WriteTime}ms. Mark Created: {MarkCreatedTime}ms Eviction {EvictionTime}ms. Delay {DelayTime}ms. - {Path}", - Math.Round(dataCreationTime.TotalMilliseconds).ToString(CultureInfo.InvariantCulture).PadLeft(4), - swIo.ElapsedMilliseconds.ToString().PadLeft(4), - swMarkCreated.ElapsedMilliseconds.ToString().PadLeft(4), - swReserveSpace.ElapsedMilliseconds.ToString().PadLeft(4), - Math.Round(delegateStartedAt.Subtract(w.JobCreatedAt).TotalMilliseconds).ToString(CultureInfo.InvariantCulture).PadLeft(4), - entry.RelativePath); - } - - return AsyncCacheDetailResult.WriteSucceeded; - default: - throw new ArgumentOutOfRangeException(); - } - } + CanFetchData = true + // Recycling / locking issues usually take a bit for shutdown and unlock + }) with { SuggestedRecheckDelay = TimeSpan.FromSeconds(10) }; + } + } + catch (Exception ex) + { + return BlobCacheHealthDetails.Error(CodeResult.FromException(ex), BlobCacheCapabilities.OnlyHealthCheck); + } - async Task EvictWriteAndLogSynchronized(bool queueFull, - TimeSpan dataCreationTime, CancellationToken ct) - { - var cacheDetailResult = AsyncCacheDetailResult.Unknown; - var writeLockComplete = await EvictAndWriteLocks.TryExecuteAsync(entry.StringKey, - Options.WaitForIdenticalRequestsTimeoutMs, cancellationToken, - async () => - { - cacheDetailResult = - await EvictWriteAndLogUnsynchronized(queueFull, dataCreationTime, ct); - }); - if (!writeLockComplete) - { - cacheDetailResult = AsyncCacheDetailResult.EvictAndWriteLockTimedOut; - } + return BlobCacheHealthDetails.FullHealth(InitialCacheCapabilities); + } - return cacheDetailResult; - } - + // Create a lambda which we can call either in a spawned Task (if enqueued successfully), or + // in this task, if our buffer is full. + async Task EvictWriteAndLogUnsynchronized(CacheEntry entry, + CacheDatabaseRecord newRecord, IConsumableBlob blob, bool insideImageDeliveryRequest, + DateTimeOffset jobCreatedAt, TimeSpan dataCreationTime, CancellationToken ct) + { + if (insideImageDeliveryRequest) throw new NotImplementedException(); + if (ct.IsCancellationRequested) throw new OperationCanceledException(ct); + if (blob.StreamLength == null) throw new InvalidOperationException("StreamLength is null"); + if (blob.StreamLength.Value > int.MaxValue) + throw new InvalidOperationException("StreamLength exceeds 2gb limit"); + var blobByteCount = (int)(blob.StreamLength ?? -1); + + + var delegateStartedAt = DateTimeOffset.UtcNow; + var swReserveSpace = Stopwatch.StartNew(); + //We only permit eviction proceedings from within the queue or if the queue is disabled + var allowEviction = !insideImageDeliveryRequest; // || CurrentWrites.MaxQueueBytes <= 0; + var reserveSpaceResult = + await CleanupManager.TryReserveSpace(entry, newRecord, allowEviction, EvictAndWriteLocks, ct); + swReserveSpace.Stop(); + + var syncString = insideImageDeliveryRequest ? "synchronous" : "async"; + if (!reserveSpaceResult.Success) + { + Logger?.LogError( + insideImageDeliveryRequest + ? "HybridCache synchronous eviction failed; {Message}. Time taken: {1}ms - {2}" + : "HybridCache async eviction failed; {Message}. Time taken: {1}ms - {2}", + syncString, reserveSpaceResult.Message, swReserveSpace.ElapsedMilliseconds, + entry.RelativePath); + + return AsyncCacheDetailResult.CacheEvictionFailed; + } - var swEnqueue = Stopwatch.StartNew(); - var queueResult = CurrentWrites.Queue(w, async delegate - { - try - { - var unused = await EvictWriteAndLogSynchronized(false, swDataCreation.Elapsed, CancellationToken.None); - } - catch (Exception ex) - { - Logger?.LogError(ex, "HybridCache failed to flush async write, {Exception} {Path}\n{StackTrace}", ex.ToString(), - entry.RelativePath, ex.StackTrace); - } + var swIo = Stopwatch.StartNew(); + // We only force an immediate File.Exists check when running from the Queue + // Otherwise it happens inside the lock + var fileWriteResult = await FileWriter.TryWriteFile(entry, async delegate(Stream s, CancellationToken ct2) + { + if (ct2.IsCancellationRequested) throw new OperationCanceledException(ct2); - }); - swEnqueue.Stop(); - swInsideQueueLock.Stop(); - swGetOrCreateBytes.Stop(); + using var fromStream = blob.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob); + await fromStream.CopyToAsync(s, 81920, ct2); + }, !insideImageDeliveryRequest, Options.WaitForIdenticalDiskWritesMs, ct); + swIo.Stop(); - if (queueResult == AsyncWriteCollection.AsyncQueueResult.QueueFull) + var swMarkCreated = Stopwatch.StartNew(); + // Mark the file as created so it can be deleted + await CleanupManager.MarkFileCreated(entry, + DateTime.UtcNow, () => newRecord); + swMarkCreated.Stop(); + + switch (fileWriteResult) + { + case CacheFileWriter.FileWriteStatus.LockTimeout: + //We failed to lock the file. + Logger?.LogWarning( + "HybridCache {Sync} write failed; disk lock timeout exceeded after {IoTime}ms - {Path}", + syncString, swIo.ElapsedMilliseconds, entry.RelativePath); + return AsyncCacheDetailResult.WriteTimedOut; + case CacheFileWriter.FileWriteStatus.FileAlreadyExists: + Logger?.LogTrace( + "HybridCache {Sync} write found file already exists in {IoTime}ms, after a {DelayTime}ms delay and {CreationTime}- {Path}", + syncString, swIo.ElapsedMilliseconds, + delegateStartedAt.Subtract(jobCreatedAt).TotalMilliseconds, + dataCreationTime, entry.RelativePath); + return AsyncCacheDetailResult.FileAlreadyExists; + case CacheFileWriter.FileWriteStatus.FileCreated: + if (insideImageDeliveryRequest) { - if (Options.WriteSynchronouslyWhenQueueFull) - { - var writerDelegateResult = await EvictWriteAndLogSynchronized(true, swDataCreation.Elapsed, cancellationToken); - cacheResult.Detail = writerDelegateResult; - } + Logger?.LogTrace( + @"HybridCache synchronous write complete. Create: {CreateTime}ms. Write {WriteTime}ms. Mark Created: {MarkCreatedTime}ms. Eviction: {EvictionTime}ms - {Path}", + Math.Round(dataCreationTime.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) + .PadLeft(4), + swIo.ElapsedMilliseconds.ToString().PadLeft(4), + swMarkCreated.ElapsedMilliseconds.ToString().PadLeft(4), + swReserveSpace.ElapsedMilliseconds.ToString().PadLeft(4), entry.RelativePath); + } + else + { + Logger?.LogTrace( + @"HybridCache async write complete. Create: {CreateTime}ms. Write {WriteTime}ms. Mark Created: {MarkCreatedTime}ms Eviction {EvictionTime}ms. Delay {DelayTime}ms. - {Path}", + Math.Round(dataCreationTime.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) + .PadLeft(4), + swIo.ElapsedMilliseconds.ToString().PadLeft(4), + swMarkCreated.ElapsedMilliseconds.ToString().PadLeft(4), + swReserveSpace.ElapsedMilliseconds.ToString().PadLeft(4), + Math.Round(delegateStartedAt.Subtract(jobCreatedAt).TotalMilliseconds) + .ToString(CultureInfo.InvariantCulture).PadLeft(4), + entry.RelativePath); } + + return AsyncCacheDetailResult.WriteSucceeded; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private async Task EvictWriteAndLogSynchronized(CacheEntry entry, + CacheDatabaseRecord record, IConsumableBlob blob, DateTimeOffset jobCreatedAt, bool queueFull, + TimeSpan dataCreationTime, CancellationToken cancellationToken) + { + var cacheDetailResult = AsyncCacheDetailResult.Unknown; + var writeLockComplete = await EvictAndWriteLocks.TryExecuteAsync(entry.HashString, + Options.WaitForIdenticalCachePutTimeoutMs, cancellationToken, + async () => + { + cacheDetailResult = + await EvictWriteAndLogUnsynchronized(entry, record, blob, queueFull, jobCreatedAt, + dataCreationTime, cancellationToken); }); - if (!queueLockComplete) + if (!writeLockComplete) { - //On queue lock failure - if (!Options.FailRequestsOnEnqueueLockTimeout) - { - // We run the callback with no intent of caching - var cacheInputEntry = await dataProviderCallback(cancellationToken); - - cacheResult.Detail = AsyncCacheDetailResult.QueueLockTimeoutAndCreated; - cacheResult.ContentType = cacheInputEntry.ContentType; - cacheResult.CreatedAt = null; //Hasn't been written yet - - cacheResult.Data = new MemoryStream(cacheInputEntry.Bytes.Array ?? throw new NullReferenceException(), - cacheInputEntry.Bytes.Offset, cacheInputEntry.Bytes.Count, false, true); - } - else + cacheDetailResult = AsyncCacheDetailResult.EvictAndWriteLockTimedOut; + } + + return cacheDetailResult; + } + + + private static class AsyncCacheResult + { + + internal static IResult FromHit(ICacheDatabaseRecord? record, + string entryRelativePath, HashBasedPathBuilder interpreter, + FileStream stream, LatencyTrackingZone latencyZone, IBlobCache notifyOfResult, IBlobCache notifyOfExternalHit) + { + var blob = new ConsumableStreamBlob(new BlobAttributes() { - cacheResult.Detail = AsyncCacheDetailResult.QueueLockTimeoutAndFailed; - } + ContentType = record?.ContentType, + Etag = record?.RelativePath, + LastModifiedDateUtc = record?.CreatedAt, + BlobByteCount = stream.CanSeek ? stream.Length : null, + StorageTags = record?.Tags, + BlobStorageReference = new FileBlobStorageReference(entryRelativePath, interpreter, record) + + }, stream); + return Result.Ok( new BlobWrapper(latencyZone, blob)); + } - return cacheResult; + + internal static IResult FromMiss(IBlobCache notifyOfResult, + IBlobCache notifyOfExternalHit) + { + return BlobCacheFetchFailure.MissResult(notifyOfResult, notifyOfExternalHit); + } + } } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/AsyncCacheOptions.cs b/src/Imazen.HybridCache/AsyncCacheOptions.cs index b1acc2ea..521ff3eb 100644 --- a/src/Imazen.HybridCache/AsyncCacheOptions.cs +++ b/src/Imazen.HybridCache/AsyncCacheOptions.cs @@ -1,27 +1,37 @@ -using System; - namespace Imazen.HybridCache { + public class AsyncCacheOptions { - public int WaitForIdenticalRequestsTimeoutMs { get; set; } = 100000; + + public int WaitForIdenticalDiskWritesMs { get; set; } = 15000; + + public int WaitForIdenticalCachePutTimeoutMs { get; set; } = 100000; + + + [Obsolete("Ignored; write queue is now global across all caches")] public long MaxQueuedBytes { get; set; } = 1024 * 1024 * 100; + [Obsolete("Ignored; write queue is now global across all caches")] public bool FailRequestsOnEnqueueLockTimeout { get; set; } = true; - public bool WriteSynchronouslyWhenQueueFull { get; set; } = true; + [Obsolete("Ignored; write queue is now global across all caches")] + public bool WriteSynchronouslyWhenQueueFull { get; set; } = false; /// - /// If this is used from .NET Core, set to File.Move(from, to, true) + /// If you're not on .NET 6/8, but you have File.Move(from, to, true) available, + /// you can set this to true to use that instead of the default File.Move(from,to) for better performance /// Used by deletion code even if MoveFilesIntoPlace is false /// - public Action MoveFileOverwriteFunc { get; set; } + public Action? MoveFileOverwriteFunc { get; set; } /// /// If true, cache files are first written to a temp file, then moved into their correct place. /// Slightly slower when true. Defaults to false. /// public bool MoveFilesIntoPlace { get; set; } + + public required string UniqueName { get; set; } } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/AsyncWrite.cs b/src/Imazen.HybridCache/AsyncWrite.cs deleted file mode 100644 index 97ddd399..00000000 --- a/src/Imazen.HybridCache/AsyncWrite.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Imazen LLC. -// No part of this project, including this file, may be copied, modified, -// propagated, or distributed except as permitted in COPYRIGHT.txt. -// Licensed under the GNU Affero General Public License, Version 3.0. -// Commercial licenses available at http://imageresizing.net/ -using System; -using System.IO; -using System.Threading.Tasks; - -namespace Imazen.HybridCache { - internal class AsyncWrite { - - public AsyncWrite(string key, ArraySegment data, string contentType) { - this.data = data; - if (data.Array == null) throw new ArgumentException(nameof(data)); - Key = key; - ContentType = contentType; - JobCreatedAt = DateTime.UtcNow; - } - private readonly ArraySegment data; - - public string ContentType { get; } - - public Task RunningTask { get; set; } - public string Key { get; } - /// - /// Returns the UTC time this AsyncWrite object was created. - /// - public DateTime JobCreatedAt { get; } - - - /// - /// Returns the length of the buffer plus 100 bytes for the AsyncWrite instance in memory - /// - /// - public long GetEntrySizeInMemory() - { - return GetBufferLength() + 100; - } - - /// - /// Returns the length of the buffer capacity - /// - /// - private long GetBufferLength() { - return data.Array?.Length ?? 0; - } - /// - /// Returns just the number of bytes used within the buffer - /// - /// - public int GetUsedBytes() { - return data.Count; - } - - /// - /// Wraps the data in a readonly MemoryStream so it can be accessed on another thread - /// - /// - public MemoryStream GetReadonlyStream() { - //Wrap the original buffer in a new MemoryStream. - return new MemoryStream(data.Array ?? throw new NullReferenceException(), data.Offset, data.Count, false, true); - } - - - } -} diff --git a/src/Imazen.HybridCache/AsyncWriteCollection.cs b/src/Imazen.HybridCache/AsyncWriteCollection.cs deleted file mode 100644 index c08997a8..00000000 --- a/src/Imazen.HybridCache/AsyncWriteCollection.cs +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Imazen LLC. -// No part of this project, including this file, may be copied, modified, -// propagated, or distributed except as permitted in COPYRIGHT.txt. -// Licensed under the GNU Affero General Public License, Version 3.0. -// Commercial licenses available at http://imageresizing.net/ - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Imazen.HybridCache { - internal class AsyncWriteCollection { - - public AsyncWriteCollection(long maxQueueBytes) { - MaxQueueBytes = maxQueueBytes; - } - - private readonly object sync = new object(); - - private readonly Dictionary c = new Dictionary(); - - /// - /// How many bytes of buffered file data to hold in memory before refusing further queue requests and forcing them to be executed synchronously. - /// - public long MaxQueueBytes { get; } - - private long queuedBytes; - /// - /// If the collection contains the specified item, it is returned. Otherwise, null is returned. - /// - /// - /// - public AsyncWrite Get(string key) { - lock (sync) { - return c.TryGetValue(key, out var result) ? result : null; - } - } - - /// - /// Removes the specified object based on its relativePath and modifiedDateUtc values. - /// - /// - private void Remove(AsyncWrite w) { - lock (sync) - { - c.Remove(w.Key); - queuedBytes -= w.GetEntrySizeInMemory(); - } - } - - public enum AsyncQueueResult - { - Enqueued, - AlreadyPresent, - QueueFull - } - /// - /// Tries to enqueue the given async write and callback - /// - /// - /// - /// - public AsyncQueueResult Queue(AsyncWrite w, Func writerDelegate){ - lock (sync) - { - if (queuedBytes < 0) throw new InvalidOperationException(); - if (queuedBytes + w.GetEntrySizeInMemory() > MaxQueueBytes) return AsyncQueueResult.QueueFull; //Because we would use too much ram. - if (c.ContainsKey(w.Key)) return AsyncQueueResult.AlreadyPresent; //We already have a queued write for this data. - c.Add(w.Key, w); - queuedBytes += w.GetEntrySizeInMemory(); - w.RunningTask = Task.Run( - async () => { - try - { - await writerDelegate(w); - } - finally - { - Remove(w); - } - }); - return AsyncQueueResult.Enqueued; - } - } - - public Task AwaitAllCurrentTasks() - { - lock (sync) - { - var tasks = c.Values.Select(w => w.RunningTask).ToArray(); - return Task.WhenAll(tasks); - } - } - } -} diff --git a/src/Imazen.HybridCache/BucketCounter.cs b/src/Imazen.HybridCache/BucketCounter.cs index 78f5e1d2..e600234a 100644 --- a/src/Imazen.HybridCache/BucketCounter.cs +++ b/src/Imazen.HybridCache/BucketCounter.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Concurrent; using System.Security.Cryptography; @@ -13,11 +12,11 @@ internal class BucketCounter /// /// We use a dictionary when at the beginning /// - private ConcurrentDictionary dict; + private ConcurrentDictionary? dict; /// /// We allocate a sparse array after a certain point /// - private ushort[] table; + private ushort[]? table; /// /// Lock object for transitioning from dictionary to sparse array diff --git a/src/Imazen.HybridCache/CacheEntry.cs b/src/Imazen.HybridCache/CacheEntry.cs index 39a0ae41..8430a11d 100644 --- a/src/Imazen.HybridCache/CacheEntry.cs +++ b/src/Imazen.HybridCache/CacheEntry.cs @@ -1,20 +1,29 @@ +using Imazen.Common.Extensibility.Support; + namespace Imazen.HybridCache { internal readonly struct CacheEntry { - public CacheEntry(byte[] keyBasis, HashBasedPathBuilder builder) + + private CacheEntry(byte[] hash, string relativePath, string physicalPath, string hashString) { - Hash = builder.HashKeyBasis(keyBasis); - RelativePath = builder.GetRelativePathFromHash(Hash); - PhysicalPath = builder.GetPhysicalPathFromRelativePath(RelativePath); - StringKey = builder.GetStringFromHash(Hash); + Hash = hash; + RelativePath = relativePath; + PhysicalPath = physicalPath; + HashString = hashString; } public byte[] Hash { get; } public string PhysicalPath { get; } - public string StringKey { get; } + public string HashString { get; } public string RelativePath { get; } + internal static CacheEntry FromHash(byte[] hash, string hashString, HashBasedPathBuilder builder) + { + var relative = builder.GetRelativePathFromHash(hash); + return new CacheEntry(hash, builder.GetRelativePathFromHash(hash), builder.GetPhysicalPathFromRelativePath(relative), hashString); + } + } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/CacheFileWriter.cs b/src/Imazen.HybridCache/CacheFileWriter.cs index 1540bf2b..f2803cb4 100644 --- a/src/Imazen.HybridCache/CacheFileWriter.cs +++ b/src/Imazen.HybridCache/CacheFileWriter.cs @@ -1,7 +1,3 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; using Imazen.Common.Concurrency; namespace Imazen.HybridCache @@ -20,15 +16,15 @@ internal enum FileWriteStatus LockTimeout, } - private readonly Action moveFileOverwriteFunc; + private readonly Action? moveFileOverwriteFunc; private AsyncLockProvider WriteLocks { get; } - private bool moveIntoPlace; - public CacheFileWriter(AsyncLockProvider writeLocks, Action moveFileOverwriteFunc, bool moveIntoPlace) + private readonly bool moveIntoPlace; + public CacheFileWriter(AsyncLockProvider writeLocks, Action? moveFileOverwriteFunc, bool moveIntoPlace) { this.moveIntoPlace = moveIntoPlace; WriteLocks = writeLocks; - this.moveFileOverwriteFunc = moveFileOverwriteFunc ?? File.Move; + this.moveFileOverwriteFunc = moveFileOverwriteFunc; } @@ -54,10 +50,10 @@ internal async Task TryWriteFile(CacheEntry entry, if (recheckFileSystemFirst) { var miss = !File.Exists(entry.PhysicalPath); - if (!miss && !WriteLocks.MayBeLocked(entry.StringKey)) return FileWriteStatus.FileAlreadyExists; + if (!miss && !WriteLocks.MayBeLocked(entry.HashString)) return FileWriteStatus.FileAlreadyExists; } - var lockingSucceeded = await WriteLocks.TryExecuteAsync(entry.StringKey, timeoutMs, cancellationToken, + var lockingSucceeded = await WriteLocks.TryExecuteAsync(entry.HashString, timeoutMs, cancellationToken, async () => { if (cancellationToken.IsCancellationRequested) @@ -103,7 +99,7 @@ internal async Task TryWriteFile(CacheEntry entry, { if (moveIntoPlace) { - moveFileOverwriteFunc(writeToFile, entry.PhysicalPath); + MoveOverwrite(writeToFile, entry.PhysicalPath); } resultStatus = FileWriteStatus.FileCreated; @@ -139,5 +135,19 @@ internal async Task TryWriteFile(CacheEntry entry, } return resultStatus; } + + private void MoveOverwrite(string from, string to) + { + if (moveFileOverwriteFunc != null) + moveFileOverwriteFunc(from, to); + else + { +#if NET5_0_OR_GREATER + File.Move(from, to, true); +#else + File.Move(from, to); +#endif + } + } } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/CleanupManager.cs b/src/Imazen.HybridCache/CleanupManager.cs index 292afcf6..67736021 100644 --- a/src/Imazen.HybridCache/CleanupManager.cs +++ b/src/Imazen.HybridCache/CleanupManager.cs @@ -1,14 +1,14 @@ -using System; using System.Globalization; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; using Imazen.Common.Concurrency; +using Imazen.Common.Extensibility.Support; +using Imazen.HybridCache.MetaStore; using Microsoft.Extensions.Logging; namespace Imazen.HybridCache { + internal class CleanupManager : ICacheCleanupManager { private readonly Lazy accessCounter; @@ -19,40 +19,143 @@ internal class CleanupManager : ICacheCleanupManager private BucketCounter AccessCounter => accessCounter.Value; - private ICacheDatabase Database { get; } + private ICacheDatabase Database { get; } private CleanupManagerOptions Options { get; } private HashBasedPathBuilder PathBuilder { get; } private ILogger Logger { get; } - public CleanupManager(CleanupManagerOptions options, ICacheDatabase database, ILogger logger, HashBasedPathBuilder pathBuilder) + + private int[] ShardIds { get; } + public CleanupManager(CleanupManagerOptions options, ICacheDatabase database, ILogger logger, HashBasedPathBuilder pathBuilder) { PathBuilder = pathBuilder; Logger = logger; Options = options; Database = database; + ShardIds = Enumerable.Range(0, Database.GetShardCount()).ToArray(); accessCounter = new Lazy(() => new BucketCounter(Options.AccessTrackingBits)); } public void NotifyUsed(CacheEntry cacheEntry) { AccessCounter.Increment(cacheEntry.Hash); } + + public async Task GetRecordReference(CacheEntry cacheEntry, CancellationToken cancellationToken) + { + return await Database.GetRecord(Database.GetShardForKey(cacheEntry.RelativePath), cacheEntry.RelativePath); + } + + public async Task>> CacheSearchByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + // check all shards simultaneously + var results = await Task.WhenAll(ShardIds.Select(async shard => await Database.LinearSearchByTag(shard, tag))); + return CodeResult>.Ok( + results.SelectMany(r => + r.Select(x => + (IBlobStorageReference)new FileBlobStorageReference(x.RelativePath, PathBuilder, x))) + .ToAsyncEnumerable()); + } - public Task GetContentType(CacheEntry cacheEntry, CancellationToken cancellationToken) + public async Task>>> CachePurgeByTag(SearchableBlobTag tag, AsyncLockProvider writeLocks, CancellationToken cancellationToken = default) { - return Database.GetContentType(Database.GetShardForKey(cacheEntry.RelativePath), cacheEntry.RelativePath); + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + var shardTasks = ShardIds.Select(shard => ShardPurgeByTag(shard, tag, writeLocks, cancellationToken)) + .ToArray(); + var result = await Task.WhenAll(shardTasks); + return CodeResult>>.Ok( + result.SelectMany(r => r).ToAsyncEnumerable()); + + } + + private async Task[]> ShardPurgeByTag(int shard, SearchableBlobTag tag, AsyncLockProvider writeLocks, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + var results = await Database.LinearSearchByTag(shard, tag); + return await Task.WhenAll(results.Select(async r => + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + var result = await TryDelete(shard, r, writeLocks); + if (result.DeletionFailed) + { + Logger?.LogError("HybridCache: Failed to delete file {Path}", r.RelativePath); + return CodeResult.Err((500, $"Failed to delete file {r.RelativePath}")); + + } + else + { + return CodeResult.Ok(new FileBlobStorageReference(r.RelativePath,PathBuilder,r)); + } + + })); } - public async Task GetRecordReference(CacheEntry cacheEntry, CancellationToken cancellationToken) + public async Task CacheDelete(string relativePath, AsyncLockProvider writeLocks, + CancellationToken cancellationToken = default) { - return await Database.GetRecord(Database.GetShardForKey(cacheEntry.RelativePath), cacheEntry.RelativePath); + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + var shard = Database.GetShardForKey(relativePath); + var record = await Database.GetRecord(shard, relativePath); + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + if (record == null) + return CodeResult.Ok((200, $"File doesn't exist (in metabase) to delete: {relativePath}")); + + var result = await TryDelete(shard, record, writeLocks); + // Retry once if the record reference is stale. + if (result.RecordDeleteResult == DeleteRecordResult.RecordStaleReQueryRetry) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + record = await Database.GetRecord(shard, relativePath); + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + if (record == null) + return CodeResult.Ok((200, $"File doesn't exist (in metabase) to delete: {relativePath}")); + + result = await TryDelete(shard, record, writeLocks); + } + + //TODO: add more detail, normalize errors better + + if (!result.DeletionFailed) + { + if (!result.FileExisted) + { + return CodeResult.Ok((200, $"File doesn't exist to delete: {relativePath}")); + } + + return CodeResult.Ok(); + } + + if (result.FileMovedForFutureDeletion) + { + return CodeResult.Err((202, + $"File moved to make inaccessible (deletion not possible, retry later): {relativePath}")); + } + + if (result.FileExisted) + { + return CodeResult.Err((500, $"File data not deleted: {relativePath}")); + } + + Logger?.LogError("HybridCache: Failed to delete file {Path}", relativePath); + return CodeResult.Err((500, $"Failed to delete file {relativePath}")); } + public static long DirectoryEntrySize() { return 4096; } - internal static long EstimateEntryBytesWithOverhead(long byteCount) + internal static long EstimateFileSizeOnDiskFor(long byteCount) { // Most file systems have a 4KiB block size var withBlockSize = (Math.Max(1, byteCount) + 4095) / 4096 * 4096; @@ -62,6 +165,8 @@ internal static long EstimateEntryBytesWithOverhead(long byteCount) return withFileDescriptor; } + public long EstimateFileSizeOnDisk(long byteCount) => EstimateFileSizeOnDiskFor(byteCount); + private async Task EvictSpace(int shard, long diskSpace, AsyncLockProvider writeLocks, CancellationToken cancellationToken) { @@ -77,7 +182,7 @@ private async Task EvictSpace(int shard, long diskSpace, Asy var records = (await Database.GetDeletionCandidates(shard, deletionCutoff, creationCutoff, Options.CleanupSelectBatchSize, AccessCounter.Get)) - .Select(r => + .Select(r => // I'm confused, GetDeletionCandidates already does this sort... new Tuple( AccessCounter.Get(r.AccessCountKey), r)) .OrderByDescending(r => r.Item1) @@ -90,8 +195,8 @@ private async Task EvictSpace(int shard, long diskSpace, Asy if (bytesDeleted >= bytesToDeleteOptimally) break; - var deletedBytes = await TryDeleteRecord(shard, record, writeLocks); - bytesDeleted += deletedBytes; + var deletedBytes = await TryDelete(shard, record, writeLocks); + bytesDeleted += deletedBytes.EstBytesDeleted; } // Unlikely to find more records in the next iteration @@ -110,8 +215,10 @@ private async Task EvictSpace(int shard, long diskSpace, Asy } return new ReserveSpaceResult(){Success = true}; } + + public int GetAccessCountKey(CacheEntry cacheEntry) => AccessCounter.GetHash(cacheEntry.Hash); - public async Task TryReserveSpace(CacheEntry cacheEntry, string contentType, int byteCount, + public async Task TryReserveSpace(CacheEntry cacheEntry, CacheDatabaseRecord newRecord, bool allowEviction, AsyncLockProvider writeLocks, CancellationToken cancellationToken) { @@ -119,19 +226,31 @@ public async Task TryReserveSpace(CacheEntry cacheEntry, str var shardSizeLimit = Options.MaxCacheBytes / Database.GetShardCount(); // When we're okay with deleting the database entry even though the file isn't written + if (newRecord.CreatedAt < DateTime.UtcNow.AddMinutes(1)) + { + throw new ArgumentException("record.CreatedAt must be in the future to avoid issues"); + } var farFuture = DateTime.UtcNow.AddHours(1); var maxAttempts = 30; for (var attempts = 0; attempts < maxAttempts; attempts++) { + + // var newRecord = new CacheDatabaseRecord + // { + // AccessCountKey = AccessCounter.GetHash(cacheEntry.Hash), + // ContentType = attributes.ContentType, + // CreatedAt = farFuture, + // EstDiskSize = attributes.EstDiskSize, + // LastDeletionAttempt = DateTime.MinValue, + // RelativePath = cacheEntry.RelativePath, + // Flags = attributes.Flags, + // Tags = attributes.Tags + // }; + var recordCreated = - await Database.CreateRecordIfSpace(shard, cacheEntry.RelativePath, - contentType, - EstimateEntryBytesWithOverhead(byteCount), - farFuture, - AccessCounter.GetHash(cacheEntry.Hash), - shardSizeLimit); + await Database.CreateRecordIfSpace(shard, newRecord, shardSizeLimit); // Return true if we created the record if (recordCreated) return new ReserveSpaceResult(){ Success = true}; @@ -139,8 +258,8 @@ await Database.CreateRecordIfSpace(shard, cacheEntry.RelativePath, // We need to evict but we are not permitted if (!allowEviction) return new ReserveSpaceResult(){ Success = false, Message = "Eviction disabled in sync mode"}; - var entryDiskSpace = EstimateEntryBytesWithOverhead(byteCount) + - Database.EstimateRecordDiskSpace(cacheEntry.RelativePath.Length + (contentType?.Length ?? 0)); + var entryDiskSpace = EstimateFileSizeOnDisk(newRecord.EstDiskSize) + + Database.EstimateRecordDiskSpace(newRecord); var missingSpace = Math.Max(0, await Database.GetShardSize(shard) + entryDiskSpace - shardSizeLimit); // Evict space @@ -155,17 +274,41 @@ await Database.CreateRecordIfSpace(shard, cacheEntry.RelativePath, } - public Task MarkFileCreated(CacheEntry cacheEntry, string contentType, long recordDiskSpace, DateTime createdDate) + public Task MarkFileCreated(CacheEntry cacheEntry, DateTime createdDate, Func createIfMissing) { return Database.UpdateCreatedDateAtomic( - Database.GetShardForKey(cacheEntry.RelativePath), - cacheEntry.RelativePath, - contentType, - EstimateEntryBytesWithOverhead(recordDiskSpace), - createdDate, - AccessCounter.GetHash(cacheEntry.Hash)); + Database.GetShardForKey(cacheEntry.RelativePath), + cacheEntry.RelativePath, + createdDate, createIfMissing); } + + // return Database.UpdateCreatedDateAtomic( + // Database.GetShardForKey(cacheEntry.RelativePath), + // cacheEntry.RelativePath, + // createdDate, () => + // + // new CacheDatabaseRecord() + // { + // AccessCountKey = AccessCounter.GetHash(cacheEntry.Hash), + // ContentType = contentType, + // CreatedAt = createdDate, + // DiskSize = EstimateEntryFileDiskBytesWithOverhead(recordDiskSpace), + // LastDeletionAttempt = DateTime.MinValue, + // RelativePath = cacheEntry.RelativePath + // } + // ); + // } + + private class TryDeleteResult + { + internal long EstBytesDeleted { get; set; } = 0; + internal bool FileExisted { get; set; } = false; + internal bool FileDeleted { get; set; } = false; + internal DeleteRecordResult? RecordDeleteResult { get; set; } = null; + internal bool DeletionFailed { get; set; } = true; + internal bool FileMovedForFutureDeletion { get; set; } = false; + } /// /// Skips the record if there is delete contention for a file. /// Only counts bytes as deleted if the physical file is deleted successfully. @@ -175,9 +318,9 @@ public Task MarkFileCreated(CacheEntry cacheEntry, string contentType, long reco /// /// /// - private async Task TryDeleteRecord(int shard, ICacheDatabaseRecord record, AsyncLockProvider writeLocks) + private async Task TryDelete(int shard, ICacheDatabaseRecord record, AsyncLockProvider writeLocks) { - long bytesDeleted = 0; + var result = new TryDeleteResult(); var unused = await writeLocks.TryExecuteAsync(record.RelativePath, 0, CancellationToken.None, async () => { var physicalPath = PathBuilder.GetPhysicalPathFromRelativePath(record.RelativePath); @@ -185,13 +328,26 @@ private async Task TryDeleteRecord(int shard, ICacheDatabaseRecord record, { if (File.Exists(physicalPath)) { + result.FileExisted = true; File.Delete(physicalPath); - await Database.DeleteRecord(shard, record); - bytesDeleted = record.DiskSize; + result.FileDeleted = true; + result.RecordDeleteResult = await Database.DeleteRecord(shard, record); + if (result.RecordDeleteResult == DeleteRecordResult.RecordStaleReQueryRetry) + { + // We deleted the file but the record was stale, so it was probably recreated since + // the last query. Since we took the file off disk, we could call this a success. + // But the stale record will persist. + + } + result.EstBytesDeleted = record.EstDiskSize; + result.DeletionFailed = false; } else { - await Database.DeleteRecord(shard, record); + result.RecordDeleteResult = await Database.DeleteRecord(shard, record); + // We deleted the file but the record was stale, so it is probably being + // recreated. + result.DeletionFailed = result.RecordDeleteResult == DeleteRecordResult.RecordStaleReQueryRetry; } } catch (IOException ioException) @@ -200,6 +356,7 @@ private async Task TryDeleteRecord(int shard, ICacheDatabaseRecord record, { // We already moved it. All we can do is update the last deletion attempt await Database.UpdateLastDeletionAttempt(shard, record.RelativePath, DateTime.UtcNow); + result.DeletionFailed = true; return; } @@ -211,21 +368,38 @@ private async Task TryDeleteRecord(int shard, ICacheDatabaseRecord record, //Move it so it usage will decrease and it can be deleted later //TODO: This is not transactional, as the db record is written *after* the file is moved //This should be split up into create and delete - (Options.MoveFileOverwriteFunc ?? File.Move)(physicalPath, movedPath); + MoveOverwrite(physicalPath, movedPath); await Database.ReplaceRelativePathAndUpdateLastDeletion(shard, record, movedRelativePath, DateTime.UtcNow); + result.FileMovedForFutureDeletion = true; + result.DeletionFailed = true; Logger?.LogError(ioException,"HybridCache: Error deleting file, moved for eventual deletion - {Path}", record.RelativePath); } catch (IOException ioException2) { await Database.UpdateLastDeletionAttempt(shard, record.RelativePath, DateTime.UtcNow); + result.DeletionFailed = true; Logger?.LogError(ioException2,"HybridCache: Failed to move file for eventual deletion - {Path}", record.RelativePath); } } }); - return bytesDeleted; + return result; } + private void MoveOverwrite(string from, string to) + { + + if (Options.MoveFileOverwriteFunc != null) + Options.MoveFileOverwriteFunc(from, to); + else + { +#if NET5_0_OR_GREATER + File.Move(from, to, true); +#else + File.Move(from, to); +#endif + } + } } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/CleanupManagerOptions.cs b/src/Imazen.HybridCache/CleanupManagerOptions.cs index 2c0fc875..b3c55927 100644 --- a/src/Imazen.HybridCache/CleanupManagerOptions.cs +++ b/src/Imazen.HybridCache/CleanupManagerOptions.cs @@ -1,5 +1,3 @@ -using System; - namespace Imazen.HybridCache { public class CleanupManagerOptions @@ -36,8 +34,11 @@ public class CleanupManagerOptions public int CleanupSelectBatchSize { get; set; } = 1000; /// - /// If this is used from .NET Core, set to File.Move(from, to, true) + /// If you're not on .NET 6/8, but you have File.Move(from, to, true) available, + /// you can set this to true to use that instead of the default File.Move(from,to) for better performance + /// Used by deletion code even if MoveFilesIntoPlace is false /// - public Action MoveFileOverwriteFunc { get; set; } + public Action? MoveFileOverwriteFunc { get; set; } + } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/FileBlobStorageReference.cs b/src/Imazen.HybridCache/FileBlobStorageReference.cs new file mode 100644 index 00000000..7503cd64 --- /dev/null +++ b/src/Imazen.HybridCache/FileBlobStorageReference.cs @@ -0,0 +1,34 @@ +using Imazen.Abstractions.Blobs; +using Imazen.Common.Extensibility.Support; + +namespace Imazen.HybridCache +{ + internal class FileBlobStorageReference : IBlobStorageReference + { + public FileBlobStorageReference(string relativePath, HashBasedPathBuilder pathBuilder, ICacheDatabaseRecord? record) + { + Record = record; + RelativePath = relativePath; + PathBuilder = pathBuilder; + } + + // Record + private ICacheDatabaseRecord? Record { get; set; } + + internal string RelativePath { get; set; } + + private HashBasedPathBuilder PathBuilder { get; set; } + + public int EstimateAllocatedBytesRecursive => + RelativePath.EstimateMemorySize(true) + + 24 + 8 + + Record?.EstimateAllocatedBytesRecursive ?? 0; + + + public string GetFullyQualifiedRepresentation() + { + return PathBuilder.GetPhysicalPathFromRelativePath(RelativePath); + } + + } +} \ No newline at end of file diff --git a/src/Imazen.HybridCache/HybridCache.cs b/src/Imazen.HybridCache/HybridCache.cs index afb87bb9..c5bcc76d 100644 --- a/src/Imazen.HybridCache/HybridCache.cs +++ b/src/Imazen.HybridCache/HybridCache.cs @@ -1,37 +1,108 @@ -using System.Collections.Generic; using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Imazen.Common.Extensibility.StreamCache; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Extensibility.Support; using Imazen.Common.Issues; +using Imazen.HybridCache.MetaStore; using Microsoft.Extensions.Logging; namespace Imazen.HybridCache { - public class HybridCache : IStreamCache + public class HybridCache : IBlobCache { - private readonly ILogger logger; + private readonly IReLogger logger; private HashBasedPathBuilder PathBuilder { get; } internal AsyncCache AsyncCache { get; } private CleanupManager CleanupManager { get; } - private ICacheDatabase Database { get; } + private ICacheDatabase Database { get; } - public HybridCache(ICacheDatabase cacheDatabase, HybridCacheOptions options, ILogger logger) + public string UniqueName { get; } + + + public HybridCache(HybridCacheAdvancedOptions advancedOptions, IReLogger logger) { this.logger = logger; - Database = cacheDatabase; + UniqueName = advancedOptions.UniqueName; + + Database = new MetaStore.MetaStore(new MetaStoreOptions(advancedOptions.PhysicalCacheDir) + { + Shards = advancedOptions.Shards, + MaxLogFilesPerShard = advancedOptions.MaxLogFilesPerShard + }, advancedOptions, logger);; + + PathBuilder = new HashBasedPathBuilder(advancedOptions.PhysicalCacheDir, advancedOptions.Subfolders, + Path.DirectorySeparatorChar, ".jpg"); + + CleanupManager = new CleanupManager(advancedOptions.CleanupManagerOptions, Database, logger, PathBuilder); + AsyncCache = new AsyncCache(advancedOptions.AsyncCacheOptions, CleanupManager, Database, PathBuilder, logger); + } + + public HybridCache(HybridCacheOptions options, IReLoggerFactory loggerFactory) + { + + this.logger = loggerFactory.CreateReLogger("Imazen.HybridCache['" + options.UniqueName + "']"); + var advancedOptions = new Imazen.HybridCache.HybridCacheAdvancedOptions(options.UniqueName, options.DiskCacheDirectory) + { + + + CleanupManagerOptions = new CleanupManagerOptions() + { + MaxCacheBytes = Math.Max(0, options.CacheSizeLimitInBytes), + MinCleanupBytes = Math.Max(0, options.MinCleanupBytes), + MinAgeToDelete = options.MinAgeToDelete.Ticks > 0 ? options.MinAgeToDelete : TimeSpan.Zero, + }, + Shards = Math.Max(1, options.DatabaseShards), + MaxLogFilesPerShard = 3 + }; + UniqueName = advancedOptions.UniqueName; + + Database = new MetaStore.MetaStore(new MetaStoreOptions(advancedOptions.PhysicalCacheDir) + { + Shards = advancedOptions.Shards, + MaxLogFilesPerShard = advancedOptions.MaxLogFilesPerShard + }, advancedOptions, logger);; + + PathBuilder = new HashBasedPathBuilder(advancedOptions.PhysicalCacheDir, advancedOptions.Subfolders, + Path.DirectorySeparatorChar, ".jpg"); + + CleanupManager = new CleanupManager(advancedOptions.CleanupManagerOptions, Database, logger, PathBuilder); + AsyncCache = new AsyncCache(advancedOptions.AsyncCacheOptions, CleanupManager, Database, PathBuilder, logger); + } + + internal HybridCache(ICacheDatabase database, HybridCacheAdvancedOptions options, IReLogger logger) + { + this.logger = logger; + UniqueName = options.UniqueName; + + Database = database; PathBuilder = new HashBasedPathBuilder(options.PhysicalCacheDir, options.Subfolders, Path.DirectorySeparatorChar, ".jpg"); - options.CleanupManagerOptions.MoveFileOverwriteFunc = options.CleanupManagerOptions.MoveFileOverwriteFunc ?? - options.AsyncCacheOptions.MoveFileOverwriteFunc; CleanupManager = new CleanupManager(options.CleanupManagerOptions, Database, logger, PathBuilder); - AsyncCache = new AsyncCache(options.AsyncCacheOptions, CleanupManager,PathBuilder, logger); + AsyncCache = new AsyncCache(options.AsyncCacheOptions, CleanupManager, Database, PathBuilder, logger); + } + + + private void FillMoveFileOverwriteFunc(HybridCacheAdvancedOptions options) + { + var moveFileOverwriteFunc = delegate(string from, string to) + { +#if (NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER) + File.Move(from, to, true); +#else + File.Move(from, to); +#endif + }; + options.CleanupManagerOptions.MoveFileOverwriteFunc = options.CleanupManagerOptions.MoveFileOverwriteFunc ?? + options.AsyncCacheOptions.MoveFileOverwriteFunc ?? moveFileOverwriteFunc; + options.AsyncCacheOptions.MoveFileOverwriteFunc = options.AsyncCacheOptions.MoveFileOverwriteFunc ?? + options.CleanupManagerOptions.MoveFileOverwriteFunc ?? moveFileOverwriteFunc; } + public IEnumerable GetIssues() { return Enumerable.Empty(); @@ -46,7 +117,7 @@ public async Task StopAsync(CancellationToken cancellationToken) { logger?.LogInformation("HybridCache is shutting down..."); var sw = Stopwatch.StartNew(); - await AsyncCache.AwaitEnqueuedTasks(); + //await AsyncCache.AwaitEnqueuedTasks(); await Database.StopAsync(cancellationToken); sw.Stop(); logger?.LogInformation("HybridCache shut down in {ShutdownTime}", sw.Elapsed); @@ -57,17 +128,43 @@ public Task AwaitEnqueuedTasks() return AsyncCache.AwaitEnqueuedTasks(); } - /// - /// - /// - /// - /// - /// - /// - /// - public async Task GetOrCreateBytes(byte[] key, AsyncBytesResult dataProviderCallback, CancellationToken cancellationToken, bool retrieveContentType) + + public Task> CacheFetch(IBlobCacheRequest request, CancellationToken cancellationToken = default) + { + return AsyncCache.CacheFetch(request, cancellationToken); + } + + public Task CachePut(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + return AsyncCache.CachePut(e, cancellationToken); + } + + public Task>> CacheSearchByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + return AsyncCache.CacheSearchByTag(tag, cancellationToken); + } + + public Task>>> CachePurgeByTag(SearchableBlobTag tag, + CancellationToken cancellationToken = default) + { + return AsyncCache.CachePurgeByTag(tag, cancellationToken); + } + + public Task CacheDelete(IBlobStorageReference reference, CancellationToken cancellationToken = default) + { + return AsyncCache.CacheDelete(reference, cancellationToken); + } + + public Task OnCacheEvent(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + return AsyncCache.OnCacheEvent(e, cancellationToken); + } + + public BlobCacheCapabilities InitialCacheCapabilities => AsyncCache.InitialCacheCapabilities; + + public ValueTask CacheHealthCheck(CancellationToken cancellationToken = default) { - return await AsyncCache.GetOrCreateBytes(key, dataProviderCallback, cancellationToken, retrieveContentType); + return AsyncCache.CacheHealthCheck(cancellationToken); } } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/HybridCacheAdvancedOptions.cs b/src/Imazen.HybridCache/HybridCacheAdvancedOptions.cs new file mode 100644 index 00000000..f535b5ad --- /dev/null +++ b/src/Imazen.HybridCache/HybridCacheAdvancedOptions.cs @@ -0,0 +1,59 @@ +namespace Imazen.HybridCache +{ + public class HybridCacheAdvancedOptions + { + + + public HybridCacheAdvancedOptions(string uniqueName, string physicalCacheDir) + { + PhysicalCacheDir = physicalCacheDir; + UniqueName = uniqueName; + CleanupManagerOptions = new CleanupManagerOptions(); + + AsyncCacheOptions = new AsyncCacheOptions() + { + UniqueName = uniqueName + }; + } + + public AsyncCacheOptions AsyncCacheOptions { get; set; } + + public CleanupManagerOptions CleanupManagerOptions { get; set; } + + /// + /// Controls how many subfolders to use for disk caching. + /// Rounded to the next power of to. (1->2, 3->4, 5->8, 9->16, 17->32, 33->64, 65->128,129->256,etc.) + /// NTFS does not handle more than 8,000 files per folder well. + /// Defaults to 2048 + /// + public int Subfolders { get; set; } = 2048; + + + /// + /// Sets the location of the cache directory and database files. + /// + public string PhysicalCacheDir { get; set; } + + + public string UniqueName { get; } + + + /// + /// The number of shards to create. Defaults to 8 + /// + public int Shards { get; set; } = 8; + + /// + /// The maximum number of log files per shard to permit. Log files will be merged when this value is exceeded. + /// + public int MaxLogFilesPerShard { get; set; } = 5; + + /// + /// If true, cache files are first written to a temp file, then moved into their correct place. + /// Slightly slower when true. Defaults to false. + /// + public bool MoveFilesIntoPlace { get; set; } = false; + + + } +} \ No newline at end of file diff --git a/src/Imazen.HybridCache/HybridCacheOptions.cs b/src/Imazen.HybridCache/HybridCacheOptions.cs index fe73eb13..f1319784 100644 --- a/src/Imazen.HybridCache/HybridCacheOptions.cs +++ b/src/Imazen.HybridCache/HybridCacheOptions.cs @@ -1,34 +1,86 @@ -namespace Imazen.HybridCache +namespace Imazen.HybridCache { public class HybridCacheOptions { + /// + /// A unique name for this cache so it can be referenced by configuration and routes. + /// + public string UniqueName { get; set; } + + /// + /// Where to store the cached files and the database + /// + public string DiskCacheDirectory { get; set; } + + /// + /// How many RAM bytes to use when writing asynchronously to disk before we switch to writing synchronously. + /// Defaults to 100MiB. + /// + [Obsolete("This is ignored; use the new shared write queue configuration instead")] + public long QueueSizeLimitInBytes { get; set; } = 100 * 1024 * 1024; + + /// + /// Defaults to 1 GiB. Don't set below 9MB or no files will be cached, since 9MB is reserved just for empty directory entries. + /// + public long CacheSizeLimitInBytes { get; set; } = 1 * 1024 * 1024 * 1024; - public HybridCacheOptions(string physicalCacheDir) + /// + /// The minimum number of bytes to free when running a cleanup task. Defaults to 1MiB; + /// + public long MinCleanupBytes { get; set; } = 1 * 1024 * 1024; + + /// + /// How many MiB of ram to use when writing asynchronously to disk before we switch to writing synchronously. + /// Defaults to 100MiB. + /// + [Obsolete("This is ignored; use the new shared write queue configuration instead")] + public long WriteQueueMemoryMb { - PhysicalCacheDir = physicalCacheDir; - AsyncCacheOptions = new AsyncCacheOptions(); - CleanupManagerOptions = new CleanupManagerOptions(); + get => QueueSizeLimitInBytes / 1024 / 1024; + set => QueueSizeLimitInBytes = value * 1024 * 1024; } - - public AsyncCacheOptions AsyncCacheOptions { get; set; } - - public CleanupManagerOptions CleanupManagerOptions { get; set; } /// - /// Controls how many subfolders to use for disk caching. - /// Rounded to the next power of to. (1->2, 3->4, 5->8, 9->16, 17->32, 33->64, 65->128,129->256,etc.) - /// NTFS does not handle more than 8,000 files per folder well. - /// Defaults to 2048 + /// Defaults to 1 GiB. Don't set below 9MB or no files will be cached, since 9MB is reserved just for empty directory + /// entries. /// - public int Subfolders { get; set; } = 2048; + public long CacheSizeMb { + get => CacheSizeLimitInBytes / 1024 / 1024; + set => CacheSizeLimitInBytes = value * 1024 * 1024; + } - /// - /// Sets the location of the cache directory. + /// The minimum number of mibibytes (1024*1024) to free when running a cleanup task. Defaults to 1MiB; /// - public string PhysicalCacheDir { get; set; } - + public long EvictionSweepSizeMb { + get => MinCleanupBytes / 1024 / 1024; + set => MinCleanupBytes = value * 1024 * 1024; + } + + /// + /// The minimum age of files to delete. Defaults to 10 seconds. + /// + public TimeSpan MinAgeToDelete { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// The number of shards to split the metabase into. More shards means more open log files, slower shutdown. + /// But more shards also mean less lock contention and faster start time for individual cached requests. + /// Defaults to 8. You have to delete the database directory each time you change this number. + /// TODO: hash incompatible settings into a root folder inside the diskcache dir + /// + public int DatabaseShards { get; set; } = 8; + public HybridCacheOptions(string cacheDir) + { + DiskCacheDirectory = cacheDir; + UniqueName = "disk"; + } + + public HybridCacheOptions(string uniqueName, string cacheDir) + { + UniqueName = uniqueName; + DiskCacheDirectory = cacheDir; + } } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/ICacheCleanupManager.cs b/src/Imazen.HybridCache/ICacheCleanupManager.cs index fdbbaabe..4b2f6aa7 100644 --- a/src/Imazen.HybridCache/ICacheCleanupManager.cs +++ b/src/Imazen.HybridCache/ICacheCleanupManager.cs @@ -1,7 +1,7 @@ -using System; -using System.Threading; -using System.Threading.Tasks; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; using Imazen.Common.Concurrency; +using Imazen.HybridCache.MetaStore; namespace Imazen.HybridCache { @@ -12,11 +12,19 @@ internal struct ReserveSpaceResult } internal interface ICacheCleanupManager { + long EstimateFileSizeOnDisk(long byteCount); void NotifyUsed(CacheEntry cacheEntry); - Task GetContentType(CacheEntry cacheEntry, CancellationToken cancellationToken); - Task GetRecordReference(CacheEntry cacheEntry, CancellationToken cancellationToken); - Task TryReserveSpace(CacheEntry cacheEntry, string contentType, int byteCount, bool allowEviction, AsyncLockProvider writeLocks, CancellationToken cancellationToken); + Task GetRecordReference(CacheEntry cacheEntry, CancellationToken cancellationToken); + Task TryReserveSpace(CacheEntry cacheEntry, CacheDatabaseRecord newRecord, bool allowEviction, AsyncLockProvider writeLocks, CancellationToken cancellationToken); - Task MarkFileCreated(CacheEntry cacheEntry, string contentType, long recordDiskSpace, DateTime createdDate); + int GetAccessCountKey(CacheEntry cacheEntry); + + Task MarkFileCreated(CacheEntry cacheEntry, DateTime createdDate, Func createIfMissing); + + Task>> CacheSearchByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default); + Task>>> CachePurgeByTag(SearchableBlobTag tag, AsyncLockProvider writeLocks, CancellationToken cancellationToken = default); + + Task CacheDelete(string relativePath, AsyncLockProvider writeLocks, + CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/ICacheDatabase.cs b/src/Imazen.HybridCache/ICacheDatabase.cs index 265dfc42..987e8542 100644 --- a/src/Imazen.HybridCache/ICacheDatabase.cs +++ b/src/Imazen.HybridCache/ICacheDatabase.cs @@ -1,23 +1,22 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; +using Imazen.HybridCache.MetaStore; using Microsoft.Extensions.Hosting; namespace Imazen.HybridCache { - public interface ICacheDatabaseRecord + [Flags] + internal enum CacheEntryFlags : byte { - int AccessCountKey { get; } - DateTime CreatedAt { get; } - DateTime LastDeletionAttempt { get; } - long DiskSize { get; } - /// May not exist on disk - string RelativePath { get; } - string ContentType { get; } + Unknown = 0, + Proxied = 1, + Generated = 2, + Metadata = 4, + DoNotEvict = 128 } - public interface ICacheDatabase: IHostedService + internal interface ICacheDatabase: IHostedService where T:ICacheDatabaseRecord { Task UpdateLastDeletionAttempt(int shard, string relativePath, DateTime when); @@ -30,7 +29,9 @@ public interface ICacheDatabase: IHostedService /// /// /// - Task DeleteRecord(int shard, ICacheDatabaseRecord record); + Task DeleteRecord(int shard, T record); + + Task TestRootDirectory(); /// /// Return the oldest (by created date) records, sorted from oldest to newest, where @@ -44,61 +45,42 @@ public interface ICacheDatabase: IHostedService /// /// /// - Task> GetDeletionCandidates(int shard, DateTime maxLastDeletionAttemptTime, + Task> GetDeletionCandidates(int shard, DateTime maxLastDeletionAttemptTime, DateTime maxCreatedDate, int count, Func getUsageCount); + Task> LinearSearchByTag(int shard, SearchableBlobTag tag); + + Task GetShardSize(int shard); int GetShardCount(); - /// - /// Looks up the content type value by key - /// - /// - /// - /// - Task GetContentType(int shard, string relativePath); - + /// /// Looks up the record info by key /// /// /// - Task GetRecord(int shard, string relativePath); + Task GetRecord(int shard, string relativePath); /// - /// Estimate disk usage for a record with the given key length + /// Estimate disk usage for the given record /// - /// /// - int EstimateRecordDiskSpace(int stringLength); + int EstimateRecordDiskSpace(CacheDatabaseRecord newRecord); /// /// Within a lock or transaction, checks if Sum(DiskBytes) + recordDiskSpace < diskSpaceLimit, then /// creates a new record with the given key, content-type, record disk space, createdDate /// and last deletion attempt time set to the lowest possible value /// - /// - /// - /// - /// - /// - /// - /// + /// /// - Task CreateRecordIfSpace(int shard, string relativePath, string contentType, long recordDiskSpace, DateTime createdDate, int accessCountKey, long diskSpaceLimit); + Task CreateRecordIfSpace(int shard, CacheDatabaseRecord newRecord, long diskSpaceLimit); - /// - /// Should only delete the record if the createdDate matches - /// - /// - /// - /// - /// - /// - /// - /// - Task UpdateCreatedDateAtomic(int shard, string relativePath, string contentType, long recordDiskSpace, DateTime createdDate, int accessCountKey); + + Task UpdateCreatedDateAtomic(int shard, string relativePath, DateTime createdDate, + Func createIfMissing); /// /// May require creation of new record and deletion of old, since we are changing the primary key @@ -109,6 +91,8 @@ Task> GetDeletionCandidates(int shard, DateTim /// /// /// - Task ReplaceRelativePathAndUpdateLastDeletion(int shard, ICacheDatabaseRecord record, string movedRelativePath, DateTime lastDeletionAttempt); + Task ReplaceRelativePathAndUpdateLastDeletion(int shard, T record, string movedRelativePath, DateTime lastDeletionAttempt); + + Task TestMetaStore(); } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/ICacheDatabaseRecord.cs b/src/Imazen.HybridCache/ICacheDatabaseRecord.cs new file mode 100644 index 00000000..098399e2 --- /dev/null +++ b/src/Imazen.HybridCache/ICacheDatabaseRecord.cs @@ -0,0 +1,23 @@ +using Imazen.Abstractions.Blobs; +using Imazen.Common.Extensibility.Support; + +namespace Imazen.HybridCache +{ + internal interface ICacheDatabaseRecord : ICacheDatabaseRecordReference, IEstimateAllocatedBytesRecursive + { + int AccessCountKey { get; } + DateTimeOffset CreatedAt { get; } + DateTimeOffset LastDeletionAttempt { get; } + /// + /// Estimated size on disk of the blob contents, excluding all metadata + /// + long EstDiskSize { get; } + /// May not exist on disk + string RelativePath { get; } + string? ContentType { get; } + CacheEntryFlags Flags { get; } + + IReadOnlyList? Tags { get; } + + } +} \ No newline at end of file diff --git a/src/Imazen.HybridCache/ICacheDatabaseRecordReference.cs b/src/Imazen.HybridCache/ICacheDatabaseRecordReference.cs new file mode 100644 index 00000000..f31f8fd4 --- /dev/null +++ b/src/Imazen.HybridCache/ICacheDatabaseRecordReference.cs @@ -0,0 +1,6 @@ +namespace Imazen.HybridCache; + +internal interface ICacheDatabaseRecordReference +{ + string CacheReferenceKey { get; } +} \ No newline at end of file diff --git a/src/Imazen.HybridCache/Imazen.HybridCache.csproj b/src/Imazen.HybridCache/Imazen.HybridCache.csproj index e8eec214..48f3f956 100644 --- a/src/Imazen.HybridCache/Imazen.HybridCache.csproj +++ b/src/Imazen.HybridCache/Imazen.HybridCache.csproj @@ -3,15 +3,13 @@ Imageflow.HybridCache - Modern disk caching system with write-ahead-logs and strict cache size limiting. Imageflow.HybridCache - Modern disk caching system with write-ahead-logs and strict cache size limiting - true - net472;netstandard2.0 - - + + @@ -20,7 +18,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + + diff --git a/src/Imazen.HybridCache/InstanceConflictIoException.cs b/src/Imazen.HybridCache/InstanceConflictIoException.cs new file mode 100644 index 00000000..ed26f2c7 --- /dev/null +++ b/src/Imazen.HybridCache/InstanceConflictIoException.cs @@ -0,0 +1,23 @@ +// delegate to an IOException, name it InstanceConflictIoException, include a parameter for the path and shardid that caused the conflict + +namespace Imazen.HybridCache{ + + public class ImageflowMultipleHybridCacheInstancesNotSupportedIoException : Exception + { + public ImageflowMultipleHybridCacheInstancesNotSupportedIoException(IOException original, string message, string path, int shardId) : base(message) + { + Original = original; + Path = path; + ShardId = shardId; + } + + public IOException Original { get; } + public string Path { get; } + public int ShardId { get; } + + public override string ToString() + { + return $"{Message} Path: {Path} ShardId: {ShardId} IOException: {Original}"; + } + } +} \ No newline at end of file diff --git a/src/Imazen.HybridCache/MetaStore/CacheDatabaseRecord.cs b/src/Imazen.HybridCache/MetaStore/CacheDatabaseRecord.cs index 68e50ffd..a43b3b79 100644 --- a/src/Imazen.HybridCache/MetaStore/CacheDatabaseRecord.cs +++ b/src/Imazen.HybridCache/MetaStore/CacheDatabaseRecord.cs @@ -1,15 +1,67 @@ -using System; +using Imazen.Abstractions.Blobs; +using Imazen.Common.Extensibility.Support; namespace Imazen.HybridCache.MetaStore { + internal class CacheDatabaseRecord: ICacheDatabaseRecord { - public int AccessCountKey { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime LastDeletionAttempt { get; set; } - public long DiskSize { get; set; } - public string RelativePath { get; set; } - public string ContentType { get; set; } + public CacheDatabaseRecord() + { + + } + + public required int AccessCountKey { get; set; } + public required DateTimeOffset CreatedAt { get; set; } + public required DateTimeOffset LastDeletionAttempt { get; set; } + public required long EstDiskSize { get; set; } + public required string RelativePath { get; set; } + public required string? ContentType { get; set; } + public required CacheEntryFlags Flags { get; set; } + public required IReadOnlyList? Tags { get; set; } + public string CacheReferenceKey => RelativePath; + + + public int EstimateAllocatedBytesRecursive => + 24 + + AccessCountKey.EstimateMemorySize(false) + + CreatedAt.EstimateMemorySize(false) + + LastDeletionAttempt.EstimateMemorySize(false) + + EstDiskSize.EstimateMemorySize(false) + + RelativePath.EstimateMemorySize(true) + + ContentType.EstimateMemorySize(true) + + Flags.EstimateMemorySize(false) + + Tags.EstimateMemorySize(true); + + internal int EstimateSerializedRowByteCount() + { + // Presuming no utf8 chars + var count = 0; + count += 4; //AccessCountKey + count += 8; //CreatedAt + count += 8; //LastDeletionAttempt + count += 8; //DiskSize + count += 1; //Flags + + count += 4; //RelativePath length + count += RelativePath.Length; //RelativePath + count += 4; //ContentType length + count += ContentType?.Length ?? 0; //ContentType + count += 1; //Tags length + if (Tags != null) + { + foreach (var tag in Tags) + { + count += 8; //key length, value length + count += tag.Key.Length; // key + count += tag.Value.Length; //value + } + } + + return count; + } + + } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/MetaStore/DeleteRecordResult.cs b/src/Imazen.HybridCache/MetaStore/DeleteRecordResult.cs new file mode 100644 index 00000000..73793f3f --- /dev/null +++ b/src/Imazen.HybridCache/MetaStore/DeleteRecordResult.cs @@ -0,0 +1,9 @@ +namespace Imazen.HybridCache.MetaStore +{ + internal enum DeleteRecordResult + { + Deleted, + NotFound, + RecordStaleReQueryRetry + } +} \ No newline at end of file diff --git a/src/Imazen.HybridCache/MetaStore/LogEntry.cs b/src/Imazen.HybridCache/MetaStore/LogEntry.cs index 0e7f9323..76e82cf9 100644 --- a/src/Imazen.HybridCache/MetaStore/LogEntry.cs +++ b/src/Imazen.HybridCache/MetaStore/LogEntry.cs @@ -1,4 +1,4 @@ -using System; +using Imazen.Abstractions.Blobs; namespace Imazen.HybridCache.MetaStore { @@ -8,26 +8,19 @@ internal enum LogEntryType: byte Update = 1, Delete = 2 } - internal struct LogEntry + internal struct LogEntry(LogEntryType entryType, ICacheDatabaseRecord record) { - internal LogEntryType EntryType; - internal int AccessCountKey; - internal DateTime CreatedAt; - internal DateTime LastDeletionAttempt; - internal long DiskSize; - internal string RelativePath; - internal string ContentType; + internal LogEntryType EntryType = entryType; + internal int AccessCountKey = record.AccessCountKey; + internal DateTimeOffset CreatedAt = record.CreatedAt; + internal DateTimeOffset LastDeletionAttempt = record.LastDeletionAttempt; + internal long EstBlobDiskSize = record.EstDiskSize; + internal string RelativePath = record.RelativePath; + internal string? ContentType = record.ContentType; + internal CacheEntryFlags Category = record.Flags; + + internal IReadOnlyList? Tags = record.Tags; - public LogEntry(LogEntryType entryType, ICacheDatabaseRecord record) - { - EntryType = entryType; - AccessCountKey = record.AccessCountKey; - ContentType = record.ContentType; - RelativePath = record.RelativePath; - DiskSize = record.DiskSize; - LastDeletionAttempt = record.LastDeletionAttempt; - CreatedAt = record.CreatedAt; - } public CacheDatabaseRecord ToRecord() { @@ -36,9 +29,11 @@ public CacheDatabaseRecord ToRecord() AccessCountKey = AccessCountKey, ContentType = ContentType, CreatedAt = CreatedAt, - DiskSize = DiskSize, + EstDiskSize = EstBlobDiskSize, LastDeletionAttempt = LastDeletionAttempt, - RelativePath = RelativePath + RelativePath = RelativePath, + Flags = Category, + Tags = Tags }; } } diff --git a/src/Imazen.HybridCache/MetaStore/MetaStore.cs b/src/Imazen.HybridCache/MetaStore/MetaStore.cs index e7415c6e..59f0dada 100644 --- a/src/Imazen.HybridCache/MetaStore/MetaStore.cs +++ b/src/Imazen.HybridCache/MetaStore/MetaStore.cs @@ -1,37 +1,44 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; +using System.Security.Cryptography; using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Extensibility.Support; namespace Imazen.HybridCache.MetaStore { - public class MetaStore: ICacheDatabase + internal class MetaStore : ICacheDatabase { private readonly Shard[] shards; + private readonly string physicalCacheDir; - public MetaStore(MetaStoreOptions options, HybridCacheOptions cacheOptions, ILogger logger) + private static long GetDirectoryEntriesBytesTotal(HashBasedPathBuilder builder) + { + return builder.CalculatePotentialDirectoryEntriesFromSubfolderBitCount() * + CleanupManager.DirectoryEntrySize() + CleanupManager.DirectoryEntrySize(); + } + + public MetaStore(MetaStoreOptions options, HybridCacheAdvancedOptions cacheOptions, IReLogger logger) { if (options.Shards <= 0 || options.Shards > 2048) { throw new ArgumentException("Shards must be between 1 and 2048"); } + physicalCacheDir = cacheOptions.PhysicalCacheDir; var pathBuilder = new HashBasedPathBuilder(cacheOptions.PhysicalCacheDir, cacheOptions.Subfolders, '/', ".jpg"); - var directoryEntriesBytes = pathBuilder.GetDirectoryEntriesBytesTotal() + CleanupManager.DirectoryEntrySize(); + var directoryEntriesBytes = + GetDirectoryEntriesBytesTotal(pathBuilder) + CleanupManager.DirectoryEntrySize(); var shardCount = options.Shards; shards = new Shard[shardCount]; for (var i = 0; i < shardCount; i++) { - shards[i] = new Shard(i, options, Path.Combine(options.DatabaseDir,"db", i.ToString()),directoryEntriesBytes / shardCount, logger); + shards[i] = new Shard(i, options, Path.Combine(options.DatabaseDir, "db", i.ToString()), + directoryEntriesBytes / shardCount, logger); } } @@ -53,7 +60,7 @@ public Task UpdateLastDeletionAttempt(int shard, string relativePath, DateTime w public int GetShardForKey(string key) { var stringBytes = Encoding.UTF8.GetBytes(key); - + using (var h = SHA256.Create()) { var a = h.ComputeHash(stringBytes); @@ -64,20 +71,43 @@ public int GetShardForKey(string key) } } - public Task DeleteRecord(int shard, ICacheDatabaseRecord record) + public Task DeleteRecord(int shard, ICacheDatabaseRecord record) { if (record.CreatedAt > DateTime.UtcNow) throw new InvalidOperationException(); return shards[shard].DeleteRecord(record); } - public Task> GetDeletionCandidates(int shard, DateTime maxLastDeletionAttemptTime, DateTime maxCreatedDate, int count, + public Task TestRootDirectory() + { + try + { + // Try to create the root directory if it's missing + if (!Directory.Exists(physicalCacheDir)) + Directory.CreateDirectory(physicalCacheDir); + // if it exists we assume that it is readable recursively + return Task.FromResult(CodeResult.Ok()); + } + catch (Exception ex) + { + return Task.FromResult(CodeResult.FromException(ex)); + } + + } + + public Task> GetDeletionCandidates(int shard, + DateTime maxLastDeletionAttemptTime, DateTime maxCreatedDate, int count, Func getUsageCount) { return shards[shard] .GetDeletionCandidates(maxLastDeletionAttemptTime, maxCreatedDate, count, getUsageCount); } + public Task> LinearSearchByTag(int shard, SearchableBlobTag tag) + { + return shards[shard].LinearSearchByTag(tag); + } + public Task GetShardSize(int shard) { return shards[shard].GetShardSize(); @@ -88,37 +118,53 @@ public int GetShardCount() return shards.Length; } - public Task GetContentType(int shard, string relativePath) + public Task GetRecord(int shard, string relativePath) { - return shards[shard].GetContentType(relativePath); + return shards[shard].GetRecord(relativePath); } - public Task GetRecord(int shard, string relativePath) + public int EstimateRecordDiskSpace(CacheDatabaseRecord newRecord) { - return shards[shard].GetRecord(relativePath); + return Shard.GetLogBytesOverhead(newRecord); } - public int EstimateRecordDiskSpace(int stringLength) + public Task CreateRecordIfSpace(int shard, CacheDatabaseRecord newRecord, long diskSpaceLimit) { - return Shard.GetLogBytesOverhead(stringLength); + return shards[shard].CreateRecordIfSpace(newRecord, diskSpaceLimit); } - public Task CreateRecordIfSpace(int shard, string relativePath, string contentType, long recordDiskSpace, DateTime createdDate, - int accessCountKey, long diskSpaceLimit) + public Task UpdateCreatedDateAtomic(int shard, string relativePath, DateTime createdDate, + Func createIfMissing) { - return shards[shard].CreateRecordIfSpace(relativePath, contentType, recordDiskSpace, createdDate, - accessCountKey, diskSpaceLimit); + return shards[shard].UpdateCreatedDateAtomic(relativePath, createdDate, createIfMissing); } - public Task UpdateCreatedDateAtomic(int shard, string relativePath, string contentType, long recordDiskSpace, DateTime createdDate, int accessCountKey) + public Task ReplaceRelativePathAndUpdateLastDeletion(int shard, ICacheDatabaseRecord record, + string movedRelativePath, + DateTime lastDeletionAttempt) { - return shards[shard].UpdateCreatedDate(relativePath, contentType, recordDiskSpace, createdDate, accessCountKey); + return shards[shard] + .ReplaceRelativePathAndUpdateLastDeletion(record, movedRelativePath, lastDeletionAttempt); } - public Task ReplaceRelativePathAndUpdateLastDeletion(int shard, ICacheDatabaseRecord record, string movedRelativePath, - DateTime lastDeletionAttempt) + public async Task TestMetaStore() { - return shards[shard].ReplaceRelativePathAndUpdateLastDeletion(record, movedRelativePath, lastDeletionAttempt); + try + { + var lastFailed = shards.FirstOrDefault(s => s.FailedToStart); + if (lastFailed != null) + { + await lastFailed.TryStart(); + } + + await Task.WhenAll(shards.Select(s => s.TryStart())); + + return CodeResult.Ok(); + } + catch (Exception ex) + { + return CodeResult.FromException(ex); + } } } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/MetaStore/MetaStoreOptions.cs b/src/Imazen.HybridCache/MetaStore/MetaStoreOptions.cs index 25e1a7a9..bf09dac0 100644 --- a/src/Imazen.HybridCache/MetaStore/MetaStoreOptions.cs +++ b/src/Imazen.HybridCache/MetaStore/MetaStoreOptions.cs @@ -1,6 +1,6 @@ namespace Imazen.HybridCache.MetaStore { - public class MetaStoreOptions + internal class MetaStoreOptions { public MetaStoreOptions(string databaseDir) { diff --git a/src/Imazen.HybridCache/MetaStore/Shard.cs b/src/Imazen.HybridCache/MetaStore/Shard.cs index 4635aa57..ef3deee6 100644 --- a/src/Imazen.HybridCache/MetaStore/Shard.cs +++ b/src/Imazen.HybridCache/MetaStore/Shard.cs @@ -1,9 +1,5 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using Imazen.Abstractions.Blobs; using Imazen.Common.Concurrency; using Microsoft.Extensions.Logging; @@ -15,7 +11,7 @@ internal class Shard private readonly ILogger logger; private readonly int shardId; - private volatile ConcurrentDictionary dict; + private volatile ConcurrentDictionary? dict; private readonly BasicAsyncLock readLock = new BasicAsyncLock(); private readonly BasicAsyncLock createLock = new BasicAsyncLock(); @@ -29,16 +25,35 @@ internal Shard(int shardId, MetaStoreOptions options, string databaseDir, long d private async Task> GetLoadedDict() { - if (dict != null) return dict; - using (var unused = await readLock.LockAsync()) - { + if (dict != null) return dict; + using (var unused = await readLock.LockAsync()) + { + if (dict != null) return dict; + try + { + dict = await writeLog.Startup(); + } + catch (Exception) + { + FailedToStart = true; + throw; + } + FailedToStart = false; + } - dict = await writeLog.Startup(); - } - return dict; + return dict; + + } + + internal bool FailedToStart{ get; set; } + + internal Task TryStart() + { + return GetLoadedDict(); } + public Task StartAsync(CancellationToken cancellationToken) { return Task.CompletedTask; @@ -56,8 +71,8 @@ public async Task UpdateLastDeletionAttempt(string relativePath, DateTime when) record.LastDeletionAttempt = when; } } - - public async Task DeleteRecord(ICacheDatabaseRecord oldRecord) + + public async Task DeleteRecord(ICacheDatabaseRecord oldRecord) { using (await createLock.LockAsync()) { @@ -68,13 +83,18 @@ public async Task DeleteRecord(ICacheDatabaseRecord oldRecord) logger?.LogError( "DeleteRecord tried to delete a different instance of the record than the one provided. Re-inserting in {ShardId}", shardId); (await GetLoadedDict()).TryAdd(oldRecord.RelativePath, currentRecord); - + return DeleteRecordResult.RecordStaleReQueryRetry; } else { await writeLog.LogDeleted(oldRecord); + return DeleteRecordResult.Deleted; } } + else + { + return DeleteRecordResult.NotFound; + } } } @@ -82,7 +102,9 @@ public async Task> GetDeletionCandidates( DateTime maxLastDeletionAttemptTime, DateTime maxCreatedDate, int count, Func getUsageCount) { var results = (await GetLoadedDict()).Values - .Where(r => r.CreatedAt < maxCreatedDate && r.LastDeletionAttempt < maxLastDeletionAttemptTime) + .Where(r => r.CreatedAt < maxCreatedDate && r.LastDeletionAttempt < maxLastDeletionAttemptTime && + !r.Flags.HasFlag(CacheEntryFlags.DoNotEvict)) + .OrderBy(r => (byte)r.Flags) .Select(r => new Tuple(r, getUsageCount(r.AccessCountKey))) .OrderByDescending(t => t.Item2) .Select(t => (ICacheDatabaseRecord) t.Item1) @@ -96,12 +118,12 @@ public async Task GetShardSize() await GetLoadedDict(); return writeLog.GetDiskSize(); } - public async Task GetContentType(string relativePath) + public async Task GetContentType(string relativePath) { return (await GetRecord(relativePath))?.ContentType; } - public async Task GetRecord(string relativePath) + public async Task GetRecord(string relativePath) { return (await GetLoadedDict()).TryGetValue(relativePath, out var record) ? record: @@ -109,46 +131,34 @@ public async Task GetRecord(string relativePath) } - internal static int GetLogBytesOverhead(int stringLength) + internal static int GetLogBytesOverhead(CacheDatabaseRecord newRecord) { - return 4 * (128 + stringLength); + return (newRecord.EstimateSerializedRowByteCount() + 1) * 4; //4x for create, update, rename, delete entries } - public async Task CreateRecordIfSpace(string relativePath, string contentType, long recordDiskSpace, - DateTime createdDate, - int accessCountKey, long diskSpaceLimit) + public async Task CreateRecordIfSpace(CacheDatabaseRecord newRecord, long diskSpaceLimit) { // Lock so multiple writers can't get past the capacity check simultaneously using (await createLock.LockAsync()) { var loadedDict = await GetLoadedDict(); - var extraLogBytes = GetLogBytesOverhead(relativePath.Length + (contentType?.Length ?? 0)); + var extraLogBytes = GetLogBytesOverhead(newRecord); var existingDiskUsage = await GetShardSize(); - if (existingDiskUsage + recordDiskSpace + extraLogBytes > diskSpaceLimit) + if (existingDiskUsage + newRecord.EstDiskSize + extraLogBytes > diskSpaceLimit) { //logger?.LogInformation("Refusing new {RecordDiskSpace} byte record in shard {ShardId} of MetaStore", // recordDiskSpace, shardId); return false; } - - var newRecord = new CacheDatabaseRecord() - { - AccessCountKey = accessCountKey, - ContentType = contentType, - CreatedAt = createdDate, - DiskSize = recordDiskSpace, - LastDeletionAttempt = DateTime.MinValue, - RelativePath = relativePath - }; - - if (loadedDict.TryAdd(relativePath, newRecord)) + + if (loadedDict.TryAdd(newRecord.RelativePath, newRecord)) { await writeLog.LogCreated(newRecord); } else { - logger?.LogWarning("CreateRecordIfSpace did nothing - database entry already exists - {Path}", relativePath); + logger?.LogWarning("CreateRecordIfSpace did nothing - database entry already exists - {Path}", newRecord.RelativePath); } } @@ -156,7 +166,7 @@ public async Task CreateRecordIfSpace(string relativePath, string contentT } - public async Task UpdateCreatedDate(string relativePath, string contentType, long recordDiskSpace, DateTime createdDate, int accessCountKey) + public async Task UpdateCreatedDateAtomic(string relativePath, DateTime createdDate,Func createIfMissing) { using (await createLock.LockAsync()) { @@ -167,18 +177,8 @@ public async Task UpdateCreatedDate(string relativePath, string contentType, lon } else { - var record = (await GetLoadedDict()).GetOrAdd(relativePath, (s) => - - new CacheDatabaseRecord() - { - AccessCountKey = accessCountKey, - ContentType = contentType, - CreatedAt = createdDate, - DiskSize = recordDiskSpace, - LastDeletionAttempt = DateTime.MinValue, - RelativePath = relativePath - } - ); + var record = (await GetLoadedDict()).GetOrAdd(relativePath, (s) => createIfMissing()); + record.CreatedAt = createdDate; await writeLog.LogCreated(record); logger?.LogError("HybridCache UpdateCreatedDate had to recreate entry - {Path}", relativePath); @@ -192,10 +192,12 @@ public async Task ReplaceRelativePathAndUpdateLastDeletion(ICacheDatabaseRecord { var newRecord = new CacheDatabaseRecord() { + Flags = record.Flags, + Tags = record.Tags, AccessCountKey = record.AccessCountKey, ContentType = record.ContentType, CreatedAt = record.CreatedAt, - DiskSize = record.DiskSize, + EstDiskSize = record.EstDiskSize, LastDeletionAttempt = lastDeletionAttempt, RelativePath = movedRelativePath }; @@ -211,5 +213,12 @@ public async Task ReplaceRelativePathAndUpdateLastDeletion(ICacheDatabaseRecord } await DeleteRecord(record); } + + public async Task> LinearSearchByTag(SearchableBlobTag tag) + { + var loadedDict = await GetLoadedDict(); + return loadedDict.Values.Where(r => r.Tags?.Contains(tag) ?? false).ToArray(); + + } } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/MetaStore/WriteLog.cs b/src/Imazen.HybridCache/MetaStore/WriteLog.cs index df62125b..ea210f3c 100644 --- a/src/Imazen.HybridCache/MetaStore/WriteLog.cs +++ b/src/Imazen.HybridCache/MetaStore/WriteLog.cs @@ -1,34 +1,33 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Diagnostics; using System.Globalization; -using System.IO; -using System.Linq; using System.Text; -using System.Threading; -using System.Threading.Tasks; +using System.Text.RegularExpressions; +using Imazen.Abstractions.Blobs; using Microsoft.Extensions.Logging; namespace Imazen.HybridCache.MetaStore { internal class WriteLog { + // Increment this if the log format changes + const int LogFormatVersion = 2; private readonly string databaseDir; private readonly MetaStoreOptions options; private readonly ILogger logger; private long startedAt; private bool startupComplete; - private FileStream writeLogStream; - private BinaryWriter binaryLogWriter; + private FileStream? writeLogStream; + private BinaryWriter? binaryLogWriter; private readonly object writeLock = new object(); private readonly int shardId; private long previousLogsBytes; private long logBytes; private long diskBytes; private readonly long directoryEntriesBytes; - - public WriteLog(int shardId, string databaseDir, MetaStoreOptions options, long directoryEntriesBytes, ILogger logger) + + public WriteLog(int shardId, string databaseDir, MetaStoreOptions options, long directoryEntriesBytes, + ILogger logger) { this.shardId = shardId; this.databaseDir = databaseDir; @@ -54,12 +53,13 @@ public Task StopAsync(CancellationToken cancellationToken) private void CreateWriteLog(long startedAtTick) { - var writeLogPath = Path.Combine(databaseDir, startedAtTick.ToString().PadLeft(20, '0') + ".metastore"); + var writeLogPath = Path.Combine(databaseDir, + $"{startedAtTick.ToString().PadLeft(20, '0')}.v{LogFormatVersion}.metastore"); try { writeLogStream = new FileStream(writeLogPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.SequentialScan); - binaryLogWriter = new BinaryWriter(writeLogStream, Encoding.UTF8,true); + binaryLogWriter = new BinaryWriter(writeLogStream, Encoding.UTF8, true); } catch (IOException ioException) { @@ -68,6 +68,13 @@ private void CreateWriteLog(long startedAtTick) } } + // create a record containing a version int and a FileStream instance + private class FileStreamWithVersion(int version, FileStream stream) + { + public int Version { get; } = version; + public FileStream Stream { get; } = stream; + } + // Returns a dictionary of the database public async Task> Startup() { @@ -82,17 +89,19 @@ public async Task> Startup() var orderedLogs = rawFiles.Select(path => { var filename = Path.GetFileNameWithoutExtension(path); + // Reject non metastore files + if (!path.EndsWith(".metastore")) return null; return long.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out long result) ? new Tuple(path, result) : null; }) - .Where(t => t != null) + .Where(t => t != null).Cast>() .OrderBy(t => t.Item2) .ToArray(); - - + + // Read in all the log files - var openedFiles = new List(orderedLogs.Length); + var openedFiles = new List(orderedLogs.Length); long openedLogFileBytes = 0; List[] logSets; try @@ -104,21 +113,32 @@ public async Task> Startup() foreach (var t in orderedLogs) { lastOpenPath = t.Item1; - var fs = new FileStream(t.Item1, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, + // parse the version from .v3.metastore in the path. Assume v1 if no .v[0-9]+.metastore suffix + var version = 1; + var versionMatch = Regex.Match(lastOpenPath, @"\.v([0-9]+)\.metastore$"); + if (versionMatch.Success) + { + version = int.Parse(versionMatch.Groups[1].Value); + } + var fs = new FileStream(lastOpenPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.SequentialScan); - openedFiles.Add(fs); - openedLogFileBytes += CleanupManager.EstimateEntryBytesWithOverhead(fs.Length); + openedFiles.Add(new FileStreamWithVersion(version, fs)); + openedLogFileBytes += CleanupManager.EstimateFileSizeOnDiskFor(fs.Length); } } catch (IOException ioException) { - logger?.LogError(ioException, "Failed to open log file {Path} for reading in shard {ShardId}. Perhaps another process is still writing to the log?", lastOpenPath, shardId); - throw; + logger?.LogError(ioException, + "Failed to open log file {Path} for reading in shard {ShardId}. Perhaps another process is still writing to the log?", + lastOpenPath, shardId); + throw new ImageflowMultipleHybridCacheInstancesNotSupportedIoException(ioException, + "Multiple instances of HybridCache are attempting to write to the same database directory. This is not supported.", + lastOpenPath, shardId); } - + // Open the write log file, even if we won't use it immediately. CreateWriteLog(startedAt); - + //Read all logs in logSets = await Task.WhenAll(openedFiles.Select(ReadLogEntries)); } @@ -128,7 +148,7 @@ public async Task> Startup() { try { - var stream = openedFiles[ix]; + var stream = openedFiles[ix].Stream; stream.Close(); stream.Dispose(); } @@ -138,7 +158,7 @@ public async Task> Startup() } } } - + // Create dictionary from logs var totalEntryEst = logSets.Sum(set => set.Count); var dict = new ConcurrentDictionary(Environment.ProcessorCount, @@ -149,7 +169,8 @@ public async Task> Startup() { if (!dict.TryRemove(entry.RelativePath, out var unused)) { - logger?.LogWarning("Log entry deletion referred to nonexistent record: {Path}", entry.RelativePath); + logger?.LogWarning("Log entry deletion referred to nonexistent record: {Path}", + entry.RelativePath); } } @@ -159,6 +180,7 @@ public async Task> Startup() { logger?.LogWarning("Log entry update refers to non-existent record {Path}", entry.RelativePath); } + dict[entry.RelativePath] = entry.ToRecord(); } @@ -166,12 +188,13 @@ public async Task> Startup() { if (!dict.TryAdd(entry.RelativePath, entry.ToRecord())) { - logger?.LogWarning("Log entry creation failed as record already exists {Path}", entry.RelativePath); + logger?.LogWarning("Log entry creation failed as record already exists {Path}", + entry.RelativePath); } } } - + // Do consolidation if needed if (orderedLogs.Length >= options.MaxLogFilesPerShard) { @@ -180,6 +203,11 @@ public async Task> Startup() { await WriteLogEntry(new LogEntry(LogEntryType.Create, record), false); } + if (writeLogStream == null) + { + throw new InvalidOperationException("writeLogStream is null"); + } + await writeLogStream.FlushAsync(); // Delete old logs @@ -198,24 +226,24 @@ public async Task> Startup() else { previousLogsBytes += openedLogFileBytes; - diskBytes += dict.Values.Sum(r => r.DiskSize); + diskBytes += dict.Values.Sum(r => r.EstDiskSize); } startupComplete = true; return dict; } - private Task> ReadLogEntries(FileStream fs) + private Task> ReadLogEntries(FileStreamWithVersion file) { - using (var binaryReader = new BinaryReader(fs, Encoding.UTF8, true)) + using (var binaryReader = new BinaryReader(file.Stream, Encoding.UTF8, true)) { - var rows = new List((int)(fs.Length / 100)); + var rows = new List((int)(file.Stream.Length / 100)); while (true) { var entry = new LogEntry(); try { - entry.EntryType = (LogEntryType) binaryReader.ReadByte(); + entry.EntryType = (LogEntryType)binaryReader.ReadByte(); } catch (EndOfStreamException) { @@ -228,10 +256,39 @@ private Task> ReadLogEntries(FileStream fs) entry.ContentType = binaryReader.ReadString(); // We convert empty strings to null if (entry.ContentType == "") entry.ContentType = null; - entry.CreatedAt = DateTime.FromBinary(binaryReader.ReadInt64()); - entry.LastDeletionAttempt = DateTime.FromBinary(binaryReader.ReadInt64()); - entry.AccessCountKey = binaryReader.ReadInt32(); - entry.DiskSize = binaryReader.ReadInt64(); + if (file.Version >= 2) + { + // Version 2 and above have a category and tags + entry.CreatedAt = new DateTimeOffset(binaryReader.ReadInt64(), TimeSpan.Zero); + entry.LastDeletionAttempt = new DateTimeOffset(binaryReader.ReadInt64(), TimeSpan.Zero); + entry.AccessCountKey = binaryReader.ReadInt32(); + entry.EstBlobDiskSize = binaryReader.ReadInt64(); + // Version 2 and above have a disk size + entry.Category = (CacheEntryFlags)binaryReader.ReadByte(); + // and tags + var tagCount = binaryReader.ReadByte(); + if (tagCount > 0) + { + var tags = new List(tagCount); + for (var i = 0; i < tagCount; i++) + { + var key = binaryReader.ReadString(); + var value = binaryReader.ReadString(); + + tags.Add(SearchableBlobTag.CreateUnvalidated(key, value)); + } + + entry.Tags = tags; + } + } + else + { + entry.CreatedAt = DateTime.FromBinary(binaryReader.ReadInt64()); + entry.LastDeletionAttempt = DateTime.FromBinary(binaryReader.ReadInt64()); + entry.AccessCountKey = binaryReader.ReadInt32(); + entry.EstBlobDiskSize = binaryReader.ReadInt64(); + entry.Category = CacheEntryFlags.Unknown; + } rows.Add(entry); } catch (EndOfStreamException e) @@ -243,34 +300,50 @@ private Task> ReadLogEntries(FileStream fs) } } + private const byte EndOfRecord = 0xB0; private Task WriteLogEntry(LogEntry entry, bool flush) { lock (writeLock) { if (startedAt == 0) throw new InvalidOperationException("WriteLog cannot be used before calling Startup()"); - if (writeLogStream == null) + if (writeLogStream == null || binaryLogWriter == null) throw new InvalidOperationException("WriteLog cannot be after StopAsync is called"); + var startPos = writeLogStream.Position; - binaryLogWriter.Write((byte) entry.EntryType); + binaryLogWriter.Write((byte)entry.EntryType); binaryLogWriter.Write(entry.RelativePath); binaryLogWriter.Write(entry.ContentType ?? ""); - binaryLogWriter.Write(entry.CreatedAt.ToBinary()); - binaryLogWriter.Write(entry.LastDeletionAttempt.ToBinary()); + binaryLogWriter.Write(entry.CreatedAt.UtcTicks); + binaryLogWriter.Write(entry.LastDeletionAttempt.UtcTicks); + binaryLogWriter.Write(entry.AccessCountKey); - binaryLogWriter.Write(entry.DiskSize); + binaryLogWriter.Write(entry.EstBlobDiskSize); + binaryLogWriter.Write((byte)entry.Category); + var tagCount = entry.Tags?.Count ?? 0; + if (tagCount > 255) + { + throw new InvalidOperationException("Cannot write more than 255 tags per record"); + } + binaryLogWriter.Write((byte)tagCount); + foreach (var tag in entry.Tags ?? Enumerable.Empty()) + { + binaryLogWriter.Write(tag.Key); + binaryLogWriter.Write(tag.Value); + } + if (flush) { binaryLogWriter.Flush(); } // Increase the log bytes by the number of bytes we wrote - logBytes += Math.Max(0,writeLogStream.Position - startPos); + logBytes += Math.Max(0, writeLogStream.Position - startPos); // On create events, increase the disk bytes if (entry.EntryType == LogEntryType.Create) - diskBytes += entry.DiskSize; + diskBytes += entry.EstBlobDiskSize; if (entry.EntryType == LogEntryType.Delete) - diskBytes -= entry.DiskSize; + diskBytes -= entry.EstBlobDiskSize; return Task.CompletedTask; } } @@ -293,7 +366,8 @@ public Task LogUpdated(CacheDatabaseRecord updatedRecord) public long GetDiskSize() { - return diskBytes + CleanupManager.EstimateEntryBytesWithOverhead(logBytes) + previousLogsBytes + directoryEntriesBytes; + return diskBytes + CleanupManager.EstimateFileSizeOnDiskFor(logBytes) + previousLogsBytes + + directoryEntriesBytes; } } } \ No newline at end of file diff --git a/src/Imazen.HybridCache/VisibleToTests.cs b/src/Imazen.HybridCache/VisibleToTests.cs index fb6fe6c4..15a7891d 100644 --- a/src/Imazen.HybridCache/VisibleToTests.cs +++ b/src/Imazen.HybridCache/VisibleToTests.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Imazen.HybridCache.Tests")] +[assembly: InternalsVisibleTo("Imazen.HybridCache.Benchmark")] \ No newline at end of file diff --git a/src/Imazen.HybridCache/packages.lock.json b/src/Imazen.HybridCache/packages.lock.json index b2d096ae..0a397fca 100644 --- a/src/Imazen.HybridCache/packages.lock.json +++ b/src/Imazen.HybridCache/packages.lock.json @@ -1,36 +1,53 @@ { "version": 1, "dependencies": { - ".NETFramework,Version=v4.7.2": { + ".NETStandard,Version=v2.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", "requested": "[3.3.4, )", "resolved": "3.3.4", "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" }, - "Microsoft.NETFramework.ReferenceAssemblies": { + "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.0.3, )", - "resolved": "1.0.3", - "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.NETFramework.ReferenceAssemblies.net472": "1.0.3" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, - "Microsoft.SourceLink.GitHub": { + "NETStandard.Library": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "System.Interactive.Async": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" } }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -78,29 +95,37 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, - "Microsoft.NETFramework.ReferenceAssemblies.net472": { + "Microsoft.NETCore.Platforms": { "type": "Transitive", - "resolved": "1.0.3", - "contentHash": "0E7evZXHXaDYYiLRfpyXvCh+yzM2rNTyuZDI+ZO7UUqSc6GfjePiXTdqJGtgIKUwdI81tzQKmaWprnUiPj9hAw==" + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, "System.Buffers": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "AwarXzzoDwX6BgrhjoJsk6tUezZEozOT5Y9QKF94Gl4JK91I4PIIBkBco9068Y9/Dra8Dkbie99kXB8+1BaYKw==" + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } }, "System.Memory": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", "dependencies": { - "System.Buffers": "4.4.0", + "System.Buffers": "4.5.1", "System.Numerics.Vectors": "4.4.0", - "System.Runtime.CompilerServices.Unsafe": "4.5.0" + "System.Runtime.CompilerServices.Unsafe": "4.5.3" } }, "System.Numerics.Vectors": { @@ -110,17 +135,47 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } } }, - ".NETStandard,Version=v2.0": { + "net6.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", "requested": "[3.3.4, )", @@ -129,27 +184,32 @@ }, "Microsoft.SourceLink.GitHub": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "IaJGnOv/M7UQjRJks7B6p7pbPnOwisYGOIzqCz5ilGFTApZ3ktOR+6zJ12ZRPInulBmdAf1SrGdDG2MU8g6XTw==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", "dependencies": { - "Microsoft.Build.Tasks.Git": "1.1.1", - "Microsoft.SourceLink.Common": "1.1.1" + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" } }, - "NETStandard.Library": { + "System.Interactive.Async": { "type": "Direct", - "requested": "[2.0.3, )", - "resolved": "2.0.3", - "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0" + "System.Linq.Async": "6.0.1" } }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "AT3HlgTjsqHnWpBHSNeR0KxbLZD7bztlZVj7I8vgeYG9SYqbeFGh0TM/KVtC6fg53nrWHl3VfZFvb5BiQFcY6Q==" + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -197,44 +257,182 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, - "Microsoft.NETCore.Platforms": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" - }, "Microsoft.SourceLink.Common": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "WMcGpWKrmJmzrNeuaEb23bEMnbtR/vLmvZtkAP5qWu7vQsY59GqfRJd65sFpBszbd2k/bQ8cs8eWawQKAabkVg==" + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" }, - "System.Buffers": { + "System.Linq.Async": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "AwarXzzoDwX6BgrhjoJsk6tUezZEozOT5Y9QKF94Gl4JK91I4PIIBkBco9068Y9/Dra8Dkbie99kXB8+1BaYKw==" + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } }, "System.Memory": { "type": "Transitive", "resolved": "4.5.1", - "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", "dependencies": { - "System.Buffers": "4.4.0", - "System.Numerics.Vectors": "4.4.0", - "System.Runtime.CompilerServices.Unsafe": "4.5.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.Numerics.Vectors": { + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + } + }, + "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[3.3.4, )", + "resolved": "3.3.4", + "contentHash": "kNLTfXtXUWDHVt5iaPkkiPuyHYlMgLI6SOFT4w88bfeI2vqSeGgHunFkdvlaCM8RDfcY0t2+jnesQtidRJJ/DA==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "System.Interactive.Async": { + "type": "Direct", + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "System.Runtime.CompilerServices.Unsafe": { + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "System.Memory": { "type": "Transitive", "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } } diff --git a/src/Imazen.Routing/Caching/BlobCacheEngine.cs.txt b/src/Imazen.Routing/Caching/BlobCacheEngine.cs.txt new file mode 100644 index 00000000..a9082ae5 --- /dev/null +++ b/src/Imazen.Routing/Caching/BlobCacheEngine.cs.txt @@ -0,0 +1,262 @@ +using System.Diagnostics; +using Imazen.Common.Concurrency; +using Imazen.Common.Concurrency.BoundedTaskCollection; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Extensibility.StreamCache; +using Imazen.Common.Extensibility.Support; +using Imazen.Common.Issues; +using Microsoft.Extensions.Logging; + +namespace Imageflow.Server.Caching; + +internal readonly struct BlobCacheEntry +{ + public BlobCacheEntry(byte[] keyBasis, HashBasedPathBuilder builder) + { + Hash = builder.HashKeyBasis(keyBasis); + RelativePath = builder.GetRelativePathFromHash(Hash); + PhysicalPath = builder.GetPhysicalPathFromRelativePath(RelativePath); + HashString = builder.GetStringFromHash(Hash); + } + + public byte[] Hash { get; } + public string PhysicalPath { get; } + public string HashString { get; } + + public string RelativePath { get; } + +} + +internal enum AsyncCacheDetailResult +{ + Unknown = 0, + MemoryHit, + QueueLockTimeoutAndCreated, + Miss, + QueueLockTimeoutAndFailed, + BlobCacheHit +} +internal class AsyncBlobCacheResult : IStreamCacheResult +{ + public Stream? Data { get; set; } + public string? ContentType { get; set; } + + public DateTime? CreatedAt { get; set; } + + public string Status => Detail.ToString(); + + public AsyncCacheDetailResult Detail { get; set; } +} +// +// internal class CacheSequence : INamedStreamCache +// { +// public CacheSequence(List caches, IReLogger logger) +// { +// Caches = caches; +// name = +// string.Join('>', caches.Select(c => c.UniqueName).ToArray()); +// +// markedNames = caches.Select(active => +// string.Join('>', caches.Select(c => +// c.UniqueName == active.UniqueName ? $"({c.UniqueName})" : c.UniqueName).ToArray()) +// ).ToArray(); +// +// +// } +// +// private readonly string name; +// private readonly string[] markedNames; +// internal List Caches { get; } + +internal class BlobCacheEngine +{ + + IReLogger Logger { get; } + + public BlobCacheEngine(IBlobCache blobCache, BlobCacheEngineOptions options, IReLogger parentLogger) + { + BlobCache = blobCache; + QueueLocks = new AsyncLockProvider(); + CurrentWrites = new BoundedTaskCollection(options.MaxQueueBytes); + Options = options; + Logger = parentLogger.WithSubcategory($"BlobCacheEngine({blobCache.GetType().Name}:{blobCache.UniqueName})"); + HashPathBuilder = new HashBasedPathBuilder("/", 256, '/', ""); + UniqueName = $"{BlobCache.UniqueName}[adapted]"; + } + + public HashBasedPathBuilder HashPathBuilder { get; } + public BlobCacheEngineOptions Options { get; } + public IBlobCache BlobCache { get; } + public string UniqueName { get; private set; } + + /// + /// Provides string-based locking for image resizing (not writing, just processing). Prevents duplication of efforts in asynchronous mode, where 'Locks' is not being used. + /// + private AsyncLockProvider QueueLocks { get; } + + /// + /// Contains all the queued and in-progress writes to the cache. + /// + private BoundedTaskCollection CurrentWrites { get; } + + public IEnumerable GetIssues() + { + // Probably not relevant since the migration. + if (BlobCache is IIssueProvider provider) return provider.GetIssues(); + return System.Array.Empty(); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + //TODO: Fetch the probability blob. + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + //TODO: Save the probability blob + Logger?.LogInformation("BlobCacheEngine is shutting down..."); + var sw = Stopwatch.StartNew(); + await AwaitEnqueuedTasks(); + sw.Stop(); + Logger?.LogInformation("BlobCacheEngine shut down in {ShutdownTime}", sw.Elapsed); + } + + + + public Task AwaitEnqueuedTasks() + { + return CurrentWrites.AwaitAllCurrentTasks(); + } + + public async Task GetOrCreateBytes(byte[] key, AsyncBytesResult dataProviderCallback, + CancellationToken cancellationToken, + bool retrieveContentType) + { + var swGetOrCreateBytes = Stopwatch.StartNew(); + var entry = new BlobCacheEntry(key, HashPathBuilder); + var cacheResult = new AsyncBlobCacheResult(); + var swFileExists = Stopwatch.StartNew(); + + var queueLockComplete = await QueueLocks.TryExecuteAsync(entry.HashString, + Options.WaitForIdenticalRequestsTimeoutMs, cancellationToken, + async () => + { + var swInsideQueueLock = Stopwatch.StartNew(); + + // Now, if the item we seek is in the queue, we have a memcached hit. + // If not, we should check the filesystem. It's possible the item has been written to disk already. + // If both are a miss, we should see if there is enough room in the write queue. + // If not, switch to in-thread writing. + + var existingQueuedWrite = CurrentWrites.Get(entry.HashString); + + if (existingQueuedWrite != null) + { + cacheResult.Data = existingQueuedWrite.GetReadonlyStream(); + cacheResult.CreatedAt = null; // Hasn't been written yet + cacheResult.ContentType = existingQueuedWrite.Blob.Attributes.ContentType; + cacheResult.Detail = AsyncCacheDetailResult.MemoryHit; + return; + } + + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + swFileExists.Start(); + + var cacheRequest = new BlobCacheRequest(BlobGroup.GeneratedCacheEntry, entry.Hash, entry.HashString, false); + + // Fast path on disk hit, now that we're in a synchronized state + var blobFetchResult = + await BlobCache.CacheFetch(cacheRequest, CancellationToken.None); + if (blobFetchResult.IsOk) + { + var value = blobFetchResult.Unwrap(); + cacheResult = new AsyncBlobCacheResult + { + Data = value.TakeOrMakeConsumable().TakeStream(), //TODO: review if this the right lifetime + ContentType = value.Attributes.ContentType, + Detail = AsyncCacheDetailResult.BlobCacheHit + }; + return; + } + + swFileExists.Stop(); + + var swDataCreation = Stopwatch.StartNew(); + //Read, resize, process, and encode the image. Lots of exceptions thrown here. + var result = await dataProviderCallback(cancellationToken); + swDataCreation.Stop(); + + var blobData = new ReusableArraySegmentBlob(result.Bytes, new BlobAttributes(){ContentType = result.ContentType}); + + //Create AsyncWrite object to enqueue + var w = new BlobTaskItem(entry.HashString, blobData); + + cacheResult.Detail = AsyncCacheDetailResult.Miss; + cacheResult.ContentType = w.Blob.Attributes.ContentType; + cacheResult.CreatedAt = null; // Hasn't been written yet. + cacheResult.Data = w.GetReadonlyStream(); + + var swEnqueue = Stopwatch.StartNew(); + var queueResult = CurrentWrites.Queue(w, async delegate + { + try + { + var putResult = await BlobCache.CachePut(BlobGroup.GeneratedCacheEntry, + w.UniqueKey, new BytesBlobData(result.Bytes, result.ContentType), + new CacheEventDetails(), CancellationToken.None); + if (!putResult.IsOk) + { + Logger?.LogError("BlobCacheEngine failed to write {Path} to blob cache, {Reason}", + entry.RelativePath, putResult.StatusMessage); + } + } + catch (Exception ex) + { + Logger?.LogError(ex, + "BlobCacheEngine failed to flush async write, {Exception} {Path}\n{StackTrace}", + ex.ToString(), + entry.RelativePath, ex.StackTrace); + } + + }); + swEnqueue.Stop(); + swInsideQueueLock.Stop(); + swGetOrCreateBytes.Stop(); + if (queueResult == BoundedTaskCollection.EnqueueResult.QueueFull) + { + // TODO: Log that we had to discard writes because the queue RAM limit was reached + Logger?.LogWarning("BlobCacheEngine queue full, discarding write for {Path}", + entry.RelativePath); + } + }); + if (queueLockComplete) return cacheResult; + //On queue lock failure + if (!Options.FailRequestsOnEnqueueLockTimeout) + { + // We run the callback with no intent of caching + var cacheInputEntry = await dataProviderCallback(cancellationToken); + + cacheResult.Detail = AsyncCacheDetailResult.QueueLockTimeoutAndCreated; + cacheResult.ContentType = cacheInputEntry.ContentType; + cacheResult.CreatedAt = null; //Hasn't been written yet + + cacheResult.Data = new MemoryStream(cacheInputEntry.Bytes.Array ?? throw new NullReferenceException(), + cacheInputEntry.Bytes.Offset, cacheInputEntry.Bytes.Count, false, true); + } + else + { + cacheResult.Detail = AsyncCacheDetailResult.QueueLockTimeoutAndFailed; + } + + return cacheResult; + + + } +} + diff --git a/src/Imazen.Routing/Caching/BlobCacheEngineOptions.cs b/src/Imazen.Routing/Caching/BlobCacheEngineOptions.cs new file mode 100644 index 00000000..e82b0743 --- /dev/null +++ b/src/Imazen.Routing/Caching/BlobCacheEngineOptions.cs @@ -0,0 +1,6 @@ +namespace Imazen.Routing.Caching; +internal class BlobCacheEngineOptions{ + public int MaxQueueBytes { get; set; } = 1024 * 1024 * 300; //300mb seems reasonable (was 100mb) + public int WaitForIdenticalRequestsTimeoutMs { get; set; } = 100000; + public bool FailRequestsOnEnqueueLockTimeout { get; set; } = true; +} \ No newline at end of file diff --git a/src/Imazen.Routing/Caching/CachingMiddleware.cs.txt b/src/Imazen.Routing/Caching/CachingMiddleware.cs.txt new file mode 100644 index 00000000..e799a883 --- /dev/null +++ b/src/Imazen.Routing/Caching/CachingMiddleware.cs.txt @@ -0,0 +1,118 @@ +namespace Imazen.Routing.RequestRouting; + +public class CachingMiddleware : IPromiseMiddleware +{ + public ValueTask DoStuff(ICacheableBlobPromise original, CancellationToken cancellationToken) + { + + } + + private string MakeWeakEtag(string cacheKey) => $"W/\"{cacheKey}\""; + // ReSharper disable once UnusedMember.Global + + + private async Task ProcessWithCacheManager(TResponse response, string cacheKey, ImageJobInfo info) + { + cacheKey = await imageJobInfo.GetFastCacheKey(); + + // W/"etag" should be used instead, since we might have to regenerate the result non-deterministically while a client is downloading it with If-Range + // If-None-Match is supposed to be weak always + var etagHeader = MakeWeakEtag(cacheKey); + + if (request.TryGetHeader(HttpHeaderNames.IfNoneMatch, out var conditionalEtag) && etagHeader == conditionalEtag) + { + GlobalPerf.Singleton.IncrementCounter("etag_hit"); + response.SetContentLength(0); + response.SetStatusCode(304); + return true; + } + GlobalPerf.Singleton.IncrementCounter("etag_miss"); + + string fullPathInfo = $"{info.FinalVirtualPath}?{info.CommandString}"; + if (info.HasParams){ + var cacheResult = await cacheManager.GetOrProcess(cacheKey, fullPathInfo, async (cancellationToken) => + { + if (info.HasParams) + { + logger?.LogDebug("Cache miss: Processing image {VirtualPath}?{Querystring}", info.FinalVirtualPath,info.ToString()); + var result = await info.ProcessUncached(); + + return new StreamCacheInput(result.ContentType, result.Bytes); + } + + logger?.LogDebug("Cache miss: Proxying image {VirtualPath}", info.FinalVirtualPath); + var bytes = await info.GetPrimaryBlobBytesAsync(); + return new StreamCacheInput(null, bytes); + + },CancellationToken.None); + + SetCachingHeaders(response, MakeWeakEtag(cacheKey)); + await MagicBytes.ProxyToStream(cacheResult.Data, response); + } + else + { + var proxyResult = await cacheManager.ProxyBlob(cacheKey, fullPathInfo, async (cancellationToken) => + { + logger?.LogDebug("Cache miss: Proxying image {VirtualPath}", info.FinalVirtualPath); + var bytes = await info.GetPrimaryBlobBytesAsync(); + return new StreamCacheInput(null, bytes); + }, CancellationToken.None); + + + SetCachingHeaders(response, MakeWeakEtag(cacheKey)); + await MagicBytes.ProxyToStream(proxyResult.Data, response); + } + + + } + + private async Task ProcessWithNoCache(TRequest request, TResponse response, ImageJobInfo info) + { + // If we're not caching, we should always use the modified date from source blobs as part of the etag + var betterCacheKey = await info.GetExactCacheKey(); + // Still use weak since recompression is non-deterministic + + var etagHeader = MakeWeakEtag(betterCacheKey); + if (request.TryGetHeader(HttpHeaderNames.IfNoneMatch, out var conditionalEtag) && etagHeader == conditionalEtag) + { + GlobalPerf.Singleton.IncrementCounter("etag_hit"); + response.SetContentLength(0); + response.SetStatusCode(304); + return; + } + GlobalPerf.Singleton.IncrementCounter("etag_miss"); + if (info.HasParams) + { + logger?.LogInformation("Processing image {VirtualPath} with params {CommandString}", info.FinalVirtualPath, info.CommandString); + GlobalPerf.Singleton.IncrementCounter("nocache_processed"); + var imageData = await info.ProcessUncached(); + + var contentType = imageData.Attributes.ContentType; + + await using var byteStream = imageData.CreateReadStream(); + + if (contentType == null) + { + SetCachingHeaders(response, etagHeader); + await MagicBytes.ProxyToStream(byteStream, response); + } + else + { + response.SetContentType(contentType); + response.SetContentLength(byteStream.Length); + SetCachingHeaders(response, etagHeader); + await byteStream.CopyToAsync(response.GetBodyWriteStream()).ConfigureAwait(false); + } + } + else + { + logger?.LogInformation("Proxying image {VirtualPath} with params {CommandString}", info.FinalVirtualPath, info.CommandString); + GlobalPerf.Singleton.IncrementCounter("nocache_proxied"); + await using var sourceStream = (await info.GetPrimaryBlob()).OpenRead(); + SetCachingHeaders(response, etagHeader); + await MagicBytes.ProxyToStream(sourceStream, response); + } + + + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Caching/MemoryCache.cs b/src/Imazen.Routing/Caching/MemoryCache.cs new file mode 100644 index 00000000..bac78404 --- /dev/null +++ b/src/Imazen.Routing/Caching/MemoryCache.cs @@ -0,0 +1,266 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Extensibility.Support; + +namespace Imazen.Routing.Caching; + + +public record MemoryCacheOptions(string UniqueName, int MaxMemoryUtilizationMb, int MaxItems, int MaxItemSizeKb, TimeSpan MinKeepNewItemsFor); + +public class UsageTracker +{ + + public required DateTimeOffset CreatedUtc { get; init; } + public required DateTimeOffset LastAccessedUtc; + public int AccessCount { get; private set; } + + public void Used() + { + LastAccessedUtc = DateTimeOffset.UtcNow; + AccessCount++; + } + + public static UsageTracker Create() + { + var now = DateTimeOffset.UtcNow; + return new UsageTracker + { + CreatedUtc = now, + LastAccessedUtc = now + }; + } +} + +public static class AsyncEnumerableExtensions +{ +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public static async IAsyncEnumerable AsAsyncEnumerable(this IEnumerable enumerable) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + foreach (var item in enumerable) + { + yield return item; + } + } +} + +/// +/// This is essential for managing thundering herds problems, even if items only stick around for a few seconds. +/// It requests inline execution in capabilities. +/// +/// Ideally, we'll implement something better than LRU. +/// Items that are under the MinKeepNewItemsFor grace period should be kept in a separate list. +/// They can graduate to the main list if they are accessed more than x times +/// +public class MemoryCache(MemoryCacheOptions options) : IBlobCache +{ + private record CacheEntry(string CacheKey, IBlobWrapper BlobWrapper, UsageTracker UsageTracker) + { + internal MemoryCacheStorageReference GetReference() + { + return new MemoryCacheStorageReference(CacheKey); + } + } + + private record MemoryCacheStorageReference(string CacheKey) : IBlobStorageReference + { + public string GetFullyQualifiedRepresentation() + { + return $"MemoryCache:{CacheKey}"; + } + + public int EstimateAllocatedBytesRecursive => 24 + CacheKey.EstimateMemorySize(true); + } + ConcurrentDictionary __cache = new ConcurrentDictionary(); + + public string UniqueName => options.UniqueName; + + + public BlobCacheCapabilities InitialCacheCapabilities { get; } = new BlobCacheCapabilities + { + CanFetchMetadata = true, + CanFetchData = true, + CanConditionalFetch = false, + CanPut = true, + CanConditionalPut = false, + CanDelete = true, + CanSearchByTag = true, + CanPurgeByTag = true, + CanReceiveEvents = true, + SupportsHealthCheck = true, + SubscribesToRecentRequest = true, + SubscribesToExternalHits = true, + SubscribesToFreshResults = true, + RequiresInlineExecution = true, // Unsure if this is true + FixedSize = true + }; + + + public Task CacheFetch(IBlobCacheRequest request, CancellationToken cancellationToken = default) + { + if (__cache.TryGetValue(request.CacheKeyHashString, out var entry)) + { + entry.UsageTracker.Used(); + return Task.FromResult(BlobCacheFetchFailure.OkResult(entry.BlobWrapper)); + } + + return Task.FromResult(BlobCacheFetchFailure.MissResult(this, this)); + } + + private long memoryUsedSync; + private long itemCountSync; + private bool TryRemove(string cacheKey, [MaybeNullWhen(false)] out CacheEntry removed) + { + if (!__cache.TryRemove(cacheKey, out removed)) return false; + Interlocked.Add(ref memoryUsedSync, -removed.BlobWrapper.EstimateAllocatedBytes ?? 0); + Interlocked.Decrement(ref itemCountSync); + return true; + } + + private bool TryEnsureCapacity(long size) + { + List? snapshotOfEntries = null; + int nextCandidateIndex = 0; + while (itemCountSync > options.MaxItems || memoryUsedSync + size > options.MaxMemoryUtilizationMb * 1024 * 1024) + { + if (snapshotOfEntries == null) + { + // Sort by least total access count, then by least recently accessed + snapshotOfEntries = __cache.Values + .OrderBy(entry => entry.UsageTracker.AccessCount) + .ThenBy(entry => entry.UsageTracker.LastAccessedUtc).ToList(); + } + if (nextCandidateIndex >= snapshotOfEntries.Count) + { + return false; // We've run out of candidates. We can't make space. + } + var candidate = snapshotOfEntries[nextCandidateIndex++]; + if (candidate.UsageTracker.LastAccessedUtc > DateTimeOffset.UtcNow - options.MinKeepNewItemsFor) + { + nextCandidateIndex++; // Skip this item + continue; // This item is too new to evict. + } + var _ = TryRemove(candidate.CacheKey, out var _); + nextCandidateIndex++; + } + return true; + } + + private bool TryAdd(string cacheKey, IBlobWrapper blob) + { + if (!blob.IsNativelyReusable) + { + throw new InvalidOperationException("Cannot cache a blob that is not natively reusable"); + } + if (blob.EstimateAllocatedBytes == null) + { + throw new InvalidOperationException("Cannot cache a blob that does not have an EstimateAllocatedBytes"); + } + IBlobWrapper? existingBlob = null; + if (__cache.TryGetValue(cacheKey, out var oldEntry)) + { + existingBlob = oldEntry.BlobWrapper; + if (existingBlob == blob) + { + return false; // Why are we adding the same blob twice? + } + } + var replacementSizeDifference = (long)blob.EstimateAllocatedBytes! - (existingBlob?.EstimateAllocatedBytes ?? 0); + if (blob.EstimateAllocatedBytes > options.MaxItemSizeKb * 1024) + { + return false; + } + if (!TryEnsureCapacity(replacementSizeDifference)) + { + return false; // Can't make space? That's odd. + } + + var entry = new CacheEntry(cacheKey, blob, UsageTracker.Create()); + if (entry == __cache.AddOrUpdate(cacheKey, entry, (_, existing) => existing with { BlobWrapper = blob })) + { + Interlocked.Increment(ref itemCountSync); + Interlocked.Add(ref memoryUsedSync, blob.EstimateAllocatedBytes ?? 0); + itemCountSync++; + return true; + } + Interlocked.Add(ref memoryUsedSync, replacementSizeDifference); + + return false; + } + + private void IncrementUsage(string cacheKeyHashString) + { + if (__cache.TryGetValue(cacheKeyHashString, out var entry)) + { + entry.UsageTracker.Used(); + } + } + private IEnumerable AllEntries => __cache.Values; + public Task CachePut(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + if (e.Result?.TryUnwrap(out var blob) == true) + { + TryAdd(e.OriginalRequest.CacheKeyHashString, blob); + } + + return Task.FromResult(CodeResult.Ok()); + } + + public Task>> CacheSearchByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + // we iterate instead of having an index, this is almost never called (we think) + var results = + AllEntries.Where(entry => entry.BlobWrapper.Attributes.StorageTags?.Contains(tag) == true) + .Select(entry => (IBlobStorageReference)entry.GetReference()).AsAsyncEnumerable(); + return Task.FromResult(CodeResult>.Ok(results)); + } + + public Task>>> CachePurgeByTag(SearchableBlobTag tag, CancellationToken cancellationToken = default) + { + // As we delete items from the cache, build a list of the deleted items + // so we can return them to the caller. + var deleted = new List>(); + foreach (var entry in AllEntries.Where(entry => entry.BlobWrapper.Attributes.StorageTags?.Contains(tag) == true)) + { + if (TryRemove(entry.CacheKey, out var removed)) + { + deleted.Add(CodeResult.Ok(removed.GetReference())); + } + } + return Task.FromResult(CodeResult>>.Ok(deleted.AsAsyncEnumerable())); + } + + public Task CacheDelete(IBlobStorageReference reference, CancellationToken cancellationToken = default) + { + if (reference is MemoryCacheStorageReference memoryCacheStorageReference) + { + if (TryRemove(memoryCacheStorageReference.CacheKey, out _)) + { + return Task.FromResult(CodeResult.Ok()); + } + } + + return Task.FromResult(CodeResult.Err(404)); + } + + public Task OnCacheEvent(ICacheEventDetails e, CancellationToken cancellationToken = default) + { + // notify usage trackers + if (e.ExternalCacheHit != null) + { + IncrementUsage(e.OriginalRequest.CacheKeyHashString); + } + return Task.FromResult(CodeResult.Ok()); + } + + + + public ValueTask CacheHealthCheck(CancellationToken cancellationToken = default) + { + // We could be low on memory, but we wouldn't turn off features, we'd just evict smarter. + return new ValueTask(BlobCacheHealthDetails.FullHealth(InitialCacheCapabilities)); + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Caching/design.md b/src/Imazen.Routing/Caching/design.md new file mode 100644 index 00000000..6a1a349d --- /dev/null +++ b/src/Imazen.Routing/Caching/design.md @@ -0,0 +1,125 @@ +# design notes (all of this is outdated and should be ignored) + +We want to enable chaining, where one request could be based on another. + +# Execution plan + +A simple plan might just include the input image locations and +the image job parameters. +But what if we want to dramatically speed up operations on large images by detecting if we can use a smaller image variant as input, leveraging the caches? + +We could map a &preshrinkvariant=true parameter to behavior that uses the cache for a smaller variant. But what if the original is already small? + + + +# Cache Manager + +Optional bits: +- circuit breaker functionality +- cache invalidation +- cache purging +- cache revalidation +- existence probability enhancement per-cache +- optimization based on real-time cache latency and time-to-last-byte +- optimization based on usage frequency (some caches might only want to be written to after x number of reads for the resource) +- cache failure paths, not just 200s. 404s, 502s, 403s, etc need a bit of caching, even if only for a few seconds. +- memory cache, not just a write queue. Can also track usage to determine +- if a write is justified.. +- immutable mode vs mutable mode? + + + + + + +Unsolved - we may want to clear a cache of everything past a certain age, such as when we invalidate all data by changing a global image resize command, for example. But we may also want to allow a soft/slow invalidation process since that can slam a server. Most likely that would look like a 'phase-in-configuration' that is applied to requests when CPU utilization is below a certain threshold, and takes into account if a cache entry exists for the prior config. + + + +## For Lambda/fn/AOT +We want to make features with start-up latency optional. + +Such as: +* Existence probable map +* Revalidation bit set +* AWS config loading (more a plugin thing) +* Large allocations / pools + +Avoid slow Gettemppathname +consider Frozen collections +records classes/structs should be usable from .net classic +check for proxy perf tricks in https://microsoft.github.io/reverse-proxy/ + +## Normal content + +The fresh content generation middleware, when called, will have a reference to the manager that accepts virtual paths. The manager will handle source file fetching/caching. We may consider more than just virtual paths as part of source file requests. + + +## (Complete) Key cache and provider interface needs: + +Reusability and thread-safe buffering, so that a result can be sent to the requesting client AND stored in an upload queue for caching and (until then) memory caching/access. + +But if no caching is happening, then we don't need to be able to open multiple read streams to the data, just one to proxy to the client. This is commom with requests for original files and blended asset type file serving. (BlobWrapper does this) + +Each cache layer might at different times have different native capabilities, such as reading from http, file, or bytes, and different overhead for creating a reusable stream, so that buffering/upgrade should happen only on an as-needed basis. Metadata should always be immutable and thread-safe. + +Cache keys should have both string and byte representation available in the key struct, and 'compliance tags' should also be stored and accessible - and searchable for adjusting access control or deletion. These could also be used for invalidation. + +For reusable result objects, we want to be able to release the byte buffer losts back into the pool at some point, but this gets tricky with multiple underlying streams attached, unless all those streams are custom-designed to coordinate with the pool. + + +## Invalidation ideas + +When we do a purge on a cache, we can extract the cache hashes, and mark those in the bit map for revalidation. Otherwise 304s will continue to be send for if-none-match, even if we are successful on the complete purge. Whatever the longest cache-string is for, we can theoretically cycle the bit maps. Ex, if it is one month - We write all bits to 2 different sets. We clear one set every month, alternating. + +Note: Dual-purposing blobstoragereference doesn't give us an audit trail, just this one cache hit. Although technically that is the data source. + +## +Bit arrays based on hashes - false negatives only if (a) unexpected shutdown or (b) badly timed writes during persistence. + +We would need an API for etag compareexchange stye cache puts... not hard (cache put options) + +But also, this doesn't solve the source file invalidation problem, unless we track sources and check their invalidation hashes. + +What we can do is create an execution plan - rapidly - for each incoming request, and then check all the input files against the probabilistic invalidation set. + +We could also do a late-failure invalidation, such as checking the invalidation set for hashes of tags based on the tags provided by a cache backend. We probably need some time-based logic here, such as a date stamp of when the invalidation occurred, and a date stamp of when the cache entry was last created or invalidated. + + +# Bloom invalidation thoughts + +Bit sets that exceed a certain threshold of density can trigger the creation of a new layer to the bloom filter. But we also want to protect from spammed invalidation commands. if we find an invalidation bit set, and the next ~4 are also set, then we should fall back to some kind of system that can tolerate mass invalidations rather than continuing to set bits. The problem is that encoding that data requires something smarter than ORing the bits, for eventual consistency. + +We could require invalidation requesters specify the precise etag they want to invalidate (although they probably will hate that). OR, we could schedule a revalidation that fetches all the source files for a URL, hashes them, and verifies those hashes exist as tags in the cache. Or we just store etags from source files as tags in the cache, i.e, "input:asf321qfwafs" + +Using the existing purge infrastructure, we would need to delete all source-cached stuff first, then all variants - but since requests continue to come in, disk locking could be an issue. Also, search by tag is slow(ish) for hybridcache. We would need to be able to first target source cached proxied copies for deletion before moving on to generated versions that incorporate it. + +Alternatively, we offer multiple invalidation APIs + +InvalidateAll() - increments a global counter/seed incorporated into every hash. Also available as a config integer + +InvalidateSince(DateTime) - would rely on new cache interface, would only delete newly written stuff. + +InvalidateSourceSet(list of source URIs) +InvalidateUrls(list of endpoint URIs) + +RevalidateSourceSet() - for each cache, for each source (key basis), check that the remote etags haven't diverged (GET with if-none-match) from the cached etags (fall back to bitwise comparison if the remote server doesn't send an etag). If they HAVE diverged, increment the invalidation counter for those sources (but only once, regardless of cache) (which bumps the lookup hash and key basis). Purge the old key basis (in case our invalidation set gets wiped). + +RevalidateSourcesFor() - using execution plans, revalidate all involved sources. + +For both above, allow config for the source to go 404. I.e, if the resource is no longer accessible, ensure all dependent requests now fail instead of cache hitting. + +Note that we need a plan for throttling failed requests. 502 bad gateway results can be cached for a limited time, in memory only. upstream 403/404 results should also be memcached, for a longer time. + + +Perhaps we have additional bloom filters (remember, we only used 24 of the 256 bits available to us) + + +TODO: notify the cache when an entry has been invalidated and usage is likely to cease. This, however, disregards the more frequent cases where global settings cause all cache entries to invalidate, or URLs are refactored. + + + + + + + diff --git a/src/Imazen.Routing/Engine/ExtensionlessPath.cs b/src/Imazen.Routing/Engine/ExtensionlessPath.cs new file mode 100644 index 00000000..f269a1f0 --- /dev/null +++ b/src/Imazen.Routing/Engine/ExtensionlessPath.cs @@ -0,0 +1,9 @@ +using Imazen.Routing.Layers; + +namespace Imazen.Routing.Engine; + +public readonly record struct ExtensionlessPath(string Prefix, StringComparison StringComparison = StringComparison.OrdinalIgnoreCase) + : IStringAndComparison +{ + public string StringToCompare => Prefix; +} \ No newline at end of file diff --git a/src/Imazen.Routing/Engine/RoutingBuilder.cs b/src/Imazen.Routing/Engine/RoutingBuilder.cs new file mode 100644 index 00000000..3165d0f3 --- /dev/null +++ b/src/Imazen.Routing/Engine/RoutingBuilder.cs @@ -0,0 +1,127 @@ +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Layers; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Engine; + +public class RoutingBuilder +{ + protected IFastCond? GlobalPreconditions { get; set; } + protected List Layers { get; } = new List(); + // set default conditions (IFastCond) + // add page endpoints (IRoutingEndpoint) + + // Add endpoints based on suffixes or exact matches. At build time, we roll these up into a single FastCond that can be evaluated quickly. + + /// + /// Adds an endpoint that also extends GlobalPreconditions + /// + /// + /// + /// + public RoutingBuilder AddGlobalEndpoint(IFastCond fastMatcher, IRoutingEndpoint endpoint) + => AddEndpoint(true, fastMatcher, endpoint); + + public RoutingBuilder AddEndpoint(IFastCond fastMatcher, IRoutingEndpoint endpoint) + => AddEndpoint(false, fastMatcher, endpoint); + + private RoutingBuilder AddEndpoint(bool global, IFastCond fastMatcher, IRoutingEndpoint endpoint) + { + if (global) AddAlternateGlobalPrecondition(fastMatcher); + Layers.Add(new SimpleLayer(fastMatcher.ToString() ?? "(error describing route)", + (request) => fastMatcher.Matches(request.MutablePath, request.ReadOnlyQueryWrapper) ? + CodeResult.Ok(endpoint) : null, fastMatcher)); + return this; + } + public RoutingBuilder AddLayer(IRoutingLayer layer) + { + Layers.Add(layer); + return this; + } + public RoutingBuilder AddGlobalLayer(IRoutingLayer layer) + { + AddAlternateGlobalPrecondition(layer.FastPreconditions ?? Conditions.True); + Layers.Add(layer); + return this; + } + //Add predefined reusable responses + + /// + /// Adds an endpoint that also extends GlobalPreconditions + /// + /// + /// + /// + public RoutingBuilder AddGlobalEndpoint(IFastCond fastMatcher, IAdaptableReusableHttpResponse response) + { + var endpoint = new PredefinedResponseEndpoint(response); + return AddGlobalEndpoint(fastMatcher, endpoint); + } + public RoutingBuilder AddEndpoint(IFastCond fastMatcher, IAdaptableReusableHttpResponse response) + { + var endpoint = new PredefinedResponseEndpoint(response); + return AddEndpoint(fastMatcher, endpoint); + } + /// + /// Adds an endpoint that also extends GlobalPreconditions + /// + /// + /// + /// + public RoutingBuilder AddGlobalEndpoint(IFastCond fastMatcher, Func handler) + { + var endpoint = new SyncEndpointFunc(handler); + return AddGlobalEndpoint(fastMatcher, endpoint); + } + + public RoutingBuilder AddEndpoint(IFastCond fastMatcher, Func handler) + { + var endpoint = new SyncEndpointFunc(handler); + return AddEndpoint(fastMatcher, endpoint); + } + + // Set default Preconditions IFastCond + + + public RoutingBuilder SetGlobalPreconditions(IFastCond fastMatcher) + { + GlobalPreconditions = fastMatcher; + return this; + } + public IFastCond CreatePreconditionToRequireImageExtensionOrExtensionlessPathPrefixes(IReadOnlyCollection? extensionlessPaths = null) + where T : IStringAndComparison + { + if (extensionlessPaths is { Count: > 0 }) + { + return Conditions.HasSupportedImageExtension.Or( + Conditions.PathHasNoExtension.And(Conditions.HasPathPrefix(extensionlessPaths))); + } + return Conditions.HasSupportedImageExtension; + } + + public IFastCond CreatePreconditionToRequireImageExtensionOrSuffixOrExtensionlessPathPrefixes(IReadOnlyCollection? extensionlessPaths = null, string[]? alternatePathSuffixes = null) + where T : IStringAndComparison + { + if (alternatePathSuffixes is { Length: > 0 }) + { + return CreatePreconditionToRequireImageExtensionOrExtensionlessPathPrefixes(extensionlessPaths) + .Or(Conditions.HasPathSuffixOrdinalIgnoreCase(alternatePathSuffixes)); + } + return CreatePreconditionToRequireImageExtensionOrExtensionlessPathPrefixes(extensionlessPaths); + } + + public RoutingBuilder AddAlternateGlobalPrecondition(IFastCond? fastMatcher) + { + if (fastMatcher == null) return this; + if (fastMatcher is Conditions.FastCondFalse) return this; + GlobalPreconditions = GlobalPreconditions?.Or(fastMatcher) ?? fastMatcher; + return this; + } + + public RoutingEngine Build(IReLogger logger) + { + return new RoutingEngine(Layers.ToArray(), GlobalPreconditions ?? Conditions.True, logger); + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Engine/RoutingBuilderExtensions.cs b/src/Imazen.Routing/Engine/RoutingBuilderExtensions.cs new file mode 100644 index 00000000..fcc49974 --- /dev/null +++ b/src/Imazen.Routing/Engine/RoutingBuilderExtensions.cs @@ -0,0 +1,6 @@ +namespace Imazen.Routing.Engine; + +public static class RoutingBuilderExtensions +{ + +} \ No newline at end of file diff --git a/src/Imazen.Routing/Engine/RoutingEngine.cs b/src/Imazen.Routing/Engine/RoutingEngine.cs new file mode 100644 index 00000000..35a28b85 --- /dev/null +++ b/src/Imazen.Routing/Engine/RoutingEngine.cs @@ -0,0 +1,117 @@ +using System.Text; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Layers; +using Imazen.Routing.Promises; +using Imazen.Routing.Requests; +using Imazen.Routing.Serving; +using Microsoft.Extensions.Logging; + +namespace Imazen.Routing.Engine; + +public class RoutingEngine : IBlobRequestRouter, IHasDiagnosticPageSection +{ + private readonly IRoutingLayer[] layers; + private readonly IReLogger logger; + + private readonly IFastCond mightHandleConditions; + internal RoutingEngine(IRoutingLayer[] layers, IFastCond globalPrecondition, IReLogger logger) + { + this.layers = layers; + this.logger = logger; + + // Build a list of unique fast exits we must consult. We can skip duplicates. + var preconditions = new List(layers.Length) { }; + foreach (var layer in layers) + { + if (layer.FastPreconditions != null && !preconditions.Contains(layer.FastPreconditions)) + { + preconditions.Add(layer.FastPreconditions); + } + } + mightHandleConditions = globalPrecondition.And(preconditions.AnyPrecondition()).Optimize(); + } + + public bool MightHandleRequest(string path, TQ query) where TQ : IReadOnlyQueryWrapper + { + return mightHandleConditions.Matches(path, query); + } + + public async ValueTask?> Route(MutableRequest request, CancellationToken cancellationToken = default) + { + // log info about the request + foreach (var layer in layers) + { + var result = await layer.ApplyRouting(request,cancellationToken); + // log what (if anything) has changed. + if (result != null) + { + // log the result + return result; + } + } + + return null; // We don't have matching routing for this. Let the rest of the app handle it. + } + + + public async ValueTask?> RouteToPromiseAsync(MutableRequest request, CancellationToken cancellationToken = default) + { + // log info about the request + foreach (var layer in layers) + { + var result = await layer.ApplyRouting(request,cancellationToken); + // log what (if anything) has changed. + if (result != null) + { + + if (result.IsOk) + { + var endpoint = result.Value; + if (endpoint!.IsBlobEndpoint) + { + var promise = await endpoint.GetInstantPromise(request, cancellationToken); + if (promise.IsCacheSupporting && promise is ICacheableBlobPromise cacheablePromise) + { + return CodeResult.Ok(cacheablePromise); + } + } + else + { + logger.LogError("Imageflow Routing endpoint {0} (from routing layer {1} is not a blob endpoint", + endpoint, layer.Name); + return CodeResult.Err( + HttpStatus.ServerError.WithAppend("Routing endpoint is not a blob endpoint")); + } + } + else + { + return CodeResult.Err(result.Error); + } + } + } + + return null; // We don't have matching routing for this. Let the rest of the app handle it. + } + + public string? GetDiagnosticsPageSection(DiagnosticsPageArea section) + { + if (section == DiagnosticsPageArea.Start) + { + // preconditions + var sb = new StringBuilder(); + sb.AppendLine($"Routing Engine: {layers.Length} layers, Preconditions: {mightHandleConditions}"); + for(var i = 0; i < layers.Length; i++) + { + sb.AppendLine($"layer[{i}]: {layers[i].Name} ({layers[i].GetType().Name}), Preconditions={layers[i].FastPreconditions}"); + } + return sb.ToString(); + } + else + { + + } + return null; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Global.cs b/src/Imazen.Routing/Global.cs new file mode 100644 index 00000000..88564d04 --- /dev/null +++ b/src/Imazen.Routing/Global.cs @@ -0,0 +1,16 @@ +global using IBlobResult = Imazen.Abstractions.Resulting.IDisposableResult; +global using CacheFetchResult = Imazen.Abstractions.Resulting.IResult; + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ImazenShared.Tests")] +[assembly: InternalsVisibleTo("Imageflow.Server")] +[assembly: InternalsVisibleTo("Imageflow.Server.Tests")] +[assembly: InternalsVisibleTo("ImageResizer")] +[assembly: InternalsVisibleTo("ImageResizer.LicensingTests")] +[assembly: InternalsVisibleTo("ImageResizer.HybridCache.Tests")] +[assembly: InternalsVisibleTo("ImageResizer.HybridCache.Benchmark")] +[assembly: InternalsVisibleTo("Imazen.Routing")] +[assembly: InternalsVisibleTo("Imazen.Routing.Tests")] + diff --git a/src/Imazen.Routing/Health/BehaviorMetrics.cs b/src/Imazen.Routing/Health/BehaviorMetrics.cs new file mode 100644 index 00000000..7c4bac0b --- /dev/null +++ b/src/Imazen.Routing/Health/BehaviorMetrics.cs @@ -0,0 +1,119 @@ +using System.Text; + +namespace Imazen.Routing.Health; + +internal enum MetricBasis: byte +{ + ProblemReports, + HealthChecks +} + +internal struct BehaviorMetrics(MetricBasis basis, BehaviorTask task) +{ + public MetricBasis Basis { get; init; } = basis; + public BehaviorTask Task { get; init; } = task; + + + public TimeSpan Uptime { get; private set; } + public TimeSpan Downtime { get; private set; } + public DateTimeOffset LastReport { get; private set; } = DateTimeOffset.MinValue; + public DateTimeOffset LastSuccessReport { get; private set; } = DateTimeOffset.MinValue; + public DateTimeOffset LastFailureReport { get; private set; } = DateTimeOffset.MinValue; + + public int TotalSuccessReports { get; private set; } = 0; + public int TotalFailureReports { get; private set; } = 0; + public int ConsecutiveSuccessReports { get; private set; } = 0; + public int ConsecutiveFailureReports { get; private set; } = 0; + + // track TTFB, TTLB, and byte count? + // If the ArraySegmentBlobWrapper duration is > 0 < 5ms, it was probably + // buffered prior, and we will assume that only TTLB is relevant. + + + public void ReportBehavior(bool ok, BehaviorTask task, TimeSpan taskDuration) + { + LastReport = DateTimeOffset.UtcNow; + if (ok) + { + if (LastSuccessReport > LastFailureReport) + { + Uptime += LastReport - LastSuccessReport; + } // Don't add transition time. + + LastSuccessReport = LastReport; + ConsecutiveSuccessReports++; + ConsecutiveFailureReports = 0; + TotalSuccessReports++; + } + else + { + if (LastFailureReport > LastSuccessReport) + { + Downtime += LastReport - LastFailureReport; + } // Don't add transition time. + LastFailureReport = LastReport; + ConsecutiveFailureReports++; + ConsecutiveSuccessReports = 0; + TotalFailureReports++; + } + } + + /// + /// If it's had ten failures in a row, it's probably down. + /// + /// + public bool SeemsDown() + { + return ConsecutiveFailureReports > 10; + } + + public void WriteSummaryTo(StringBuilder sb) + { + if (ConsecutiveFailureReports > 0) + { + sb.Append("! Failed last "); + sb.Append(ConsecutiveFailureReports); + } + else if (ConsecutiveSuccessReports > 0) + { + sb.Append("OK last "); + sb.Append(ConsecutiveSuccessReports); + } + else + { + sb.Append("? No data"); + return; + } + + sb.Append(" times"); + if (Uptime > TimeSpan.Zero) + { + sb.Append(", uptime="); + sb.Append(Uptime.ToString(@"hh\:mm\:ss")); + } + if (Downtime > TimeSpan.Zero) + { + sb.Append(", downtime="); + sb.Append(Downtime.ToString(@"hh\:mm\:ss")); + } + sb.Append(", total of "); + sb.Append(TotalFailureReports); + sb.Append(" errors to "); + sb.Append(TotalSuccessReports); + sb.Append(" successes overall)"); + var now = DateTimeOffset.UtcNow; + if (TotalSuccessReports > 0) + { + sb.Append(", last success "); + sb.Append((now - LastSuccessReport).ToString(@"hh\:mm\:ss")); + sb.Append("s ago"); + } + if (TotalFailureReports > 0) + { + sb.Append(", last failure "); + sb.Append((now - LastFailureReport).ToString(@"hh\:mm\:ss")); + sb.Append("s ago"); + } + + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Health/BehaviorTask.cs b/src/Imazen.Routing/Health/BehaviorTask.cs new file mode 100644 index 00000000..dc224e1c --- /dev/null +++ b/src/Imazen.Routing/Health/BehaviorTask.cs @@ -0,0 +1,17 @@ +namespace Imazen.Routing.Health; + +public enum BehaviorTask: byte +{ + FetchData, + FetchMetadata, + Put, + Delete, + SearchByTag, + PurgeByTag, + HealthCheck +} + +internal static class BehaviorTaskHelpers +{ + internal const int BehaviorTaskCount = 8; +} \ No newline at end of file diff --git a/src/Imazen.Routing/Health/CacheHealthMetrics.cs b/src/Imazen.Routing/Health/CacheHealthMetrics.cs new file mode 100644 index 00000000..84dc79bf --- /dev/null +++ b/src/Imazen.Routing/Health/CacheHealthMetrics.cs @@ -0,0 +1,375 @@ +using System.Diagnostics; +using System.Text; +using Imazen.Abstractions.BlobCache; + +namespace Imazen.Routing.Health; + +internal class CacheHealthMetrics +{ + /// + /// The last exception thrown by a health check, if any. + /// + public Exception? LastHealthCheckException { get; private set; } + + /// + /// The last report from a health check, whether it was healthy or not. + /// + private IBlobCacheHealthDetails? LastHealthCheckCompleted { get; set; } = null; + + /// + /// If we haven't had a successful health check in 3+ minutes and it has failed at least 3 times + /// We are on our own for determining capabilities based on observed behavior + /// + private bool HealthCheckCrashing => ConsecutiveCheckCrashes > 3 && LastCheckCompletedOrFailed < DateTimeOffset.UtcNow - TimeSpan.FromMinutes(3); + + + /// + /// True if the cache advertises support for health checks. + /// + private bool SupportsHealthCheck { get; init; } + // collectedMetrics[BehaviorTask.HealthCheck] refers to whether the health check completed or threw an exception, not if it returned a degraded result (it was still a success) + private readonly BehaviorMetrics[] collectedMetrics = new BehaviorMetrics[BehaviorTaskHelpers.BehaviorTaskCount]; + // This just tracks reported capabilities over time. + private readonly BehaviorMetrics[] healthCheckMetrics = new BehaviorMetrics[BehaviorTaskHelpers.BehaviorTaskCount]; + /// + /// This tracks whether the health checks have been returning at full health or not (ignoring crashes, those are counted in collectedMetrics) + /// + private BehaviorMetrics atFullHealth = new BehaviorMetrics(MetricBasis.HealthChecks, BehaviorTask.HealthCheck); + + private readonly BlobCacheCapabilities initialAdvertisedCapabilities; + + + public CacheHealthMetrics(BlobCacheCapabilities initialAdvertisedCapabilities) + { + this.initialAdvertisedCapabilities = initialAdvertisedCapabilities; + + SupportsHealthCheck = initialAdvertisedCapabilities.SupportsHealthCheck; + for (var i = 0; i < collectedMetrics.Length; i++) + { + collectedMetrics[i] = new BehaviorMetrics(MetricBasis.ProblemReports, (BehaviorTask) i); + healthCheckMetrics[i] = new BehaviorMetrics(MetricBasis.HealthChecks, (BehaviorTask) i); + } + } + + private DateTimeOffset LastCheckCompletedOrFailed => collectedMetrics[(int) BehaviorTask.HealthCheck].LastReport; + public int ConsecutiveCheckCrashes => collectedMetrics[(int) BehaviorTask.HealthCheck].ConsecutiveFailureReports; + public int ConsecutiveCheckResponses => collectedMetrics[(int) BehaviorTask.HealthCheck].ConsecutiveSuccessReports; + public int ConsecutiveUnhealthyChecks => atFullHealth.ConsecutiveFailureReports; + + private int consecutiveIdenticalHealthCheckCaps; + + + + private BlobCacheCapabilities? cachedEstimate; + public BlobCacheCapabilities EstimateCapabilities() + { + // If we have a successful health check, we can use the reported capabilities + if (!HealthCheckCrashing && LastHealthCheckCompleted != null) + return LastHealthCheckCompleted.CurrentCapabilities; + + // If health checks aren't supported, or checks themselves have been failing for a while, we should assume the worst and + // only report capabilities that intersect with (a) initial capabilities and (b) capabilities NOT on a failure streak + // based on behavior reports + // Here we go off reported behavior + cachedEstimate ??= initialAdvertisedCapabilities; + cachedEstimate = Intersect(cachedEstimate, collectedMetrics); + return cachedEstimate; + } + + + /// + /// This returns true when we should fire off another health check. + /// + /// + /// + public bool IsStale() + { + if (!SupportsHealthCheck) return false; // We simply never support health checks. + + var timeSinceLastCheck = DateTimeOffset.UtcNow - LastCheckCompletedOrFailed; + + // First-time call (or first time call is pending) + if (LastHealthCheckCompleted == null && ConsecutiveCheckCrashes == 0) return true; + + // Fast exit with an absolute minimum delay + if (timeSinceLastCheck < TimeSpan.FromMilliseconds(500)) return false; + + return timeSinceLastCheck > GetOrAdjustHealthCheckInterval(); + } + private TimeSpan GetOrAdjustHealthCheckInterval(bool readOnly = false){ + // See if we can listen to the cache's health check interval + var suggestedRecheck = ConsecutiveCheckCrashes == 0 ? LastHealthCheckCompleted?.SuggestedRecheckDelay : null; + if (suggestedRecheck != null && LastHealthCheckCompleted != null) return suggestedRecheck.Value; + + // If HealthCheckCrashing=true, we're not using the health check results anyway, we've fallen back to behavior reports. + + // We should probably do exponential backoff if we're always getting the same results, whether good or bad + // ConsecutiveIdenticalHealthCheckCaps > 1 + + // But if we get a surge in bad behavior reports, we should lower the MAX delay between health checks to 10 seconds + // Because clearly there's a conflict between the capabilities and the behavior. + + var maxCheckInterval = HealthCheckCrashing ? TimeSpan.FromSeconds(30) : TimeSpan.FromMinutes(20); + var minCheckInterval = HealthCheckCrashing ? TimeSpan.FromMilliseconds(500) : TimeSpan.FromMinutes(1); + + // 10+ consecutive failures with the latest in the interval probably means a difference between reported health and actual behavior. + var badBehaviorPatience = TimeSpan.FromSeconds(15); + var badBehavior = collectedMetrics.Any(m => m.SeemsDown() && m.LastFailureReport > DateTimeOffset.UtcNow - badBehaviorPatience); + if (badBehavior) + { + maxCheckInterval = badBehaviorPatience; + minCheckInterval = TimeSpan.FromSeconds(1); + } + if (lastDelay == default) lastDelay = minCheckInterval + GetJitter(); // Not actually reachable except in a race condition. + + // Read-only mode is used to determine the next interval without actually changing the state. + if (readOnly) return lastDelay; + + if (lastMinCheckInterval != minCheckInterval) + { + // When we initially transition between init / bad behavior / crashing checks / consistent reporting (good or bad) + // We reset the delay to the minimum and trigger an immediate check. + // We're not interlocking lastMinCheckInterval because this whole thing is a heuristic anyway. + lastDelay = minCheckInterval + GetJitter(); + return lastDelay; + } + lastMinCheckInterval = minCheckInterval; + + if (consecutiveIdenticalHealthCheckCaps < 3) + { + // We don't need to start backing off yet. We can poll at the minimum interval. + return lastDelay; + } + // Ok, now we've gotten 3 identical health check results in a row. We should start backing off. + const int backOffMultiplier = 2; + lastDelay = Min(new TimeSpan(lastDelay.Ticks * backOffMultiplier), maxCheckInterval); + return lastDelay; + } + + private TimeSpan lastDelay; + private TimeSpan lastMinCheckInterval; + + /// + /// 50-550ms jitter. We only call this during state transitions, it's used once for the base jitter and then we presume + /// multiplication will simply expand the difference between this client and other clients. + /// + /// + private TimeSpan GetJitter() + { + // Add some jitter to avoid thundering herd (although this is unlikely unless we have a lot of servers and the cache is not good quality) +#if DOTNET6_OR_GREATER + var random = Random.Shared; +#else + var random = new Random(); +#endif + return TimeSpan.FromMilliseconds(50 + random.NextDouble() * 500); + } + private TimeSpan Min(TimeSpan a, TimeSpan b) + { + return a < b ? a : b; + } + + public void ReportBehavior(bool successful, BehaviorTask task, TimeSpan taskDuration) + { + ref var m = ref collectedMetrics[(int) task]; + m.ReportBehavior(successful, task, taskDuration); + } + + private BlobCacheCapabilities? lastReportedCapabilities; + private void AnyReport(BlobCacheCapabilities? newCapabilities, TimeSpan taskDuration) + { + if (newCapabilities == lastReportedCapabilities) consecutiveIdenticalHealthCheckCaps++; + else consecutiveIdenticalHealthCheckCaps = 0; + lastReportedCapabilities = newCapabilities; + + ReportBehavior(newCapabilities != null, BehaviorTask.HealthCheck, taskDuration); + if (newCapabilities != null) + { + healthCheckMetrics[(int)BehaviorTask.FetchData] + .ReportBehavior(newCapabilities.CanFetchData, BehaviorTask.FetchData, TimeSpan.Zero); + healthCheckMetrics[(int)BehaviorTask.FetchMetadata].ReportBehavior(newCapabilities.CanFetchMetadata, + BehaviorTask.FetchMetadata, TimeSpan.Zero); + healthCheckMetrics[(int)BehaviorTask.Put].ReportBehavior(newCapabilities.CanPut, BehaviorTask.Put, TimeSpan.Zero); + healthCheckMetrics[(int)BehaviorTask.Delete].ReportBehavior(newCapabilities.CanDelete, BehaviorTask.Delete, TimeSpan.Zero); + healthCheckMetrics[(int)BehaviorTask.SearchByTag].ReportBehavior(newCapabilities.CanSearchByTag, + BehaviorTask.SearchByTag, TimeSpan.Zero); + healthCheckMetrics[(int)BehaviorTask.PurgeByTag].ReportBehavior(newCapabilities.CanPurgeByTag, + BehaviorTask.PurgeByTag, TimeSpan.Zero); + healthCheckMetrics[(int)BehaviorTask.HealthCheck].ReportBehavior(newCapabilities.SupportsHealthCheck, + BehaviorTask.HealthCheck, TimeSpan.Zero); + } + LastHealthCheckException = null; + } + + public void ReportHealthCheck(IBlobCacheHealthDetails newHealth, IBlobCacheHealthDetails? priorHealth, + TimeSpan taskDuration) + { + atFullHealth.ReportBehavior(newHealth.AtFullHealth, BehaviorTask.HealthCheck, taskDuration); + AnyReport(newHealth.CurrentCapabilities, taskDuration); + } + + public void ReportFailedHealthCheck(Exception exception, IBlobCacheHealthDetails? priorHealth, TimeSpan taskDuration) + { + LastHealthCheckException = exception; + ReportBehavior(false, BehaviorTask.HealthCheck, taskDuration); + AnyReport(null, taskDuration); + } + + private BlobCacheCapabilities Intersect(BlobCacheCapabilities caps, BehaviorMetrics[] metrics) + { + if (caps.CanFetchData && metrics[(int)BehaviorTask.FetchData].SeemsDown()) + { + caps = caps with {CanFetchData = false}; + } + if (caps.CanFetchMetadata && metrics[(int)BehaviorTask.FetchMetadata].SeemsDown()) + { + caps = caps with {CanFetchMetadata = false}; + } + if (caps.CanPut && metrics[(int)BehaviorTask.Put].SeemsDown()) + { + caps = caps with {CanPut = false}; + } + if (caps.CanDelete && metrics[(int)BehaviorTask.Delete].SeemsDown()) + { + caps = caps with {CanDelete = false}; + } + if (caps.CanSearchByTag && metrics[(int)BehaviorTask.SearchByTag].SeemsDown()) + { + caps = caps with {CanSearchByTag = false}; + } + if (caps.CanPurgeByTag && metrics[(int)BehaviorTask.PurgeByTag].SeemsDown()) + { + caps = caps with {CanPurgeByTag = false}; + } + // We'll always estimate health checks as supported, because we handle that backoff ourselves. + // if (caps.SupportsHealthCheck && metrics[(int)BehaviorTask.HealthCheck].SeemsDown()) + // { + // caps = caps with {SupportsHealthCheck = false}; + // } + return caps; + } + private bool WasAdvertised(BehaviorTask task) + { + return initialAdvertisedCapabilities switch + { + {CanFetchData: true} when task == BehaviorTask.FetchData => true, + {CanFetchMetadata: true} when task == BehaviorTask.FetchMetadata => true, + {CanPut: true} when task == BehaviorTask.Put => true, + {CanDelete: true} when task == BehaviorTask.Delete => true, + {CanSearchByTag: true} when task == BehaviorTask.SearchByTag => true, + {CanPurgeByTag: true} when task == BehaviorTask.PurgeByTag => true, + {SupportsHealthCheck: true} when task == BehaviorTask.HealthCheck => true, + _ => false + }; + } + + public void WriteHealthCheckStatusTo(StringBuilder sb, string linePrefix) + { + + // We have 4 states - healthy, unhealthy, unsupported, and not responding. + // consecutiveIdenticalHealthCheckCaps determines how long it's been that way. (although unsupported we don't count) + if (!SupportsHealthCheck) + { + sb.Append(linePrefix); + sb.AppendLine("Health checks are not supported by this cache."); + return; + } + // not responding + else if (ConsecutiveCheckCrashes > 0) + { + sb.Append(linePrefix); + sb.AppendLine($"Health check has crashed for the last {ConsecutiveCheckCrashes} times in a row ({LastHealthCheckException?.Message})."); + + } else if (ConsecutiveCheckResponses > 0) + { + var statusLabel = LastHealthCheckCompleted?.AtFullHealth == true ? "Healthy" : "Unhealthy"; + sb.Append(linePrefix); + sb.AppendLine( + $"Health check has reported [{statusLabel}] for the last {consecutiveIdenticalHealthCheckCaps} checks."); + + } + if (LastCheckCompletedOrFailed == default) + { + // Shouldn't happen + sb.Append(linePrefix); + sb.AppendLine("Health check has not been attempted yet."); + return; + } + + var interval = GetOrAdjustHealthCheckInterval(true); + // last attempt was {time} ago, next attempt in {time}, current interval is {time} + var nextIn = (LastCheckCompletedOrFailed + interval - DateTimeOffset.UtcNow); + var lastAgo = (DateTimeOffset.UtcNow - LastCheckCompletedOrFailed); + sb.AppendLine($@"Last check was {lastAgo:hh\:mm\:ss}s, next attempt in {nextIn:hh\:mm\:ss}s, current interval is {lastDelay:hh\:mm\:ss}s."); + } + + public void WriteMetricsTo(StringBuilder sb, string linePrefix, bool minimal) + { + WriteHealthCheckStatusTo(sb, linePrefix); + var estimatedCapabilities = EstimateCapabilities(); + + sb.Append(linePrefix); + sb.AppendLine("Advertised cache capabilities:"); + initialAdvertisedCapabilities.WriteTruePairs(sb); + sb.AppendLine(); + if (SupportsHealthCheck) + { + if (LastHealthCheckCompleted != null && + initialAdvertisedCapabilities != LastHealthCheckCompleted?.CurrentCapabilities) + { + sb.Append(linePrefix); + sb.AppendLine("Last responding health check instead reported:"); + LastHealthCheckCompleted?.CurrentCapabilities.WriteTruePairs(sb); + + sb.Append("Difference: "); + LastHealthCheckCompleted?.CurrentCapabilities.WriteReducedPairs(sb, initialAdvertisedCapabilities); + sb.AppendLine(); + } + } + if (initialAdvertisedCapabilities != estimatedCapabilities) + { + sb.Append(linePrefix); + sb.AppendLine("Current observed cache capabilities also differ: "); + estimatedCapabilities.WriteTruePairs(sb); + sb.AppendLine(); + sb.Append("Difference: "); + estimatedCapabilities.WriteReducedPairs(sb, initialAdvertisedCapabilities); + sb.AppendLine(); + } + + if (!minimal) + { + sb.Append(linePrefix); + sb.AppendLine(HealthCheckCrashing + ? "Observed behavior metrics: (due to health check crashes, we are using these instead)" + : "Observed behavior metrics:"); + WriteMetricsTo(sb, collectedMetrics, linePrefix, minimal); + if (!SupportsHealthCheck && !minimal) + { + sb.Append(linePrefix); + sb.AppendLine("Health check reports over time:"); + WriteMetricsTo(sb, healthCheckMetrics, linePrefix, minimal); + } + } + } + + private void WriteMetricsTo(StringBuilder sb, BehaviorMetrics[] metrics, string linePrefix, bool minimal) + { + foreach(var m in metrics){ + var advertised = WasAdvertised(m.Task); + if (minimal && !advertised) continue; + sb.Append(linePrefix); + sb.Append(m.Task.ToString()); + sb.Append(": "); + if (advertised) + { + m.WriteSummaryTo(sb); + sb.AppendLine(); + } + else + { + sb.AppendLine("-- never implemented --"); + } + } + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Health/CacheHealthStatus.cs b/src/Imazen.Routing/Health/CacheHealthStatus.cs new file mode 100644 index 00000000..9b069de7 --- /dev/null +++ b/src/Imazen.Routing/Health/CacheHealthStatus.cs @@ -0,0 +1,168 @@ +using System.Diagnostics; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Logging; +using Imazen.Routing.Caching.Health; +using Imazen.Routing.Helpers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Imazen.Routing.Health; + +internal class CacheHealthStatus : IHostedService, IDisposable +{ + // We want to track the history of health checks so we can plan for recovery + // We want to track the current health status so we can make decisions about routing + // If we have failures happening a lot, we should trigger a health check even if it's in good health. + private IBlobCache Cache { get; } + public bool SupportsHealthCheck { get; init; } + private IBlobCacheHealthDetails? CurrentHealthDetails { get; set; } + + private CacheHealthMetrics Metrics { get; init; } + private static TimeSpan Timeout => TimeSpan.FromMinutes(3); + + private readonly NonOverlappingAsyncRunner? healthCheckTask; + + private IReLogger Logger { get; } + + /// + /// Determine if the result (whether a lastCheckException or a CurrentHealthDetails or a null CurrentHealthDetails) is stale. + /// + /// + private bool IsStale() + { + return Metrics.IsStale(); + } + + /// + /// Triggers a health check. This could eventually be expanded to specify the category of problem.(fetch/put/search/etc) + /// + public void ReportBehavior(bool successful, BehaviorTask task, TimeSpan duration = default) + { + Metrics.ReportBehavior(successful, task, duration); + if (IsStale()) healthCheckTask?.FireAndForget(); + } + + public CacheHealthStatus(IBlobCache cache, IReLogger logger) + { + Cache = cache; + Logger = logger.WithSubcategory($"CacheHealth({cache.UniqueName})"); + SupportsHealthCheck = cache.InitialCacheCapabilities.SupportsHealthCheck; + Metrics = new CacheHealthMetrics(Cache.InitialCacheCapabilities); + if (!SupportsHealthCheck) return; + healthCheckTask = new NonOverlappingAsyncRunner( + async (ct) => + { + + var sw = Stopwatch.StartNew(); + try + { + var result = await Cache.CacheHealthCheck(ct); + sw.Stop(); + var priorHealth = CurrentHealthDetails; + CurrentHealthDetails = result; + Metrics.ReportHealthCheck(result, priorHealth, sw.Elapsed); + if (result.AtFullHealth) + { + if (priorHealth is { AtFullHealth: false }) + { + Logger.WithRetain.LogInformation("Cache {CacheName} is back at full health: {HealthDetails}", + Cache.UniqueName, result.GetReport()); + } + } + else + { + if (priorHealth is { AtFullHealth: true }) + { + Logger.WithRetain.LogError("Cache {CacheName} went from full health down to: {HealthDetails}", + Cache.UniqueName, result.GetReport()); + } + if (Metrics.ConsecutiveUnhealthyChecks > 0) + { + Logger.WithRetain.LogWarning( + "Cache {CacheName} is still not at full health after 2 checks: {HealthDetails}", + Cache.UniqueName, result.GetReport()); + } + } + return result; + } + catch (Exception ex) + { + sw.Stop(); + Metrics.ReportFailedHealthCheck(ex, CurrentHealthDetails, sw.Elapsed); + Logger.WithRetain.LogError(ex, "Error checking health of cache {CacheName} (Failure #{CrashCount})", + Cache.UniqueName, Metrics.ConsecutiveCheckCrashes); + throw; + } + + }, false, Timeout); + } + + + + public Task StartAsync(CancellationToken cancellationToken) + { + return healthCheckTask?.StartAsync(cancellationToken) ?? Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken = default) + { + return healthCheckTask?.StopAsync(cancellationToken) ?? Task.CompletedTask; + } + + public BlobCacheCapabilities GetEstimatedCapabilities() + { + if (IsStale()) healthCheckTask?.FireAndForget(); + return Metrics.EstimateCapabilities(); + } + + /// + /// Checks the health of the blob cache. If the check crashed, the exception is rethrown (even if cached) + /// + /// True to force a fresh health check, false to used cached results. + /// The timeout for us to wait (underlying check isn't canceled) + /// The cancellation token to cancel our waiting. + /// A task that represents the asynchronous operation. The task result contains the blob cache health details, or null if the cache does not support health check or is cancelled. + public ValueTask CheckHealth(bool forceFresh, TimeSpan timeout = default, CancellationToken cancellationToken = default) + { + if (!Cache.InitialCacheCapabilities.SupportsHealthCheck) + { + return Tasks.ValueResult(null); + } + if (forceFresh || IsStale()) return healthCheckTask!.RunNonOverlappingAsync(timeout, cancellationToken)!; + + if (Metrics.LastHealthCheckException != null) + { + throw Metrics.LastHealthCheckException; + } + return new ValueTask(CurrentHealthDetails); + } + + + public async ValueTask GetReport(bool forceFresh, CancellationToken cancellationToken) + { + // TODO: make better + var health = await CheckHealth(forceFresh, default, cancellationToken); + if (health == null) + { + return !Cache.InitialCacheCapabilities.SupportsHealthCheck ? + $"Cache '{Cache.UniqueName}' ({Cache.GetType().FullName}) does not support health checks." + : $"!!! ERROR Cache '{Cache.UniqueName}' ({Cache.GetType().FullName}) is not responding to health checks"; + } + var header = $"Cache '{Cache.UniqueName}' ({Cache.GetType().FullName})"; + // Wrap header with !!! instead of === when we have a problem + if (health.AtFullHealth) + { + header = $"====== OK: {header} is at full health ======"; + } + else + { + header = $"!!!!!! ERROR: {header} has problems !!!!!!"; + } + return $"\n{header}\n{health.GetReport()}\n\n"; + } + + public void Dispose() + { + healthCheckTask?.Dispose(); + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Health/CacheHealthTracker.cs b/src/Imazen.Routing/Health/CacheHealthTracker.cs new file mode 100644 index 00000000..684d17b0 --- /dev/null +++ b/src/Imazen.Routing/Health/CacheHealthTracker.cs @@ -0,0 +1,40 @@ +using System.Collections.Concurrent; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Logging; +using Imazen.Routing.Helpers; +using Microsoft.Extensions.Hosting; + +namespace Imazen.Routing.Health; + +internal class CacheHealthTracker(IReLogger logger) : IHostedService +{ + private readonly ConcurrentDictionary cacheHealth = new ConcurrentDictionary(); + + // ReSharper disable once HeapView.CanAvoidClosure + private CacheHealthStatus this[IBlobCache cache] => cacheHealth.GetOrAdd(cache, c => new CacheHealthStatus(c, logger)); + + public BlobCacheCapabilities GetEstimatedCapabilities(IBlobCache cache) + { + return this[cache].GetEstimatedCapabilities(); + } + + public ValueTask CheckHealth(IBlobCache cache, bool forceFresh, TimeSpan timeout, CancellationToken cancellationToken = default) + { + return cacheHealth[cache].CheckHealth(forceFresh, timeout, cancellationToken); + } + + public async ValueTask GetReport(bool forceFresh, CancellationToken cancellationToken = default) + { + var reports = await Tasks.WhenAll(cacheHealth.Values.Select(x => x.GetReport(forceFresh, cancellationToken))); + return string.Join("\n\n", reports); + } + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.WhenAll(cacheHealth.Values.Select(x => x.StopAsync(cancellationToken)).ToArray()); + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Health/DiagnosticsReport.cs b/src/Imazen.Routing/Health/DiagnosticsReport.cs new file mode 100644 index 00000000..6ffc055e --- /dev/null +++ b/src/Imazen.Routing/Health/DiagnosticsReport.cs @@ -0,0 +1,273 @@ +using System.Globalization; +using System.Reflection; +using System.Security; +using System.Text; +using Imazen.Abstractions; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.DependencyInjection; +using Imazen.Abstractions.Logging; +using Imazen.Common.Extensibility.ClassicDiskCache; +using Imazen.Common.Extensibility.StreamCache; +using Imazen.Common.Issues; +using Imazen.Common.Storage; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Serving; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Imazen.Routing.Health; + +public class StartupDiagnostics(IImageServerContainer serviceProvider) +{ + public void LogIssues(IReLogger logger) + { + DetectNamingConflicts(logger, false); + // Log an error warning if IStreamCache or IClassicDiskCache has any registrations + // Each implementation gets its own error so users know what class & assembly to search for +#pragma warning disable CS0618 // Type or member is obsolete + var classicDiskCaches = serviceProvider.GetService>(); +#pragma warning restore CS0618 // Type or member is obsolete + if (classicDiskCaches != null) + { + foreach (var cache in classicDiskCaches) + { + logger?.WithRetain.LogError( + "IClassicDiskCache is obsolete and ignored. Please use the official caches or implement IBlobCache instead. {FullName} is registered in {Assembly}", + cache.GetType().FullName, cache.GetType().Assembly.FullName); + } + } + +#pragma warning disable CS0618 // Type or member is obsolete + var streamCaches = serviceProvider.GetService>(); +#pragma warning restore CS0618 // Type or member is obsolete + if (streamCaches == null) return; + foreach (var cache in streamCaches) + { + logger?.WithRetain.LogError( + "IStreamCache is obsolete and ignored. Please use the official caches or implement IBlobCache instead. {FullName} is registered in {Assembly}", + cache.GetType().FullName, cache.GetType().Assembly.FullName); + } + + } + + private void DetectNamingConflicts(IReLogger logger, bool throwOnConflict = true) + { + var blobCacheProviders = serviceProvider.GetService>(); + var all = new List(); + if (blobCacheProviders != null) + { + foreach (var provider in blobCacheProviders) + { + var caches = provider.GetBlobCaches(); + all.AddRange(caches); + } + } + all.AddRange(serviceProvider.GetInstanceOfEverythingLocal()); + // deduplicate instances by reference + all = all.Distinct().ToList(); + + // throw exception if any duplicate names exist, and put the type names in the exception message + var duplicateNames = all.GroupBy(x => x.UniqueName).Where(x => x.Count() > 1) + .ToList(); + if (duplicateNames.Count > 0) + { + StringBuilder sb = new StringBuilder(); + // collect all the type names + // for each group of conflicting names, log an error + foreach (var item in duplicateNames.SelectMany(group => group)) + { + logger?.WithRetain.LogError( + "Duplicate IUniqueName '{UniqueName}' detected (assigned to type {FullName}, from {Assembly})", + item.UniqueName, item.GetType().FullName, item.GetType().Assembly.FullName); + sb.AppendLine($"Duplicate IUniqueName '{item.UniqueName}' detected (assigned to type {item.GetType().FullName}, from {item.GetType().Assembly.FullName})"); + } + if (throwOnConflict) + { + throw new InvalidOperationException(sb.ToString()); + } + } + + } + + public void Validate(IReLogger logger) + { + DetectNamingConflicts(logger, true); + } +} + +public class DiagnosticsReport(IServiceProvider serviceProvider, IReLogStore logStore) +{ + + /// + /// SectionProviders should include Licensing and ImageServer at minimum + /// + /// + /// + /// + public ValueTask GetReport(IHttpRequestStreamAdapter? request, IReadOnlyCollection sectionProviders) + { + // TODO: add support for beginning tasks and having a refresh header that reloads the page in time for those health + // checks to complete + + var sections = + GetAllImplementers(serviceProvider).Concat(sectionProviders).Distinct().ToList(); + + + var s = new StringBuilder(8096); + var now = DateTime.UtcNow.ToString(NumberFormatInfo.InvariantInfo); + s.AppendLine($"Diagnostics for Imageflow at {request?.GetHost().Value} generated {now} UTC"); + + try + { + using var job = new Imageflow.Bindings.JobContext(); + var version = job.GetVersionInfo(); + s.AppendLine($"libimageflow {version.LongVersionString}"); + } + catch (Exception e) + { + s.AppendLine($"Failed to get libimageflow version: {e.Message}"); + } + + s.AppendLine("Please remember to provide this page when contacting support."); + + + // get array of loaded assemblies + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + var summary = assemblies.ExplainVersionMismatches(true); + if (!string.IsNullOrEmpty(summary)) + { + s.AppendLine(summary); + } + + + var issueProviders = GetAllImplementers(serviceProvider).ToList(); + if (issueProviders.Count == 0) + { + s.AppendLine("No IIssueProvider implementations detected."); + } + else + { + var issues = issueProviders + .SelectMany(p => p.GetIssues()).ToList(); + s.AppendLine( + $"{issues.Count} issues from {issueProviders.Count} legacy plugins (those implementing IIssueProvider instead of calling IReLogger.WithRetain.) detected:\r\n"); + foreach (var i in issues.OrderBy(i => i?.Severity)) + s.AppendLine( + $"{i?.Source}({i?.Severity}):\t{i?.Summary}\n\t\t\t{i?.Details?.Replace("\n", "\r\n\t\t\t")}\n"); + } + + foreach (var sectionProvider in sections) + { + s.AppendLine(sectionProvider.GetDiagnosticsPageSection(DiagnosticsPageArea.Start)); + } + + // ReStore report + var shortReport = logStore.GetReport(new ReLogStoreReportOptions() + { + ReportType = ReLogStoreReportType.FullReport, + }); + s.AppendLine(shortReport); + + s.AppendLine("\nAccepted querystring keys:\n"); + s.AppendLine(string.Join(", ", PathHelpers.SupportedQuerystringKeys)); + + s.AppendLine("\nAccepted file extensions:\n"); + s.AppendLine(string.Join(", ", PathHelpers.AcceptedImageExtensions)); + + s.AppendLine("\nEnvironment information:\n"); + s.AppendLine( + $"Running on {Environment.OSVersion} and CLR {Environment.Version} and .NET Core {GetNetCoreVersion()}"); + + try + { + var wow64 = Environment.GetEnvironmentVariable("PROCESSOR_ARCHITEW6432"); + var arch = Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE"); + s.AppendLine("OS arch: " + arch + (string.IsNullOrEmpty(wow64) + ? "" + : " !! Warning, running as 32-bit on a 64-bit OS(" + wow64 + + "). This will limit ram usage !!")); + } + catch (SecurityException) + { + s.AppendLine( + "Failed to detect operating system architecture - security restrictions prevent reading environment variables"); + } + + + //List loaded assemblies, and also detect plugin assemblies that are not being used. + s.AppendLine("\nLoaded assemblies:\n"); + + foreach (var a in assemblies) + { + if (a.FullName == null) continue; + var assemblyName = new AssemblyName(a.FullName); + var line = ""; + var error = a.GetExceptionForReading(); + if (error != null) + { + line += $"{assemblyName.Name,-40} Failed to read assembly attributes: {error.Message}"; + } + else + { + var version = $"{a.GetFileVersion()} ({assemblyName.Version})"; + var infoVersion = $"{a.GetInformationalVersion()}"; + line += $"{assemblyName.Name,-40} File: {version,-25} Informational: {infoVersion,-30}"; + } + + s.AppendLine(line); + } + + // Now get the sections while passing in DiagnosticsPageArea.End + + foreach (var sectionProvider in sections) + { + var section = sectionProvider.GetDiagnosticsPageSection(DiagnosticsPageArea.End); + if (!string.IsNullOrEmpty(section)) + { + s.AppendLine(section); + } + } + + return Tasks.ValueResult(s.ToString()); + } + + private static List GetAllImplementers(IServiceProvider serviceProvider) where T: class + { + void Add(ICollection candidates) + { + var items = serviceProvider.GetService>(); + if (items == null) return; + foreach (var tvObj in items) + { + if (tvObj is not T t) continue; + if (!candidates.Contains(t)) + { + candidates.Add(t); + } + } + } + var c = new List(); + Add(c); + Add(c); + Add(c); + Add(c); + +#pragma warning disable CS0618 // Type or member is obsolete + Add(c); + Add(c); + Add(c); + Add(c); +#pragma warning restore CS0618 // Type or member is obsolete + return c.Distinct().ToList(); + } + private static string GetNetCoreVersion() + { + return System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; + } +} + + + + diff --git a/src/Imazen.Routing/Helpers/AssemblyHelpers.cs b/src/Imazen.Routing/Helpers/AssemblyHelpers.cs new file mode 100644 index 00000000..247d8136 --- /dev/null +++ b/src/Imazen.Routing/Helpers/AssemblyHelpers.cs @@ -0,0 +1,327 @@ +using System.Reflection; +using System.Text; +using Imazen.Abstractions.AssemblyAttributes; + +namespace Imazen.Routing.Helpers; + +internal static class AssemblyHelpers +{ + + private static bool TryGetVersionedWithAssemblies(this Assembly a, out string[]? patterns, out Exception? error) + { + patterns = null; + error = null; + try + { + var attrs = a.GetCustomAttributes(typeof(VersionedWithAssembliesAttribute), false); + if (attrs.Length > 0) + { + patterns = ((VersionedWithAssembliesAttribute)attrs[0]).AssemblyPatterns; + return true; + } + } + catch (Exception e) + { + error = e; + } + + return false; + } + + private readonly record struct AssemblyVersionInfo + { + internal string? CompatibilityVersion { get; init; } + internal string? AssemblyVersion { get; init; } + internal string? FileVersion { get; init; } + internal string? InformationalVersion { get; init; } + internal string? FullName { get; init; } + internal string? Commit { get; init; } + internal string? BuildDate { get; init; } + internal string? NugetPackageName { get; init; } + internal Exception? AccessError { get; init; } + + public bool IsFileNotFoundException => AccessError is FileNotFoundException; + + public bool HasCompatibilityVersion => !string.IsNullOrWhiteSpace(CompatibilityVersion); + + public static AssemblyVersionInfo FromAssembly(Assembly a) + { + // catch exceptions (missing dependencies, etc) + try + { + Exception? error = null; + var fullName = a.FullName; + string? version; + try + { + version = a.GetName().Version?.ToString(); + } + catch (Exception e) + { + version = null; + error = e; + } + + var fileVersion = a.TryGetFirstAttribute(ref error)?.Version; + var infoVersion = a.TryGetFirstAttribute(ref error) + ?.InformationalVersion; + var buildDate = a.TryGetFirstAttribute(ref error)?.Value; + var commit = a.TryGetFirstAttribute(ref error)?.Value; + var nugetPackageName = a.TryGetFirstAttribute(ref error)?.PackageName; + return new AssemblyVersionInfo + { + FileVersion = fileVersion, + InformationalVersion = infoVersion, + FullName = fullName, + AssemblyVersion = version, + BuildDate = buildDate, + Commit = commit, + NugetPackageName = nugetPackageName, + CompatibilityVersion = string.IsNullOrWhiteSpace(fileVersion) ? null : fileVersion, + }; + + } + catch (Exception e) + { + return new AssemblyVersionInfo + { + AccessError = e + }; + } + + + } + } + + private class AssemblyGroup + { + private List Patterns { get; } = []; + internal List Assemblies { get; } = []; + + internal List VersionInfo { get; } = []; + public int AssemblyCount => Assemblies.Count; + + internal bool HasMissingAssemblyVersionData; + internal List? MissingCompatibilityVersionData; + internal bool HasMismatchedFileVersions; + internal List? MismatchedFileVersions; + + private bool calculated; + + internal void CalculateFinal() + { + if (calculated) return; + calculated = true; + + HasMissingAssemblyVersionData = VersionInfo.Any(v => !v.HasCompatibilityVersion); + if (HasMissingAssemblyVersionData) + { + MissingCompatibilityVersionData = VersionInfo.Where(v => !v.HasCompatibilityVersion).ToList(); + } + + HasMismatchedFileVersions = VersionInfo.GroupBy(v => v.CompatibilityVersion).Count() > 1; + if (HasMismatchedFileVersions) + { + MismatchedFileVersions = VersionInfo.Where(v => v.HasCompatibilityVersion) + .OrderBy(v => v.CompatibilityVersion).ToList(); + } + } + + private static bool PatternMatches(string pattern, string? patternOrAssembly) + { + if (string.IsNullOrWhiteSpace(patternOrAssembly)) return false; + if (pattern.Equals(patternOrAssembly, StringComparison.OrdinalIgnoreCase)) return true; + return pattern.EndsWith("*") && + patternOrAssembly!.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase); + } + + internal bool Matches(string pattern) + { + return Patterns.Any(p => PatternMatches(pattern, p)) || + VersionInfo.Any(v => PatternMatches(pattern, v.FullName ?? "")); + } + + public void Add(Assembly a) + { + if (Assemblies.Contains(a)) return; + Assemblies.Add(a); + var info = AssemblyVersionInfo.FromAssembly(a); + VersionInfo.Add(info); + if (!a.TryGetVersionedWithAssemblies(out var patterns, out _)) return; + if (patterns != null) Patterns.AddRange(patterns); + } + + + } + + private static List GetVersionedWithAssemblyGroups(this Assembly[] list) + { + var groups = new List(); + foreach (var a in list) + { + var assemblyName = a.GetName().Name; + if (string.IsNullOrWhiteSpace(assemblyName)) continue; + var group = groups.FirstOrDefault(g => g.Matches(assemblyName)); + if (group == null) + { + group = new AssemblyGroup(); + groups.Add(group); + } + group.Add(a); + } + foreach (var group in groups) + { + group.CalculateFinal(); + } + + return groups; + } + + internal static string ExplainVersionMismatches(this Assembly[] loadedAssemblies, bool listSuccesses = false) + { + var groups = loadedAssemblies.GetVersionedWithAssemblyGroups(); + + var sb = new StringBuilder(); + foreach (var group in groups) + { + bool ok = group is { HasMissingAssemblyVersionData: false, HasMismatchedFileVersions: false }; + if (ok && group.AssemblyCount > 1 && listSuccesses) + { + // {x} assemblies have compatible versions (version) + sb.AppendLine( + $"OK: {group.Assemblies.Count} assemblies have compatible versions ({group.VersionInfo[0].CompatibilityVersion})"); + } + + if (group.HasMissingAssemblyVersionData) + { + sb.AppendLine( + $"ERROR: Missing compatibility version for {group.MissingCompatibilityVersionData?.Count} assemblies:"); + foreach (var info in group.MissingCompatibilityVersionData!) + { + sb.AppendLine($"ERROR: {info.FullName} {info.AccessError?.Message}"); + } + } + + if (group.HasMismatchedFileVersions) + { + sb.AppendLine($"ERROR: Mismatched file versions for {group.MismatchedFileVersions?.Count} assemblies:"); + foreach (var info in group.MismatchedFileVersions!) + { + sb.AppendLine($" {info.FullName} ({info.FileVersion})"); + } + + sb.AppendLine(); + } + } + + return sb.ToString(); + } + + private static T? GetFirstAttribute(this ICustomAttributeProvider a) + { + try + { + var attrs = a.GetCustomAttributes(typeof(T), false); + if (attrs.Length > 0) return (T)attrs[0]; + } + catch (FileNotFoundException) + { + //Missing dependencies + } + // ReSharper disable once EmptyGeneralCatchClause + catch (Exception) + { + } + + return default; + } + + private static T? TryGetFirstAttribute(this ICustomAttributeProvider a, ref Exception? error) + { + try + { + var attrs = a.GetCustomAttributes(typeof(T), false); + if (attrs.Length > 0) return (T)attrs[0]; + } + catch (Exception e) + { + error = e; + } + + return default; + } + + public static Exception? GetExceptionForReading(this Assembly a) + { + try + { + var unused = a.GetCustomAttributes(typeof(T), false); + } + catch (Exception e) + { + return e; + } + + return null; + } + + public static string? GetInformationalVersion(this Assembly a) + { + return GetFirstAttribute(a)?.InformationalVersion; + } + + public static string? GetFileVersion(this Assembly a) + { + return GetFirstAttribute(a)?.Version; + } + + + + + // Imazen.Abstractions.AssemblyAttributes.VersionedWithAssemblies + // Imazen.Abstractions.AssemblyAttributes.BuildDate + + public static string? GetBuildDate(this Assembly a) + { + return GetFirstAttribute(a)?.Value; + } + + public static string[]? GetVersionedWithAssemblies(this Assembly a) + { + return GetFirstAttribute(a)?.AssemblyPatterns; + } + + public static string? GetCommit(this Assembly a) + { + return GetFirstAttribute(a)?.Value; + } + + // Get nuget package name for an assembly + + public static string? GetNugetPackageName(this Assembly a) + { + return GetFirstAttribute(a)?.PackageName; + } + + public static IReadOnlyCollection> GetMetadataPairs(this Assembly a) + { + // sus, autogenerated, where's the try catch and why no casting? + var pairs = new List>(); + var attrs = a.GetCustomAttributesData(); + foreach (var attr in attrs) + { + if (attr.AttributeType == typeof(AssemblyMetadataAttribute)) + { + + var key = attr.ConstructorArguments[0].Value?.ToString(); + var value = attr.ConstructorArguments[1].Value?.ToString(); + if (key != null && value != null) + { + pairs.Add(new KeyValuePair(key, value)); + } + } + } + + return pairs; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Helpers/CollectionHelpers.cs b/src/Imazen.Routing/Helpers/CollectionHelpers.cs new file mode 100644 index 00000000..cbf98663 --- /dev/null +++ b/src/Imazen.Routing/Helpers/CollectionHelpers.cs @@ -0,0 +1,9 @@ +namespace Imazen.Routing.Helpers; + +public static class CollectionHelpers +{ + public static void AddIfUnique(this ICollection collection, T value) + { + if (!collection.Contains(value)) collection.Add(value); + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Helpers/EnumHelpers.cs b/src/Imazen.Routing/Helpers/EnumHelpers.cs new file mode 100644 index 00000000..65c8f46d --- /dev/null +++ b/src/Imazen.Routing/Helpers/EnumHelpers.cs @@ -0,0 +1,29 @@ +namespace Imazen.Routing.Helpers; + +public static class EnumHelpers +{ + /// + /// Maps a to a short string representation + /// Ordinal => "", OrdinalIgnoreCase => "(i)", + /// CurrentCulture => "(current culture)", CurrentCultureIgnoreCase => "(current culture, i)", + /// InvariantCulture => "(invariant)", InvariantCultureIgnoreCase => "(invariant i)" + /// + /// + /// + /// + public static string ToStringShort(this StringComparison comparison) + { + return comparison switch + { + StringComparison.CurrentCulture => "(current culture)", + StringComparison.CurrentCultureIgnoreCase => "(current culture, i)", + StringComparison.InvariantCulture => "(invariant)", + StringComparison.InvariantCultureIgnoreCase => "(invariant i)", + StringComparison.Ordinal => "", + StringComparison.OrdinalIgnoreCase => "(i)", + + + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null) + }; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Helpers/HexHelpers.cs b/src/Imazen.Routing/Helpers/HexHelpers.cs new file mode 100644 index 00000000..456346ab --- /dev/null +++ b/src/Imazen.Routing/Helpers/HexHelpers.cs @@ -0,0 +1,52 @@ +namespace Imazen.Routing.Helpers; + +public static class HexHelpers +{ + public static string ToHexLowercase(this byte[] buffer) => ToHexLowercase(buffer.AsSpan()); + public static string ToHexLowercase(this ReadOnlySpan buffer) + { + Span hexBuffer = buffer.Length > 1024 ? new char[buffer.Length * 2] : stackalloc char[buffer.Length * 2]; + + for(var i = 0; i < buffer.Length; i++) + { + var b = buffer[i]; + var nibbleA = b >> 4; + var nibbleB = b & 0x0F; + hexBuffer[i * 2] = ToCharLower(nibbleA); + hexBuffer[i * 2 + 1] = ToCharLower(nibbleB); + } + return hexBuffer.ToString(); + } + public static string ToHexLowercaseWith(this ReadOnlySpan buffer, ReadOnlySpan prefix, ReadOnlySpan suffix) + { + var prefixLength = prefix.Length; + var bufferOutputLength = buffer.Length * 2; + var requiredChars = prefixLength + bufferOutputLength + suffix.Length; + Span hexBuffer = requiredChars > 1024 ? new char[requiredChars] : stackalloc char[requiredChars]; + + prefix.CopyTo(hexBuffer); + for(var i = 0; i < buffer.Length; i++) + { + var b = buffer[i]; + var nibbleA = b >> 4; + var nibbleB = b & 0x0F; + hexBuffer[prefixLength + (i * 2)] = ToCharLower(nibbleA); + hexBuffer[prefixLength + (i * 2) + 1] = ToCharLower(nibbleB); + } + suffix.CopyTo(hexBuffer[(prefixLength + bufferOutputLength)..]); + return hexBuffer.ToString(); + } + + + + private static char ToCharLower(int value) + { + value &= 0xF; + value += '0'; + if (value > '9') + { + value += ('a' - ('9' + 1)); + } + return (char)value; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Helpers/PathHelpers.cs b/src/Imazen.Routing/Helpers/PathHelpers.cs new file mode 100644 index 00000000..18ce4f62 --- /dev/null +++ b/src/Imazen.Routing/Helpers/PathHelpers.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using System.Text; +using Imazen.Abstractions.HttpStrings; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.Helpers +{ + public static class PathHelpers + { + + // ReSharper disable once HeapView.ObjectAllocation + private static readonly string[] Suffixes = { + ".png", + ".jpg", + ".jpeg", + ".jfif", + ".jif", + ".jfi", + ".jpe", + ".gif", + ".webp", + // Soon, .avif and .jxl will be added + }; + + //TODO: Some of these are only modifier keys, and as such, should not trigger processing + private static readonly string[] QuerystringKeys = new string[] + { + "mode", "anchor", "flip", "sflip", "scale", "cache", "process", + "quality", "zoom", "dpr", "crop", "cropxunits", "cropyunits", + "w", "h", "width", "height", "maxwidth", "maxheight", "format", "thumbnail", + "autorotate", "srotate", "rotate", "ignoreicc", + "stretch", "webp.lossless", "webp.quality", + "frame", "page", "subsampling", "colors", "f.sharpen", "f.sharpen_when", "down.colorspace", + "404", "bgcolor", "paddingcolor", "bordercolor", "preset", "floatspace", "jpeg_idct_downscale_linear", "watermark", + "s.invert", "s.sepia", "s.grayscale", "s.alpha", "s.brightness", "s.contrast", "s.saturation", "trim.threshold", + "trim.percentpadding", "a.blur", "a.sharpen", "a.removenoise", "a.balancewhite", "dither", "jpeg.progressive", + "encoder", "decoder", "builder", "s.roundcorners.", "paddingwidth", "paddingheight", "margin", "borderwidth", "decoder.min_precise_scaling_ratio" + }; + + public static IEnumerable AcceptedImageExtensions => Suffixes; + public static IEnumerable SupportedQuerystringKeys => QuerystringKeys; + + internal static string[] ImagePathSuffixes => Suffixes; + internal static bool IsImagePath(string path) + { + // ReSharper disable once LoopCanBeConvertedToQuery + foreach (var suffix in Suffixes) + { + if (path.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + public static string? SanitizeImageExtension(string extension) + { + extension = extension.ToLowerInvariant().TrimStart('.'); + return extension switch + { + "png" => "png", + "gif" => "gif", + "webp" => "webp", + "jpeg" => "jpg", + "jfif" => "jpg", + "jif" => "jpg", + "jfi" => "jpg", + "jpe" => "jpg", + _ => null + }; + } + public static string? GetImageExtensionFromContentType(string? contentType) + { + return contentType switch + { + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + "image/jpeg" => "jpg", + _ => null + }; + } + + + /// + /// Creates a 43-character URL-safe hash of arbitrary data. Good for implementing caches. SHA256 is used, and the result is base64 encoded with no padding, and the + and / characters replaced with - and _. + /// + /// + /// + public static string CreateBase64UrlHash(string data) => CreateBase64UrlHash(Encoding.UTF8.GetBytes(data)); + + /// + /// Creates a 43-character URL-safe hash of arbitrary data. Good for implementing caches. SHA256 is used, and the result is base64 encoded with no padding, and the + and / characters replaced with - and _. + /// + /// + /// + public static string CreateBase64UrlHash(byte[] data){ + using var sha2 = SHA256.Create(); + // check cache and return if cached + var hashBytes = + sha2.ComputeHash(data); + return Convert.ToBase64String(hashBytes) + .Replace("=", string.Empty) + .Replace('+', '-') + .Replace('/', '_'); + } + + + internal static Dictionary ToQueryDictionary(IEnumerable> requestQuery) + { + var dict = new Dictionary(8, StringComparer.OrdinalIgnoreCase); + foreach (var pair in requestQuery) + { + dict.Add(pair.Key, pair.Value.ToString()); + } + + return dict; + } + + internal static string SerializeCommandString(Dictionary finalQuery) + { + var qs = UrlQueryString.Create(finalQuery.Select(p => new KeyValuePair(p.Key, p.Value))); + return qs.ToString()?.TrimStart('?') ?? ""; + } + + public static Dictionary? ParseQuery(string querystring) + { + return QueryHelpers.ParseNullableQuery(querystring); + } + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Helpers/ReadOnlyMemoryBlob.cs b/src/Imazen.Routing/Helpers/ReadOnlyMemoryBlob.cs new file mode 100644 index 00000000..9217de30 --- /dev/null +++ b/src/Imazen.Routing/Helpers/ReadOnlyMemoryBlob.cs @@ -0,0 +1,123 @@ +using System.Buffers; +using CommunityToolkit.HighPerformance; +using Imazen.Common.Extensibility.Support; +using CommunityToolkit.HighPerformance.Streams; + +namespace Imazen.Abstractions.Blobs +{ + + + public sealed class ConsumableMemoryBlob : IConsumableMemoryBlob + { + public IBlobAttributes Attributes { get; } + + /// + /// Creates a consumable wrapper over owner-less memory (I.E. owned by the garbage collector - it cannot be manually disposed) + /// + /// + /// + public ConsumableMemoryBlob(IBlobAttributes attrs, ReadOnlyMemory memory): this(attrs, memory, null) + { + } + /// + /// + /// + /// + /// + /// + internal ConsumableMemoryBlob(IBlobAttributes attrs, ReadOnlyMemory memory, IMemoryOwner? owner = null) + { + this.Attributes = attrs; + this.memory = memory; + this.owner = owner; + } + public void Dispose() + { + disposed = true; + memory = default; + if (disposalPromise == DisposalPromise.CallerDisposesBlobOnly) + { + streamBorrowed?.Dispose(); + streamBorrowed = null; + } + owner?.Dispose(); + owner = null; + } + private IMemoryOwner? owner; + private ReadOnlyMemory memory; + private bool disposed = false; + private Stream? streamBorrowed; + private DisposalPromise? disposalPromise = default; + public ReadOnlyMemory BorrowMemory + { + get + { + if (disposed) throw new ObjectDisposedException("The ConsumableMemoryBlob has been disposed"); + return memory; + } + } + public bool StreamAvailable => !disposed && streamBorrowed == null; + public long? StreamLength => !disposed ? memory.Length : default; + public Stream BorrowStream(DisposalPromise callerPromises) + { + if (disposed) throw new ObjectDisposedException("The ConsumableMemoryBlob has been disposed. .BorrowStream is an invalid operation on a disposed object"); + if (this.disposalPromise != null) throw new InvalidOperationException("The stream has already been taken"); + streamBorrowed = memory.AsStream(); + disposalPromise = callerPromises; + return streamBorrowed; + } + } + + public sealed class ReusableReadOnlyMemoryBlob : IReusableBlob + { + public IBlobAttributes Attributes { get; } + private ReadOnlyMemory? data; + private bool disposed = false; + public TimeSpan CreationDuration { get; init; } + public DateTime CreationCompletionUtc { get; init; } = DateTime.UtcNow; + + private ReadOnlyMemory Memory => disposed || data == null + ? throw new ObjectDisposedException("The ReusableReadOnlyMemoryBlob has been disposed") + : data.Value; + + public ReusableReadOnlyMemoryBlob(ReadOnlyMemory data, IBlobAttributes metadata, TimeSpan creationDuration, int? backingAllocationSize = null) + { + CreationDuration = creationDuration; + this.data = data; + this.Attributes = metadata; + var backingSize = backingAllocationSize ?? data.Length; + if (backingSize < data.Length) + { + throw new ArgumentException("backingAllocationSize must be at least as large as data.Length"); + } + // Precalculate since it will be called often + EstimateAllocatedBytesRecursive = + 24 + Attributes.EstimateAllocatedBytesRecursive + + 24 + (backingSize); + } + + public int EstimateAllocatedBytesRecursive { get; } + + + public IConsumableBlob GetConsumable() + { + if (IsDisposed) + { + throw new ObjectDisposedException("The ReusableReadOnlyMemoryBlob has been disposed"); + } + return new ConsumableMemoryBlob(Attributes, Memory); + } + + public bool IsDisposed => disposed; + + public long StreamLength => IsDisposed + ? throw new ObjectDisposedException("The ReusableReadOnlyMemoryBlob has been disposed") + : data!.Value.Length; + public void Dispose() + { + disposed = true; + data = null; + } + + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Helpers/Tasks.cs b/src/Imazen.Routing/Helpers/Tasks.cs new file mode 100644 index 00000000..faa6f6a5 --- /dev/null +++ b/src/Imazen.Routing/Helpers/Tasks.cs @@ -0,0 +1,33 @@ +using System.Runtime.CompilerServices; + +namespace Imazen.Routing.Helpers; +internal static class Tasks +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask ValueResult(T t) + { +#if DOTNET5_0_OR_GREATER + return Tasks.ValueResult(t); +#else + return new ValueTask(t); +#endif + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask ValueComplete() + { +#if DOTNET5_0_OR_GREATER + return Tasks.ValueResult(t); +#else + return new ValueTask(); +#endif + } + + public static async ValueTask WhenAll(IEnumerable> tasks) + { + // We could alternately depend on https://www.nuget.org/packages/ValueTaskSupplement + // https://github.com/Cysharp/ValueTaskSupplement/blob/master/src/ValueTaskSupplement/ValueTaskEx.WhenAll.tt + return await Task.WhenAll(tasks.Select(x => x.AsTask())); + } + +} \ No newline at end of file diff --git a/src/Imazen.Routing/HttpAbstractions/DictionaryExtensions.cs b/src/Imazen.Routing/HttpAbstractions/DictionaryExtensions.cs new file mode 100644 index 00000000..319f627b --- /dev/null +++ b/src/Imazen.Routing/HttpAbstractions/DictionaryExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.HttpAbstractions; + +public static class DictionaryExtensions +{ + public static Dictionary ToStringDictionary(this IReadOnlyQueryWrapper query) + { + var d = new Dictionary(query.Count); + foreach (var kvp in query) + { + d[kvp.Key] = kvp.Value.ToString(); + } + return d; + } + public static Dictionary ToStringDictionary(this IDictionary query) + { + var d = new Dictionary(query.Count); + foreach (var kvp in query) + { + d[kvp.Key] = kvp.Value.ToString(); + } + return d; + } + + public static Dictionary ToStringValuesDictionary( + this IEnumerable> query) + { + var d = new Dictionary(); + foreach (var kvp in query) + { + // parse the string into a StringValues, delimited by commas + d[kvp.Key] = new StringValues(kvp.Value.Split(',')); + } + return d; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/HttpAbstractions/HttpAdapterExtensions.cs b/src/Imazen.Routing/HttpAbstractions/HttpAdapterExtensions.cs new file mode 100644 index 00000000..b6e9ae27 --- /dev/null +++ b/src/Imazen.Routing/HttpAbstractions/HttpAdapterExtensions.cs @@ -0,0 +1,6 @@ +namespace Imazen.Routing.HttpAbstractions; + +public class HttpAdapterExtensions +{ + +} \ No newline at end of file diff --git a/src/Imazen.Routing/HttpAbstractions/HttpHeaderNames.cs b/src/Imazen.Routing/HttpAbstractions/HttpHeaderNames.cs new file mode 100644 index 00000000..e13ce1f6 --- /dev/null +++ b/src/Imazen.Routing/HttpAbstractions/HttpHeaderNames.cs @@ -0,0 +1,20 @@ +namespace Imazen.Routing.HttpAbstractions; + +internal static class HttpHeaderNames +{ + public static readonly string IfNoneMatch = "If-None-Match"; + public static readonly string ETag = "ETag"; + public static readonly string CacheControl = "Cache-Control"; + public static readonly string ContentType = "Content-Type"; + public static readonly string ContentLength = "Content-Length"; + public static readonly string ContentEncoding = "Content-Encoding"; + public static readonly string ContentRange = "Content-Range"; + public static readonly string ContentDisposition = "Content-Disposition"; + public static readonly string AcceptRanges = "Accept-Ranges"; + public static readonly string LastModified = "Last-Modified"; + public static readonly string Expires = "Expires"; + public static readonly string Date = "Date"; + public static readonly string Connection = "Connection"; + public static readonly string KeepAlive = "Keep-Alive"; + +} \ No newline at end of file diff --git a/src/Imazen.Routing/HttpAbstractions/IAdaptableHttpResponse.cs b/src/Imazen.Routing/HttpAbstractions/IAdaptableHttpResponse.cs new file mode 100644 index 00000000..74c94879 --- /dev/null +++ b/src/Imazen.Routing/HttpAbstractions/IAdaptableHttpResponse.cs @@ -0,0 +1,17 @@ +using System.Collections.Immutable; + +namespace Imazen.Routing.HttpAbstractions; + +public interface IAdaptableHttpResponse +{ + string? ContentType { get; } + int StatusCode { get; } + IImmutableDictionary? OtherHeaders { get; init; } + + ValueTask WriteAsync(TResponse target, CancellationToken cancellationToken = default) where TResponse : IHttpResponseStreamAdapter; + +} + +public interface IAdaptableReusableHttpResponse : IAdaptableHttpResponse +{ +} diff --git a/src/Imazen.Routing/HttpAbstractions/IHttpRequestStreamAdapter.cs b/src/Imazen.Routing/HttpAbstractions/IHttpRequestStreamAdapter.cs new file mode 100644 index 00000000..fc7ca145 --- /dev/null +++ b/src/Imazen.Routing/HttpAbstractions/IHttpRequestStreamAdapter.cs @@ -0,0 +1,179 @@ +using System.IO.Pipelines; +using System.Net; +using System.Text; +using Imazen.Abstractions.HttpStrings; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.HttpAbstractions; + +/// +/// +/// +/// +public interface IHttpRequestStreamAdapter +{ + // Microsoft.AspNetCore.Http.HttpRequest and System.Web.HttpRequest use different types for their properties. + // PathString, QueryString, and PathBase are all strings in System.Web.HttpRequest, but are structs in Microsoft.AspNetCore.Http.HttpRequest. + // https://source.dot.net/#Microsoft.AspNetCore.Http.Abstractions/PathString.cs,ff3b073b1591ea45 + // https://source.dot.net/#Microsoft.AspNetCore.Http.Abstractions/HostString.cs,677c1978743f8e43 + // https://source.dot.net/#Microsoft.AspNetCore.Http.Abstractions/QueryString.cs,b704925bb788f6c6 + + + /// + /// May be empty if PathBase contains the full path (like for the doc root). + /// Path should be fully decoded except for '%2F' which translates to / and would change path parsing. + /// + /// + + string? TryGetServerVariable(string key); + + UrlPathString GetPath(); + + /// + /// Should not end with a trailing slash. If the app is mounted in a subdirectory, this will contain that part of the path. + /// If no subdir mounting is supported, should return String.Empty. + /// + /// + + UrlPathString GetPathBase(); + + Uri GetUri(); + + UrlQueryString GetQueryString(); + UrlHostString GetHost(); + + IEnumerable>? GetCookiePairs(); + IDictionary GetHeaderPairs(); + IReadOnlyQueryWrapper GetQuery(); + bool TryGetHeader(string key, out StringValues value); + bool TryGetQueryValues(string key, out StringValues value); + + + /// + /// Avoid using this if possible. + /// It's a hack to get around the fact that HttpContext is not a public interface and this + /// interface is used in both non-web contexts and situations where HttpContext may or may not be availble + /// + /// + /// + T? GetHttpContextUnreliable() where T : class; + + string? ContentType { get; } + long? ContentLength { get; } + string Protocol { get; } + string Scheme { get; } + string Method { get; } + bool IsHttps { get; } + + bool SupportsStream { get; } + Stream GetBodyStream(); + + IPAddress? GetRemoteIpAddress(); + IPAddress? GetLocalIpAddress(); + + + bool SupportsPipelines { get; } + PipeReader GetBodyPipeReader(); + +} + +// extension helper for local request +public static class HttpRequestStreamAdapterExtensions +{ + public static string ToShortString(this IHttpRequestStreamAdapter request) + { + // GET /path?query HTTP/1.1 + // Host: example.com:443 + // {count} Cookies + // {count} Headers + // Referer: example.com + // Supports=pipelines,streams + + var sb = new StringBuilder(); + sb.Append(request.Method); + sb.Append(" "); + sb.Append(request.GetPathBase()); + sb.Append(request.GetPath()); + sb.Append(request.GetQueryString()); + sb.Append(" "); + sb.Append(request.Protocol); + sb.Append("\r\n"); + sb.Append("Host: "); + sb.Append(request.GetHost()); + sb.Append("\r\n"); + var cookies = request.GetCookiePairs(); + if (cookies != null) + { + sb.Append(cookies.Count()); + sb.Append(" Cookies\r\n"); + } + var headers = request.GetHeaderPairs(); + sb.Append(headers.Count); + sb.Append(" Headers\r\n"); + foreach (var header in headers) + { + sb.Append(header.Key); + sb.Append(": "); + sb.Append(header.Value); + sb.Append("\r\n"); + } + sb.Append("[Supports="); + if (request.SupportsPipelines) + { + sb.Append("PipeReader,"); + } + if (request.SupportsStream) + { + sb.Append("Stream"); + } + sb.Append("]"); + return sb.ToString(); + } + + public static string GetServerHost(this IHttpRequestStreamAdapter request) + { + var host = request.GetHost(); + return host.HasValue ? host.Value : "localhost"; + } + public static string? GetRefererHost(this IHttpRequestStreamAdapter request) + { + if (request.GetHeaderPairs().TryGetValue("Referer", out var refererValues)) + { + if (refererValues.Count > 0) + { + var referer = refererValues.ToString(); + if (!string.IsNullOrEmpty(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var result)) + { + return result.DnsSafeHost; + } + } + + } + return null; + } + public static bool IsClientLocalhost(this IHttpRequestStreamAdapter request) + { + // What about when a dns record is pointing to the local machine? + var serverIp = request.GetLocalIpAddress(); + if (serverIp == null) + { + return false; + } + var clientIp = request.GetRemoteIpAddress(); + if (clientIp == null) + { + return false; + } + + if (IPAddress.IsLoopback(clientIp)) + { + return true; + } + // if they're the same + if (serverIp.Equals(clientIp)) + { + return true; + } + return false; + } +} diff --git a/src/Imazen.Routing/HttpAbstractions/IHttpResponseStreamAdapter.cs b/src/Imazen.Routing/HttpAbstractions/IHttpResponseStreamAdapter.cs new file mode 100644 index 00000000..aaef5d06 --- /dev/null +++ b/src/Imazen.Routing/HttpAbstractions/IHttpResponseStreamAdapter.cs @@ -0,0 +1,281 @@ +using System.Buffers; +using System.IO.Pipelines; +using System.Runtime.InteropServices; +using Imazen.Abstractions.Blobs; +using Imazen.Routing.Requests; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.HttpAbstractions; + +/// +/// Allows writing to a response stream and setting headers +/// +public interface IHttpResponseStreamAdapter +{ + + void SetHeader(string name, string value); + void SetStatusCode(int statusCode); + int StatusCode { get; } + void SetContentType(string contentType); + string? ContentType { get; } + void SetContentLength(long contentLength); + + bool SupportsStream { get; } + Stream GetBodyWriteStream(); + + bool SupportsPipelines { get; } + PipeWriter GetBodyPipeWriter(); + + bool SupportsBufferWriter { get; } + IBufferWriter GetBodyBufferWriter(); +} + +public static class BufferWriterExtensions +{ + + + + + + + public static void WriteWtf16String(this IBufferWriter writer, string str) + { + var byteCount = str.Length * 2; + var buffer = writer.GetSpan(byteCount); + // marshal + ReadOnlySpan byteSpan = MemoryMarshal.AsBytes(str.AsSpan()); + if (byteSpan.Length > buffer.Length) throw new InvalidOperationException("Buffer length mismatch"); + byteSpan.CopyTo(buffer); + writer.Advance(byteCount); + } + // Write single char + public static void WriteWtf16Char(this IBufferWriter writer, char c) + { + var buffer = writer.GetSpan(2); + buffer[0] = (byte) c; + buffer[1] = (byte) (c >> 8); + writer.Advance(2); + } + + // Write byte + public static void WriteByte(this IBufferWriter writer, byte b) + { + var buffer = writer.GetSpan(1); + buffer[0] = b; + writer.Advance(1); + } + + // Write span + public static void WriteBytes(this IBufferWriter writer, ReadOnlySpan bytes) + { + var buffer = writer.GetSpan(bytes.Length); + bytes.CopyTo(buffer); + writer.Advance(bytes.Length); + } + // Write float + public static void WriteFloat(this IBufferWriter writer, float f) + { + WriteAsInMemory(writer, f); + } + public static void WriteAsInMemory(this IBufferWriter writer, T value) where T : unmanaged + { + // MemoryMarshal.CreateSpan is only on .net2.1+ +#if NETSTANDARD2_1_OR_GREATER + writer.WriteBytes(MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref f, 1))); +#else + // stackalloc a float span otherwise + Span values = stackalloc T[1]; + values[0] = value; + writer.WriteBytes(MemoryMarshal.AsBytes(values)); +#endif + } + public static void WriteAsInMemory(this IBufferWriter writer, Span values) where T : unmanaged + { + writer.WriteBytes(MemoryMarshal.AsBytes(values)); + } + public static void WriteLong(this IBufferWriter writer, long l) + { + var buffer = writer.GetSpan(8); + buffer[0] = (byte) l; + buffer[1] = (byte) (l >> 8); + buffer[2] = (byte) (l >> 16); + buffer[3] = (byte) (l >> 24); + buffer[4] = (byte) (l >> 32); + buffer[5] = (byte) (l >> 40); + buffer[6] = (byte) (l >> 48); + buffer[7] = (byte) (l >> 56); + writer.Advance(8); + } + + public static void WriteInt(this IBufferWriter writer, int i) + { + var buffer = writer.GetSpan(4); + buffer[0] = (byte) i; + buffer[1] = (byte) (i >> 8); + buffer[2] = (byte) (i >> 16); + buffer[3] = (byte) (i >> 24); + writer.Advance(4); + } + + // Write IGetRequestSnapshot + public static void WriteWtf16PathAndRouteValuesCacheKeyBasis(this IBufferWriter writer, IGetResourceSnapshot request) + { + //path = request.Path + //route values = request.RouteValues + + writer.WriteWtf16String("|path="); + writer.WriteWtf16String(request.Path); + writer.WriteWtf16String("|\n|routeValues("); + writer.WriteWtf16Char((char)(request.ExtractedData?.Count ?? 0)); + writer.WriteWtf16String(")|"); + if (request.ExtractedData != null) + { + foreach (var pair in request.ExtractedData) + { + writer.WriteWtf16String(pair.Key); + writer.WriteWtf16String("="); + writer.WriteWtf16String(pair.Value); + writer.WriteWtf16String("|"); + } + } + + } + + public static void WriteUtf8String(this IBufferWriter writer, StringValues str) + { + WriteUtf8String(writer, str.ToString()); // May allocate with multiple values + } + public static void WriteUtf8String(this IBufferWriter writer, string str) + { + var byteCount = System.Text.Encoding.UTF8.GetByteCount(str); + var span = writer.GetSpan(byteCount); +#if NET5_0_OR_GREATER + var written = System.Text.Encoding.UTF8.GetBytes(str.AsSpan(), span); + writer.Advance(written); +#else + var writtenBytes = System.Text.Encoding.UTF8.GetBytes(str); + if (writtenBytes.Length != span.Length) throw new InvalidOperationException("Buffer length mismatch"); + writtenBytes.CopyTo(span); + writer.Advance(byteCount); +#endif + } + + // we want overloads for float, double, int, unit, long, ulong, bool, char, string, byte, enum + // all nullable and non-nullable (we write (char?)null as 0) + public static void WriteWtf(this IBufferWriter writer, string str) + => WriteWtf16String(writer, str); + + public static void WriteWtf(this IBufferWriter writer, char c) + => WriteWtf16Char(writer, c); + + public static void WriteWtf(this IBufferWriter writer, bool b) + => WriteByte(writer, (byte)(b ? 1 : 0)); + + public static void WriteWtf(this IBufferWriter writer, int i) + => WriteInt(writer, i); + + public static void WriteWtf(this IBufferWriter writer, uint i) + => WriteInt(writer, (int)i); + + public static void WriteWtf(this IBufferWriter writer, long l) + => WriteLong(writer, l); + + public static void WriteWtf(this IBufferWriter writer, ulong l) + => WriteLong(writer, (long)l); + + public static void WriteWtf(this IBufferWriter writer, float f) + { + WriteFloat(writer, f); + } + + public static void WriteWtf(this IBufferWriter writer, double d) + { + WriteAsInMemory(writer, d); + } + + public static void WriteWtf(this IBufferWriter writer, byte b) + { + WriteByte(writer, b); + } + + public static void WriteWtf(this IBufferWriter writer, T b) where T : unmanaged + { + WriteAsInMemory(writer, b); + } + + public static void WriteWtf(this IBufferWriter writer, T? b) where T : unmanaged + { + if (b == null) + { + WriteWtf16String(writer, "null"); + return; + } + WriteAsInMemory(writer, b.Value); + } + + // now nullable overloads that write a distinct value for null that can't be confused with a non-null value + // So we use extra bytes or fewer bytes, but never the same number + + + + + +} + +public static class HttpResponseStreamAdapterExtensions +{ + public static void WriteUtf8String(this IHttpResponseStreamAdapter response, string str) + { + if (response.SupportsBufferWriter) + { + var writer = response.GetBodyBufferWriter(); + writer.WriteUtf8String(str); + }else if (response.SupportsStream) + { + var stream = response.GetBodyWriteStream(); + var bytes = System.Text.Encoding.UTF8.GetBytes(str); + stream.Write(bytes, 0, bytes.Length); + } + + } + + /// + /// Use MagicBytes.ProxyToStream instead if you don't know the content type. This method only sets the length of the string. + /// The caller is responsible for disposing IConsumableBlob + /// + /// + /// + /// + /// + public static async ValueTask WriteBlobWrapperBody(this IHttpResponseStreamAdapter response, IConsumableBlob blob, CancellationToken cancellationToken = default) + { + using var consumable = blob; + if (!consumable.StreamAvailable) throw new InvalidOperationException("BlobWrapper must have a stream available"); +#if DOTNET5_0_OR_GREATER + await using var stream = consumable.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob); +#else + using var stream = consumable.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob); +#endif + if (stream.CanSeek) + { + response.SetContentLength(stream.Length - stream.Position); + } + if (response.SupportsPipelines) + { + var writer = response.GetBodyPipeWriter(); + await stream.CopyToAsync(writer, cancellationToken); + }else if (response.SupportsStream) + { + var writeStream = response.GetBodyWriteStream(); +#if DOTNET5_0_OR_GREATER + await stream.CopyToAsync(writeStream, cancellationToken); +#else +await stream.CopyToAsync(writeStream, 81920, cancellationToken); +#endif + } + else + { + throw new InvalidOperationException("IHttpResponseStreamAdapter must support either pipelines or streams in addition to buffer writers"); + } + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/HttpAbstractions/IReadOnlyQueryWrapper.cs b/src/Imazen.Routing/HttpAbstractions/IReadOnlyQueryWrapper.cs new file mode 100644 index 00000000..36ff9f0a --- /dev/null +++ b/src/Imazen.Routing/HttpAbstractions/IReadOnlyQueryWrapper.cs @@ -0,0 +1,128 @@ +using System.Collections; +using System.Collections.Specialized; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.HttpAbstractions; + + +/// +/// Can be created over an IQueryCollection or NameValueCollection or IEnumerable> or Dictionary +/// +public interface IReadOnlyQueryWrapper : IReadOnlyCollection> +{ + bool TryGetValue(string key, out string? value); + bool TryGetValue(string key, out StringValues value); + bool ContainsKey(string key); + StringValues this[string key] { get; } + IEnumerable Keys { get; } + +} + +public class DictionaryQueryWrapper : IReadOnlyQueryWrapper +{ + public DictionaryQueryWrapper(IDictionary dictionary) + { + d = dictionary; + } + + private readonly IDictionary d; + + internal IDictionary UnderlyingDictionary => d; + + public bool TryGetValue(string key, out string? value) + { + if (d.TryGetValue(key, out var values)) + { + value = values; + return true; + } + value = null; + return false; + } + + public bool TryGetValue(string key, out StringValues value) + { + return d.TryGetValue(key, out value); + } + + public bool ContainsKey(string key) + { + return d.ContainsKey(key); + } + + public StringValues this[string key] => d[key]; + + public IEnumerable Keys => d.Keys; + + public int Count => d.Count; + + public IEnumerator> GetEnumerator() + { + return d.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + + private static readonly Dictionary EmptyDict = new Dictionary(); + public static IReadOnlyQueryWrapper Empty { get; } = new DictionaryQueryWrapper(EmptyDict); +} + +public class NameValueCollectionWrapper : IReadOnlyQueryWrapper +{ + public NameValueCollectionWrapper(NameValueCollection collection) + { + c = collection; + } + + private readonly NameValueCollection c; + + public bool TryGetValue(string key, out string? value) + { + value = c[key]; + return value != null; + } + + public bool TryGetValue(string key, out StringValues value) + { + value = c.GetValues(key); + return value != StringValues.Empty; + } + + public bool ContainsKey(string key) + { + return c[key] != null; + } + + public StringValues this[string key] => (StringValues)(c.GetValues(key) ?? Array.Empty()); + + public IEnumerable Keys + { + get + { + foreach (var k in c.AllKeys) + { + if (k != null) yield return k; + yield return ""; + } + } + } + + public int Count => c.Count; + + public IEnumerator> GetEnumerator() + { + foreach (var key in c.AllKeys) + { + yield return new KeyValuePair(key ?? "", (StringValues)(c.GetValues(key) ?? Array.Empty())); + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/HttpAbstractions/SmallHttpResponse.cs b/src/Imazen.Routing/HttpAbstractions/SmallHttpResponse.cs new file mode 100644 index 00000000..733f9462 --- /dev/null +++ b/src/Imazen.Routing/HttpAbstractions/SmallHttpResponse.cs @@ -0,0 +1,146 @@ +using System.Collections.Immutable; +using System.Text; +using Imazen.Abstractions.Resulting; + +namespace Imazen.Routing.HttpAbstractions; + +public readonly record struct SmallHttpResponse : IAdaptableReusableHttpResponse +{ + // status code, content type, byte[] content, + // headers to set, if any + + public required byte[] Content { get; init; } + public string? ContentType { get; init; } + public int StatusCode { get; init; } + public IImmutableDictionary? OtherHeaders { get; init; } + + public SmallHttpResponse WithHeader(string key, string value) + { + return this with { OtherHeaders = (OtherHeaders ?? ImmutableDictionary.Empty).Add(key,value) }; + } + + + + public async ValueTask WriteAsync(TResponse target, CancellationToken cancellationToken = default) where TResponse : IHttpResponseStreamAdapter + { + target.SetStatusCode(StatusCode); + if (ContentType != null) + target.SetContentType(ContentType); + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (Content != null) + target.SetContentLength(Content.Length); + if (OtherHeaders != null) + { + foreach (var kv in OtherHeaders) + { + target.SetHeader(kv.Key, kv.Value); + } + } + + if (Content != null) + { + var stream = target.GetBodyWriteStream(); + await stream.WriteAsync(Content, 0, Content.Length, cancellationToken); + } + } + + public static SmallHttpResponse Text(int statusCode, string text, IImmutableDictionary? otherHeaders = null) + { + return new SmallHttpResponse() + { + Content = Encoding.UTF8.GetBytes(text), + ContentType = "text/plain; charset=utf-8", + StatusCode = statusCode, + OtherHeaders = otherHeaders + }; + } + + public static SmallHttpResponse NotModified() + { + return new SmallHttpResponse() + { + StatusCode = 304, + Content = EmptyBytes, + }; + } + private static readonly byte[] EmptyBytes = Array.Empty(); + + private static readonly IImmutableDictionary NoStoreHeaders = ImmutableDictionary.Empty.Add("Cache-Control","no-store"); + + private static readonly IImmutableDictionary NoStoreNoRobotsHeaders = + ImmutableDictionary.Empty + .Add("Cache-Control", "no-store").Add("X-Robots-Tag", "none"); + + + + public static SmallHttpResponse NoStore(int statusCode, string text) => NoStore(new HttpStatus(statusCode, text)); + + public static SmallHttpResponse NoStore(HttpStatus answer) + { + return new SmallHttpResponse() + { + Content = Encoding.UTF8.GetBytes(answer.ToString()), + ContentType = "text/plain; charset=utf-8", + StatusCode = answer.StatusCode, + OtherHeaders = NoStoreHeaders + }; + } + + public static SmallHttpResponse NoStoreNoRobots(HttpStatus answer) + { + return new SmallHttpResponse() + { + Content = Encoding.UTF8.GetBytes(answer.ToString()), + ContentType = "text/plain; charset=utf-8", + StatusCode = answer.StatusCode, + OtherHeaders = NoStoreNoRobotsHeaders + }; + } + + + + public static SmallHttpResponse NotFound() + { + return new SmallHttpResponse() + { + StatusCode = 404, + Content = EmptyBytes, + }; + } + + public static SmallHttpResponse BadRequest(string text) + { + return new SmallHttpResponse() + { + Content = Encoding.UTF8.GetBytes(text), + ContentType = "text/plain; charset=utf-8", + StatusCode = 400, + }; + } + + public static SmallHttpResponse InternalServerError(string text) + { + return new SmallHttpResponse() + { + Content = Encoding.UTF8.GetBytes(text), + ContentType = "text/plain; charset=utf-8", + StatusCode = 500, + }; + } + + + + + // from bytes + + public static SmallHttpResponse FromBytes(int statusCode, string contentType, byte[] content, IImmutableDictionary? otherHeaders = null) + { + return new SmallHttpResponse() + { + Content = content, + ContentType = contentType, + StatusCode = statusCode, + OtherHeaders = otherHeaders + }; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Imazen.Routing.csproj b/src/Imazen.Routing/Imazen.Routing.csproj new file mode 100644 index 00000000..d58b7f5f --- /dev/null +++ b/src/Imazen.Routing/Imazen.Routing.csproj @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/src/Imazen.Routing/LICENSE.txt b/src/Imazen.Routing/LICENSE.txt new file mode 100644 index 00000000..bae94e18 --- /dev/null +++ b/src/Imazen.Routing/LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/BlobProvidersLayer.cs b/src/Imazen.Routing/Layers/BlobProvidersLayer.cs new file mode 100644 index 00000000..b484189b --- /dev/null +++ b/src/Imazen.Routing/Layers/BlobProvidersLayer.cs @@ -0,0 +1,190 @@ +using System.Buffers; +using System.Text; +using Imazen.Abstractions; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Storage; +using Imazen.Routing.Helpers; +using Imazen.Routing.Promises; +using Imazen.Routing.Requests; + +#pragma warning disable CS0618 // Type or member is obsolete + +namespace Imazen.Routing.Layers; + +public class BlobProvidersLayer : IRoutingLayer +{ + public BlobProvidersLayer(IEnumerable? blobProviders, IEnumerable? blobWrapperProviders) + { + if (blobProviders == null) blobProviders = Array.Empty(); + foreach (var provider in blobProviders) + { + this.blobProviders.Add(provider); + CheckAndAddPrefixes(provider.GetPrefixes(), provider); + } + if (blobWrapperProviders == null) blobWrapperProviders = Array.Empty(); + foreach (var provider in blobWrapperProviders) + { + this.blobWrapperProviders.Add(provider); + if (provider is IBlobWrapperProviderZoned zoned) + { + CheckAndAddPrefixes(zoned.GetPrefixesAndZones(), provider); + } + else + { + CheckAndAddPrefixes(provider.GetPrefixes(), provider); + } + } + + FastPreconditions = Conditions.HasPathPrefixOrdinalIgnoreCase(blobPrefixes.Select(p => p.Prefix).ToArray()); + } + + private void CheckAndAddPrefixes(IEnumerable prefix, object provider) + { + foreach (var p in prefix) + { + CheckAndAddPrefix(p, provider); + } + } + private void CheckAndAddPrefixes(IEnumerable prefix, object provider) + { + foreach (var p in prefix) + { + CheckAndAddPrefix(p.Prefix, provider, p.LatencyZone); + } + } + private void CheckAndAddPrefix(string prefix, object provider, LatencyTrackingZone? zone = null) + { + var conflictingPrefix = + blobPrefixes.FirstOrDefault(p => + prefix.StartsWith(p.Prefix, StringComparison.OrdinalIgnoreCase) || + p.Prefix.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + if (conflictingPrefix != default) + { + throw new InvalidOperationException( + $"Blob Provider failure: Prefix {{prefix}} conflicts with prefix {conflictingPrefix}"); + } + + // We don't check for conflicts with PathMappings because / is a path mapping usually, + // and we simply prefer blobs over files if there are overlapping prefixes. + + blobPrefixes.Add(new BlobPrefix(prefix, provider, zone ?? GetZoneFor(provider, prefix))); + } + + private record struct BlobPrefix(string Prefix, object Provider, LatencyTrackingZone LatencyZone); + private readonly List blobProviders = new List(); + private readonly List blobWrapperProviders = new List(); + private readonly List blobPrefixes = new List(); + + + public string Name => "BlobProviders"; + public IFastCond FastPreconditions { get; } + + private LatencyTrackingZone GetZoneFor(object provider, string prefix) + { + string? zoneName = null; + if (provider is IUniqueNamed named) + { + zoneName = $"{named.UniqueName} ({provider.GetType().Name}) - '{prefix}'"; + } + else + { + zoneName = $"({provider.GetType().Name}) - '{prefix}'"; + } + return new LatencyTrackingZone(zoneName, 100); + } + + public ValueTask?> ApplyRouting(MutableRequest request, + CancellationToken cancellationToken = default) + { + + foreach (var prefix in blobPrefixes) + { + if (request.Path.StartsWith(prefix.Prefix, StringComparison.OrdinalIgnoreCase)) + { + var latencyZone = prefix.LatencyZone; + ICacheableBlobPromise? promise = null; + if (prefix.Provider is IBlobWrapperProvider wp && wp.SupportsPath(request.Path)) + { + promise = new BlobWrapperProviderPromise(request.ToSnapshot(true), request.Path, wp, latencyZone); + }else if (prefix.Provider is IBlobProvider p && p.SupportsPath(request.Path)) + { + promise = new BlobProviderPromise(request.ToSnapshot(true), request.Path, p, latencyZone); + } + + if (promise == null) break; + return Tasks.ValueResult?>(CodeResult.Ok( + new PromiseWrappingEndpoint( + promise))); + } + } + // Note: if GetPrefixes isn't good enough, stuff won't get routed to a blob wrapper. In the past, SupportsPath was always called + return Tasks.ValueResult?>(null); + } + + /// ToString includes all data in the layer, for full diagnostic transparency, and lists the preconditions and data count + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"Routing Layer {Name}: {blobProviders.Count} IBlobProviders, {blobWrapperProviders.Count} IBlobWrapperProviders, PreConditions: {FastPreconditions}"); + sb.AppendLine("Registered prefixes:"); + object? lastProvider = null; + foreach (var prefix in blobPrefixes) + { + // show which IBlobProvider or IBlobWrapperProvider registered this prefix (class name and tostring) + var providerObject = prefix.Provider; + var providerClassName = providerObject?.GetType().Name; + var providerUniqueName = (providerObject as IUniqueNamed)?.UniqueName; + var providerToString = providerObject?.ToString(); + if (lastProvider == providerObject) + { + sb.AppendLine($" {prefix}* -> same ^"); + } + else + { + sb.AppendLine($" {prefix}* -> {providerClassName} ('{providerUniqueName}') {providerToString}"); + } + lastProvider = providerObject; + } + return sb.ToString(); + } + +} + +internal record BlobProviderPromise(IRequestSnapshot FinalRequest, String VirtualPath, IBlobProvider Provider, LatencyTrackingZone LatencyZone): LocalFilesLayer.CacheableBlobPromiseBase(FinalRequest, LatencyZone) +{ + public override void WriteCacheKeyBasisPairsToRecursive(IBufferWriter writer) + { + base.WriteCacheKeyBasisPairsToRecursive(writer); + // We don't add anything, unless we later implement active invalidation. + } + public override async ValueTask> TryGetBlobAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, + CancellationToken cancellationToken = default) + { + var blobData = await Provider.Fetch(VirtualPath); + // TODO: handle exceptions -> CodeResults + if (blobData.Exists == false) return CodeResult.Err((404, "Blob not found")); + + var attrs = new BlobAttributes() + { + LastModifiedDateUtc = blobData.LastModifiedDateUtc, + }; + var stream = blobData.OpenRead(); + return CodeResult.Ok(new BlobWrapper(LatencyZone, new ConsumableStreamBlob(attrs, stream, blobData))); + } +} + +internal record BlobWrapperProviderPromise( + IRequestSnapshot FinalRequest, + String VirtualPath, + IBlobWrapperProvider Provider, LatencyTrackingZone LatencyZone) : LocalFilesLayer.CacheableBlobPromiseBase(FinalRequest, LatencyZone) +{ + public override async ValueTask> TryGetBlobAsync(IRequestSnapshot request, + IBlobRequestRouter router, IBlobPromisePipeline pipeline, + CancellationToken cancellationToken = default) + { + return await Provider.Fetch(VirtualPath); + } +} + \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/CommandDefaultsLayer.cs b/src/Imazen.Routing/Layers/CommandDefaultsLayer.cs new file mode 100644 index 00000000..8f54edb8 --- /dev/null +++ b/src/Imazen.Routing/Layers/CommandDefaultsLayer.cs @@ -0,0 +1,58 @@ +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Helpers; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Layers; + +public record struct CommandDefaultsLayerOptions(bool ApplyDefaultCommandsToQuerylessUrls, Dictionary CommandDefaults); + +public class CommandDefaultsLayer : IRoutingLayer +{ + private CommandDefaultsLayerOptions options; + + public CommandDefaultsLayer(CommandDefaultsLayerOptions options) + { + this.options = options; + if (options.CommandDefaults.Count == 0) + { + FastPreconditions = null; + }else if (this.options.ApplyDefaultCommandsToQuerylessUrls) + { + FastPreconditions = Conditions.True; // TODO: this isn't quite right, I think there is a file extension condition somewhere? + } + else + { + //TODO optimize alloc + FastPreconditions = Conditions.HasQueryStringKey(PathHelpers.SupportedQuerystringKeys.ToArray()); + } + } + public string Name => "CommandDefaults"; + public IFastCond? FastPreconditions { get; } + + public ValueTask?> ApplyRouting(MutableRequest request, + CancellationToken cancellationToken = default) + { + if (FastPreconditions?.Matches(request) ?? false) + { + var query = request.MutableQueryString; + foreach (var pair in options.CommandDefaults) + { + if (!query.ContainsKey(pair.Key)) + { + query[pair.Key] = pair.Value; + } + } + } + + return Tasks.ValueResult?>(null); + } + + /// ToString includes all data in the layer, for full diagnostic transparency, and lists the preconditions and data count + public override string ToString() + { + return $"Routing Layer {Name}: {options.CommandDefaults.Count} defaults, Preconditions: {FastPreconditions}"; + } + + + +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/DiagnosticsPage.cs b/src/Imazen.Routing/Layers/DiagnosticsPage.cs new file mode 100644 index 00000000..8e6a58c5 --- /dev/null +++ b/src/Imazen.Routing/Layers/DiagnosticsPage.cs @@ -0,0 +1,141 @@ +using Imazen.Abstractions.DependencyInjection; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Health; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Promises; +using Imazen.Routing.Requests; +using Imazen.Routing.Serving; +using Microsoft.Extensions.Logging; + +namespace Imazen.Routing.Layers; + + + +public record DiagnosticsPageOptions( + string? DiagnosticsPassword, + DiagnosticsPageOptions.AccessDiagnosticsFrom DiagnosticsAccess) +{ + + /// + /// Where the diagnostics page can be accessed from + /// + public enum AccessDiagnosticsFrom + { + /// + /// Do not allow unauthenticated access to the diagnostics page, even from localhost + /// + None, + /// + /// Only allow localhost to access the diagnostics page + /// + LocalHost, + /// + /// Allow any host to access the diagnostics page + /// + AnyHost + } +} + +internal class DiagnosticsPage( + IImageServerContainer serviceProvider, + IReLogger logger, + IReLogStore retainedLogStore, + DiagnosticsPageOptions options) + : IRoutingEndpoint, IRoutingLayer +{ + + public static bool MatchesPath(string path) => "/imageflow.debug".Equals(path, StringComparison.Ordinal); + + + + public bool IsAuthorized(IHttpRequestStreamAdapter request, out string? errorMessage) + { + errorMessage = null; + var providedPassword = request.GetQuery()["password"].ToString(); + var passwordMatch = !string.IsNullOrEmpty(options.DiagnosticsPassword) + && options.DiagnosticsPassword == providedPassword; + + string s; + if (passwordMatch || + options.DiagnosticsAccess == DiagnosticsPageOptions.AccessDiagnosticsFrom.AnyHost || + (options.DiagnosticsAccess == DiagnosticsPageOptions.AccessDiagnosticsFrom.LocalHost && request.IsClientLocalhost())) + { + return true; + } + else + { + s = + "You can configure access to this page via the imageflow.toml [diagnostics] section, or in C# via ImageflowMiddlewareOptions.SetDiagnosticsPageAccess(allowLocalhost, password)\r\n\r\n"; + if (options.DiagnosticsAccess == DiagnosticsPageOptions.AccessDiagnosticsFrom.LocalHost) + { + s += "You can access this page from the localhost\r\n\r\n"; + } + else + { + s += "Access to this page from localhost is disabled\r\n\r\n"; + } + + if (!string.IsNullOrEmpty(options.DiagnosticsPassword)) + { + s += "You can access this page by adding ?password=[insert password] to the URL.\r\n\r\n"; + } + else + { + s += "You can set a password via imageflow.toml [diagnostics] allow_with_password='' or in C# with SetDiagnosticsPageAccess to access this page remotely.\r\n\r\n"; + } + errorMessage = s; + logger.LogInformation("Access to diagnostics page denied. {message}", s); + return false; + } + } + + private async Task GeneratePage(IRequestSnapshot r) + { + + var request = r.OriginatingRequest; + var diagnostics = new DiagnosticsReport(serviceProvider, retainedLogStore); + + var sectionProviders = + serviceProvider.GetInstanceOfEverythingLocal().ToList(); + + var result = await diagnostics.GetReport(request, sectionProviders); + return result; + } + + + + + public ValueTask GetInstantPromise(IRequestSnapshot request, CancellationToken cancellationToken = default) + { + return Tasks.ValueResult((IInstantPromise)new PromiseFuncAsync(request, async (r, _) => + SmallHttpResponse.NoStoreNoRobots((200, await GeneratePage(r))))); + } + + public string Name => "Diagnostics page"; + public IFastCond FastPreconditions => Precondition; + + public static readonly IFastCond Precondition = Conditions.HasPathSuffix("/imageflow.debug", "/resizer.debug"); + + public ValueTask?> ApplyRouting(MutableRequest request, CancellationToken cancellationToken = default) + { + if (!Precondition.Matches(request)) return default; + if (request.IsChildRequest) return default; + // method not allowed + if (!request.IsGet()) + return new ValueTask?>( + CodeResult.Err((405, "Method not allowed"))); + + if (!IsAuthorized(request.UnwrapOriginatingRequest(), out var errorMessage)) + { + return new ValueTask?>( + CodeResult.Err((401, errorMessage))); + } + return new ValueTask?>( + CodeResult.Ok(this)); + } + + public bool IsBlobEndpoint => false; + +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/IRoutingEndpoint.cs b/src/Imazen.Routing/Layers/IRoutingEndpoint.cs new file mode 100644 index 00000000..7ad08fbf --- /dev/null +++ b/src/Imazen.Routing/Layers/IRoutingEndpoint.cs @@ -0,0 +1,88 @@ +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Promises; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Layers; + +public interface IRoutingEndpoint +{ + bool IsBlobEndpoint { get; } + + ValueTask GetInstantPromise(IRequestSnapshot request, CancellationToken cancellationToken = default); +} + +public class PredefinedResponseEndpoint : IRoutingEndpoint +{ + private readonly IAdaptableReusableHttpResponse response; + + public PredefinedResponseEndpoint(IAdaptableReusableHttpResponse response) + { + this.response = response; + } + + public bool IsBlobEndpoint => false; + + public ValueTask GetInstantPromise(IRequestSnapshot request, CancellationToken cancellationToken = default) + { + return Tasks.ValueResult((IInstantPromise)new PredefinedResponsePromise(request, response)); + } +} +public class AsyncEndpointFunc : IRoutingEndpoint +{ + private readonly Func> func; + + public AsyncEndpointFunc(Func> func) + { + this.func = func; + } + public bool IsBlobEndpoint => false; + + public ValueTask GetInstantPromise(IRequestSnapshot request, CancellationToken cancellationToken = default) + { + return Tasks.ValueResult(new PromiseFuncAsync(request, func)); + } + + private struct AsyncEndpointPromise : IInstantPromise + { + private readonly Func> func; + + public AsyncEndpointPromise(IRequestSnapshot request, Func> func) + { + FinalRequest = request; + this.func = func; + } + + public bool IsCacheSupporting => false; + public IRequestSnapshot FinalRequest { get; } + + public ValueTask CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, + CancellationToken cancellationToken = default) + { + return func(FinalRequest, cancellationToken); + } + } +} +public class SyncEndpointFunc : IRoutingEndpoint +{ + private readonly Func func; + + public SyncEndpointFunc(Func func) + { + this.func = func; + } + public bool IsBlobEndpoint => false; + public ValueTask GetInstantPromise(IRequestSnapshot request, CancellationToken cancellationToken = default) + { + return Tasks.ValueResult((IInstantPromise)new PromiseFunc(request, func)); + } +} + +public record PromiseWrappingEndpoint(ICacheableBlobPromise Promise) : IRoutingEndpoint +{ + public bool IsBlobEndpoint => true; + public ValueTask GetInstantPromise(IRequestSnapshot request, CancellationToken cancellationToken = default) + { + return Tasks.ValueResult((IInstantPromise)Promise); + } +} diff --git a/src/Imazen.Routing/Layers/IRoutingLayer.cs b/src/Imazen.Routing/Layers/IRoutingLayer.cs new file mode 100644 index 00000000..529d2749 --- /dev/null +++ b/src/Imazen.Routing/Layers/IRoutingLayer.cs @@ -0,0 +1,12 @@ +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Layers; + +public interface IRoutingLayer +{ + string Name { get; } + + IFastCond? FastPreconditions { get; } + ValueTask?> ApplyRouting(MutableRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Imazen.Routing/Layers/Licensing.cs b/src/Imazen.Routing/Layers/Licensing.cs new file mode 100644 index 00000000..894278c8 --- /dev/null +++ b/src/Imazen.Routing/Layers/Licensing.cs @@ -0,0 +1,193 @@ +using Imazen.Common.Licensing; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Serving; + +namespace Imazen.Routing.Layers; + +public class LicenseOptions{ + internal string? LicenseKey { get; set; } = ""; + internal string? MyOpenSourceProjectUrl { get; set; } = ""; + + internal string KeyPrefix { get; set; } = "imageflow_"; + public required string[] CandidateCacheFolders { get; set; } + internal EnforceLicenseWith EnforcementMethod { get; set; } = EnforceLicenseWith.RedDotWatermark; + +} +internal class Licensing : ILicenseConfig, ILicenseChecker, IHasDiagnosticPageSection +{ + + private readonly Func? getCurrentRequestUrl; + + private readonly LicenseManagerSingleton mgr; + + private Computation? cachedResult; + internal Licensing(LicenseManagerSingleton mgr, Func? getCurrentRequestUrl = null) + { + this.mgr = mgr; + this.getCurrentRequestUrl = getCurrentRequestUrl; + } + private LicenseOptions? options; + public void Initialize(LicenseOptions licenseOptions) + { + options = licenseOptions; + mgr.MonitorLicenses(this); + mgr.MonitorHeartbeat(this); + + // Ensure our cache is appropriately invalidated + cachedResult = null; + mgr.AddLicenseChangeHandler(this, (me, manager) => me.cachedResult = null); + + // And repopulated, so that errors show up. + if (Result == null) { + throw new ApplicationException("Failed to populate license result"); + } + } + + private bool EnforcementEnabled() + { + return options != null && (!string.IsNullOrEmpty(options.LicenseKey) + || string.IsNullOrEmpty(options.MyOpenSourceProjectUrl)); + } + public IEnumerable> GetDomainMappings() + { + return Enumerable.Empty>(); + } + + public IReadOnlyCollection> GetFeaturesUsed() + { + return new [] {new [] {"Imageflow"}}; + } + + public IEnumerable GetLicenses() + { + return !string.IsNullOrEmpty(options?.LicenseKey) ? Enumerable.Repeat(options!.LicenseKey, 1) : Enumerable.Empty(); + } + + public LicenseAccess LicenseScope => LicenseAccess.Local; + + public LicenseErrorAction LicenseEnforcement + { + get + { + if (options == null) { + return LicenseErrorAction.Http422; + } + return options.EnforcementMethod switch + { + EnforceLicenseWith.RedDotWatermark => LicenseErrorAction.Watermark, + EnforceLicenseWith.Http422Error => LicenseErrorAction.Http422, + EnforceLicenseWith.Http402Error => LicenseErrorAction.Http402, + _ => throw new ArgumentOutOfRangeException() + }; + } + } + + public string EnforcementMethodMessage + { + get + { + return LicenseEnforcement switch + { + LicenseErrorAction.Watermark => + "You are using EnforceLicenseWith.RedDotWatermark. If there is a licensing error, an red dot will be drawn on the bottom-right corner of each image. This can be set to EnforceLicenseWith.Http402Error instead (valuable if you are externally caching or storing result images.)", + LicenseErrorAction.Http422 => + "You are using EnforceLicenseWith.Http422Error. If there is a licensing error, HTTP status code 422 will be returned instead of serving the image. This can also be set to EnforceLicenseWith.RedDotWatermark.", + LicenseErrorAction.Http402 => + "You are using EnforceLicenseWith.Http402Error. If there is a licensing error, HTTP status code 402 will be returned instead of serving the image. This can also be set to EnforceLicenseWith.RedDotWatermark.", + _ => throw new ArgumentOutOfRangeException() + }; + } + } + +#pragma warning disable CS0067 + public event LicenseConfigEvent? LicensingChange; +#pragma warning restore CS0067 + + public event LicenseConfigEvent? Heartbeat; + public bool IsImageflow => true; + public bool IsImageResizer => false; + public string LicensePurchaseUrl => "https://imageresizing.net/licenses"; + + public string AgplCompliantMessage + { + get + { + if (!string.IsNullOrWhiteSpace(options?.MyOpenSourceProjectUrl)) + { + return "You have certified that you are complying with the AGPLv3 and have open-sourced your project at the following url:\r\n" + + options!.MyOpenSourceProjectUrl; + } + else + { + return ""; + } + } + } + + internal Computation Result + { + get { + if (cachedResult?.ComputationExpires != null && + cachedResult.ComputationExpires.Value < mgr.Clock.GetUtcNow()) { + cachedResult = null; + } + return cachedResult ??= new Computation(this, mgr.TrustedKeys,mgr, mgr, + mgr.Clock, EnforcementEnabled()); + } + } + + + public string InvalidLicenseMessage => + "Imageflow cannot validate your license; visit /imageflow.debug or /imageflow.license to troubleshoot."; + + public bool RequestNeedsEnforcementAction(IHttpRequestStreamAdapter request) + { + if (!EnforcementEnabled()) { + return false; + } + + var requestUrl = getCurrentRequestUrl != null ? getCurrentRequestUrl() : + request.GetUri(); + + var isLicensed = Result.LicensedForRequestUrl(requestUrl); + if (isLicensed) { + return false; + } + + if (requestUrl == null && Result.LicensedForSomething()) { + return false; + } + + return true; + } + + + public string GetLicensePageContents() + { + return Result.ProvidePublicLicensesPage(); + } + + public void FireHeartbeat() + { + Heartbeat?.Invoke(this, this); + } + + public string? GetDiagnosticsPageSection(DiagnosticsPageArea section) + { + if (section != DiagnosticsPageArea.End) + { + return Result.ProvidePublicLicensesPage(); + } + var s = new System.Text.StringBuilder(); + s.AppendLine( + "\n\nWhen fetching a remote license file (if you have one), the following information is sent via the querystring."); + foreach (var pair in Result.GetReportPairs().GetInfo()) + { + s.AppendFormat(" {0,32} {1}\n", pair.Key, pair.Value); + } + + + s.AppendLine(Result.DisplayLastFetchUrl()); + return s.ToString(); + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/LicensingLayer.cs b/src/Imazen.Routing/Layers/LicensingLayer.cs new file mode 100644 index 00000000..09784fec --- /dev/null +++ b/src/Imazen.Routing/Layers/LicensingLayer.cs @@ -0,0 +1,49 @@ +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Promises.Pipelines.Watermarking; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Requests; +using Imazen.Routing.Serving; + +namespace Imazen.Routing.Layers; + +internal enum EnforceLicenseWith +{ + RedDotWatermark = 0, + Http422Error = 1, + Http402Error = 2 +} +internal class LicensingLayer(ILicenseChecker licenseChecker, EnforceLicenseWith enforcementMethod) : IRoutingLayer +{ + public string Name => "Licensing"; + public IFastCond? FastPreconditions => null; + public ValueTask?> ApplyRouting(MutableRequest request, CancellationToken cancellationToken = default) + { + if (request.OriginatingRequest == null) return Tasks.ValueResult?>(null); + var licenseFailure = licenseChecker.RequestNeedsEnforcementAction(request.OriginatingRequest); + if (licenseFailure) + { + + if (enforcementMethod is EnforceLicenseWith.Http402Error or EnforceLicenseWith.Http422Error) + { + var response = SmallHttpResponse.NoStoreNoRobots(new HttpStatus( + enforcementMethod == EnforceLicenseWith.Http402Error ? 402 : 422 + , licenseChecker.InvalidLicenseMessage)); + return Tasks.ValueResult?>(new PredefinedResponseEndpoint(response)); + } + + if (enforcementMethod == EnforceLicenseWith.RedDotWatermark) + { + request.MutableQueryString["watermark_red_dot"] = "true"; + } + } + + return Tasks.ValueResult?>(null); + } + + /// ToString includes all data in the layer, for full diagnostic transparency, and lists the preconditions and data count + public override string ToString() + { + return $"Routing Layer {Name}: {enforcementMethod}"; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/LocalFilesLayer.cs b/src/Imazen.Routing/Layers/LocalFilesLayer.cs new file mode 100644 index 00000000..f1240aa2 --- /dev/null +++ b/src/Imazen.Routing/Layers/LocalFilesLayer.cs @@ -0,0 +1,159 @@ +using System.Buffers; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Promises; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Layers; +public interface IPathMapping : IStringAndComparison +{ + string VirtualPath { get; } + string PhysicalPath { get; } + bool IgnorePrefixCase { get; } + + // TODO: when one of these is mapping / to /wwwroot, we need some preconditions to + // prevent all file requests from being routed to it. + + // But we also don't want to be serving .config files if it's NOT root. +} + +public record PathMapping(string VirtualPath, string PhysicalPath, bool IgnorePrefixCase = false) : IPathMapping +{ + public string StringToCompare => VirtualPath; + public StringComparison StringComparison => IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; +} +public class LocalFilesLayer : IRoutingLayer +{ + public LocalFilesLayer(List pathMappings) + { + this.PathMappings = pathMappings; // TODO, should we clone it? + // We want to compare the longest prefixes first so they match in case of collisions + this.PathMappings.Sort((a, b) => b.VirtualPath.Length.CompareTo(a.VirtualPath.Length)); + + // ReSharper disable twice ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (this.PathMappings.Any(m => m.PhysicalPath == null || m.VirtualPath == null)) + { + throw new ArgumentException("Path mappings must have both a virtual and physical path"); + } + FastPreconditions = Conditions.HasPathPrefix(PathMappings); + + } + protected readonly List PathMappings; + + public string Name => "LocalFiles"; + public IFastCond? FastPreconditions { get; } + + + public ValueTask?> ApplyRouting(MutableRequest request, CancellationToken cancellationToken = default) + { + var path = request.Path; + foreach (var mapping in PathMappings) + { + if (path.StartsWith(mapping.VirtualPath, mapping.IgnorePrefixCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + { + + bool isRoot = mapping.VirtualPath == "/" || mapping.VirtualPath == ""; + if (isRoot && !Conditions.HasSupportedImageExtension.Matches(request)) + { + // If it's a / mapping, we only take image extensions. + // This breaks extensionless paths configuration if the root is mapped to a directory or blob provider, however. + + return Tasks.ValueResult?>(null); + } + + var relativePath = path + .Substring(mapping.VirtualPath.Length) + .Replace('/', Path.DirectorySeparatorChar) + .TrimStart(Path.DirectorySeparatorChar); + + var physicalDir = Path.GetFullPath(mapping.PhysicalPath.TrimEnd(Path.DirectorySeparatorChar)); + + var physicalPath = Path.GetFullPath(Path.Combine( + physicalDir, + relativePath)); + if (!physicalPath.StartsWith(physicalDir, StringComparison.Ordinal)) + { + return Tasks.ValueResult?>(null); //We stopped a directory traversal attack (most likely) + } + var lastWriteTimeUtc = File.GetLastWriteTimeUtc(physicalPath); + if (lastWriteTimeUtc.Year == 1601) // file doesn't exist, pass to next middleware + { + return Tasks.ValueResult?>(null); + } + else + { + + var latencyZone = new LatencyTrackingZone(mapping.PhysicalPath, 10); + return Tasks.ValueResult?>(CodeResult.Ok(new PromiseWrappingEndpoint( + new FilePromise(request.ToSnapshot(true), physicalPath, latencyZone, lastWriteTimeUtc)))); + } + } + } + + return Tasks.ValueResult?>(null); + } + + /// ToString includes all data in the layer, for full diagnostic transparency, and lists the preconditions and data count + public override string ToString() + { + // Include all mappings and preconditions, if any + var mappingPairs = PathMappings.Select(m => $" {m.VirtualPath}{(m.IgnorePrefixCase ? @"(i)" : @"")} => {m.PhysicalPath}").ToArray(); + return $"Routing Layer {Name}: {PathMappings.Count} mappings, Preconditions: {FastPreconditions}\n{string.Join("\n", mappingPairs)}"; + } + + + internal record FilePromise(IRequestSnapshot FinalRequest, string PhysicalPath,LatencyTrackingZone LatencyZone, DateTime LastWriteTimeUtc): CacheableBlobPromiseBase(FinalRequest, LatencyZone) + { + + public override void WriteCacheKeyBasisPairsToRecursive(IBufferWriter writer) + { + base.WriteCacheKeyBasisPairsToRecursive(writer); + // We don't write the physical path since it might change based on deployment. The relative path and date are sufficient. + writer.WriteLong(LastWriteTimeUtc.ToBinary()); + } + public override ValueTask> TryGetBlobAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, + CancellationToken cancellationToken = default) + { + return Tasks.ValueResult(CodeResult.Ok(new BlobWrapper(this.LatencyZone, PhysicalFileBlobHelper.CreateConsumableBlob(PhysicalPath, LastWriteTimeUtc)))); + } + } + + internal abstract record CacheableBlobPromiseBase(IRequestSnapshot FinalRequest, LatencyTrackingZone? LatencyZone) : ICacheableBlobPromise + { + public bool IsCacheSupporting => true; + + public async ValueTask CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, + CancellationToken cancellationToken = default) + { + return new BlobResponse(await TryGetBlobAsync(request, router, pipeline, cancellationToken)).AsResponse(); + } + + + public bool HasDependencies => false; + public bool ReadyToWriteCacheKeyBasisData => true; + public ValueTask RouteDependenciesAsync(IBlobRequestRouter router, CancellationToken cancellationToken = default) + { + return Tasks.ValueResult(CodeResult.Ok()); + } + + public virtual void WriteCacheKeyBasisPairsToRecursive(IBufferWriter writer) + { + FinalRequest.WriteCacheKeyBasisPairsTo(writer); + } + + private byte[]? cacheKey32Bytes = null; + public byte[] GetCacheKey32Bytes() + { + return cacheKey32Bytes ??= this.GetCacheKey32BytesUncached(); + } + + public virtual bool SupportsPreSignedUrls => false; + + public abstract ValueTask> TryGetBlobAsync(IRequestSnapshot request, + IBlobRequestRouter router, IBlobPromisePipeline pipeline, + CancellationToken cancellationToken = default); + } + +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/MutableRequestEventLayer.cs b/src/Imazen.Routing/Layers/MutableRequestEventLayer.cs new file mode 100644 index 00000000..849d5557 --- /dev/null +++ b/src/Imazen.Routing/Layers/MutableRequestEventLayer.cs @@ -0,0 +1,77 @@ +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Promises.Pipelines.Watermarking; +using Imazen.Routing.Helpers; +using Imazen.Routing.Requests; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.Layers; +internal class PathPrefixHandler +{ + internal PathPrefixHandler(string prefix, T handler) + { + PathPrefix = prefix; + Handler = handler; + } + public string PathPrefix { get; } + + public T Handler { get; } +} + +internal readonly struct MutableRequestEventArgs +{ + internal MutableRequestEventArgs(MutableRequest request) + { + Request = request; + } + public string VirtualPath { + get => Request.MutablePath; + set => Request.MutablePath = value; + } + public MutableRequest Request { get; } + + public IDictionary Query { + get => Request.MutableQueryString; + set => Request.MutableQueryString = value; + } +} +internal class MutableRequestEventLayer: IRoutingLayer +{ + public MutableRequestEventLayer(string name, List>> handlers) + { + Name = name; + Handlers = handlers; + if (Handlers.Any(x => string.IsNullOrEmpty(x.PathPrefix))) + { + FastPreconditions = new Conditions.FastCondTrue(); + } + else + { + FastPreconditions = new Conditions.FastCondHasPathPrefixes(Handlers.Select(x => x.PathPrefix).ToArray(), + StringComparison.OrdinalIgnoreCase); + } + } + public string Name { get; } + public List>> Handlers { get; } + public IFastCond? FastPreconditions { get; } + public ValueTask?> ApplyRouting(MutableRequest request, CancellationToken cancellationToken = default) + { + var args = new MutableRequestEventArgs(request); + + foreach (var handler in Handlers) + { + var matches = string.IsNullOrEmpty(handler.PathPrefix) || + request.Path.StartsWith(handler.PathPrefix, StringComparison.OrdinalIgnoreCase); + if (matches && !handler.Handler(args)) + return + Tasks.ValueResult?> + (CodeResult.Err((403, "Forbidden"))); + } + return Tasks.ValueResult?>(null); + } + + /// ToString includes all data in the layer, for full diagnostic transparency, and lists the preconditions and data count + public override string ToString() + { + return $"Routing Layer {Name}: {Handlers.Count} handlers, Preconditions: {FastPreconditions}"; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/PhysicalFileBlob.cs b/src/Imazen.Routing/Layers/PhysicalFileBlob.cs new file mode 100644 index 00000000..a5bed517 --- /dev/null +++ b/src/Imazen.Routing/Layers/PhysicalFileBlob.cs @@ -0,0 +1,17 @@ +using Imazen.Abstractions.Blobs; + +namespace Imazen.Routing.Layers +{ + internal static class PhysicalFileBlobHelper + { + + internal static IConsumableBlob CreateConsumableBlob(string path, DateTime lastModifiedDateUtc) + { + return new ConsumableStreamBlob(new BlobAttributes() + { + LastModifiedDateUtc = lastModifiedDateUtc, + }, new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan), null); + } + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/PresetsLayer.cs b/src/Imazen.Routing/Layers/PresetsLayer.cs new file mode 100644 index 00000000..ff7630e8 --- /dev/null +++ b/src/Imazen.Routing/Layers/PresetsLayer.cs @@ -0,0 +1,123 @@ +using System.Text; +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Helpers; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Layers; + +public enum PresetPriority +{ + DefaultValues = 0, + OverrideQuery = 1 +} +public class PresetOptions +{ + public string Name { get; } + + public PresetPriority Priority { get; } + + internal readonly Dictionary Pairs = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public PresetOptions(string name, PresetPriority priority, Dictionary commands) + { + Name = name; + Priority = priority; + foreach (var pair in commands) + { + Pairs[pair.Key] = pair.Value; + } + } + + public PresetOptions SetCommand(string key, string value) + { + Pairs[key] = value; + return this; + } + + public override string ToString() + { + var sb = new StringBuilder(); + var mode = Priority == PresetPriority.OverrideQuery ? "overrides" : "provides defaults"; + sb.Append($"Preset {Name} {mode} "); + foreach (var pair in Pairs) + { + sb.Append($"{pair.Key}={pair.Value}, "); + } + + return sb.ToString().TrimEnd(' ', ','); + } +} + +public record PresetsLayerOptions +{ + public bool UsePresetsExclusively { get; set; } + public Dictionary? Presets { get; set; } +} + +public class PresetsLayer(PresetsLayerOptions options) : IRoutingLayer +{ + public string Name => "Presets"; + public IFastCond? FastPreconditions { get; } = new Conditions.FastCondAny(new List + { + Conditions.HasQueryStringKey("preset") + }); + + public ValueTask?> ApplyRouting(MutableRequest request, CancellationToken cancellationToken = default) + { + var query = request.MutableQueryString; + if (options.UsePresetsExclusively) + { + var firstKey = query.FirstOrDefault().Key; + + if (query.Count > 1 || (firstKey != null && firstKey != "preset")) + { + return Tasks.ValueResult((CodeResult?) + CodeResult.Err((403, "Only presets are permitted in the querystring"))); + } + } + + + // Parse and apply presets before rewriting + if (query.TryGetValue("preset", out var presetNames)) + { + foreach (var presetName in presetNames) + { + if (presetName == null) continue; + if (string.IsNullOrWhiteSpace(presetName)) continue; + if (options.Presets?.TryGetValue(presetName, out var presetOptions) ?? false) + { + foreach (var pair in presetOptions.Pairs) + { + if (presetOptions.Priority == PresetPriority.OverrideQuery || + !query.ContainsKey(pair.Key)) + { + query[pair.Key] = pair.Value; + } + } + } + else + { + return new ValueTask?>(CodeResult.Err((400, + $"The image preset {presetName} was referenced from the querystring but is not registered."))); + } + } + } + return Tasks.ValueResult?>(null); + + } + + /// ToString includes all data in the layer, for full diagnostic transparency, and lists the preconditions and data count + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"Routing Layer {Name}: {options.Presets?.Count} presets, Preconditions: {FastPreconditions}"); + if (options.Presets == null) return sb.ToString(); + foreach (var pair in options.Presets) + { + sb.AppendLine($" {pair.Value}"); + } + return sb.ToString(); + } +} + diff --git a/src/Imazen.Routing/Layers/SignatureVerificationLayer.cs b/src/Imazen.Routing/Layers/SignatureVerificationLayer.cs new file mode 100644 index 00000000..a0ddf96f --- /dev/null +++ b/src/Imazen.Routing/Layers/SignatureVerificationLayer.cs @@ -0,0 +1,186 @@ +using System.Text; +using Imazen.Abstractions.HttpStrings; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Helpers; +using Imazen.Routing.Helpers; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Layers; + +public enum SignatureRequired +{ + ForAllRequests = 0, + ForQuerystringRequests = 1, + Never = 2 +} + +internal struct SignaturePrefix +{ + internal string Prefix { get; set; } + internal StringComparison PrefixComparison { get; set; } + internal SignatureRequired Requirement { get; set; } + internal List SigningKeys { get; set; } + + override public string ToString() + { + return $"{Prefix}{PrefixComparison.ToStringShort()} -> {Requirement} ({SigningKeys.Count} keys defined)"; + } +} + +public class RequestSignatureOptions +{ + internal SignatureRequired DefaultRequirement { get; } + + internal List DefaultSigningKeys { get; } + + internal List Prefixes { get; } = new List(); + + public bool IsEmpty => Prefixes.Count == 0 && DefaultSigningKeys.Count == 0 && DefaultRequirement == SignatureRequired.Never; + + internal RequestSignatureOptions(SignatureRequired defaultRequirement, IEnumerable defaultSigningKeys) + { + DefaultRequirement = defaultRequirement; + DefaultSigningKeys = defaultSigningKeys.ToList(); + } + + protected void AddPrefix(string prefix, StringComparison prefixComparison, SignatureRequired requirement, IEnumerable signingKeys) + { + Prefixes.Add(new SignaturePrefix() + { + Prefix = prefix, + PrefixComparison = prefixComparison, + Requirement = requirement, + SigningKeys = signingKeys.ToList() + }); + } + internal RequestSignatureOptions AddAllPrefixes(IEnumerable prefixes) + { + Prefixes.AddRange(prefixes); + return this; + } + internal Tuple> GetRequirementForPath(string path) + { + if (path != null) + { + foreach (var p in Prefixes) + { + if (path.StartsWith(p.Prefix, p.PrefixComparison)) + { + return new Tuple>(p.Requirement, p.SigningKeys); + } + } + } + return new Tuple>(DefaultRequirement, DefaultSigningKeys); + } + /// ToString includes all data in the layer, for full diagnostic transparency, and lists the preconditions and data count + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"Default Signing Requirement: {DefaultRequirement}, Defined Signing Keys: {DefaultSigningKeys.Count}"); + foreach (var prefix in Prefixes) + { + sb.AppendLine($" {prefix}"); + } + return sb.ToString(); + } + +} +public record struct SignatureVerificationLayerOptions(RequestSignatureOptions RequestSignatureOptions); + +public class SignatureVerificationLayer : IRoutingLayer +{ + private readonly SignatureVerificationLayerOptions options; + public SignatureVerificationLayer(SignatureVerificationLayerOptions options) + { + this.options = options; + if (options.RequestSignatureOptions.IsEmpty) + { + FastPreconditions = null; + } + else + { + // We probably need to filter by extension tho, and prefixes listed + FastPreconditions = Conditions.True; + } + } + + public string Name => "SignatureVerification"; + public IFastCond? FastPreconditions { get; } + + private bool VerifySignature(MutableRequest request, ref string authorizedMessage) + { + var (requirement, signingKeys) = options.RequestSignatureOptions + .GetRequirementForPath(request.Path); + + if (request.MutableQueryString.TryGetValue("signature", out var actualSignature)) + { + if (request.IsChildRequest) + { + // We can't allow or verify child requests. + authorizedMessage = "Child job requests (such as for watermarks) need not and cannot be signed."; + return false; + } + var queryString = UrlQueryString.Create(request.MutableQueryString).ToString(); + + var pathBase = request.OriginatingRequest?.GetPathBase() ?? UrlPathString.Empty; + + var pathAndQuery = pathBase.HasValue + ? "/" + pathBase.Value.TrimStart('/') + : ""; + pathAndQuery += request.Path + queryString; + + pathAndQuery = Signatures.NormalizePathAndQueryForSigning(pathAndQuery); + + var actualSignatureString = actualSignature.ToString(); + foreach (var key in signingKeys) + { + var expectedSignature = Signatures.SignString(pathAndQuery, key, 16); + // ordinal comparison + if (string.Equals(expectedSignature, actualSignatureString, StringComparison.Ordinal)) + { + return true; + } + } + + authorizedMessage = "Image signature does not match request, or used an invalid signing key."; + return false; + + } + + if (requirement == SignatureRequired.Never || request.IsChildRequest) + { + return true; + } + if (requirement == SignatureRequired.ForQuerystringRequests) + { + if (request.MutableQueryString.Count == 0) return true; + + authorizedMessage = "Image processing requests must be signed. No &signature query key found. "; + return false; + } + authorizedMessage = "Image requests must be signed. No &signature query key found. "; + return false; + + } + + public ValueTask?> ApplyRouting(MutableRequest request, + CancellationToken cancellationToken = default) + { + if (FastPreconditions == null || !FastPreconditions.Matches(request)) return Tasks.ValueResult?>(null); + + var authorizedMessage = ""; + if (VerifySignature(request, ref authorizedMessage)) + { + return Tasks.ValueResult?>(null); + } + + return Tasks.ValueResult?>( + CodeResult.Err((403, authorizedMessage))); + } + + /// ToString includes all data in the layer, for full diagnostic transparency, and lists the preconditions and data count + public override string ToString() + { + return $"Routing Layer {Name} Preconditions: {FastPreconditions}\n{options.RequestSignatureOptions}\n"; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/SimpleLayer.cs b/src/Imazen.Routing/Layers/SimpleLayer.cs new file mode 100644 index 00000000..062c9efe --- /dev/null +++ b/src/Imazen.Routing/Layers/SimpleLayer.cs @@ -0,0 +1,13 @@ +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Helpers; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Layers; + +internal record SimpleLayer(string Name, Func?> Function, IFastCond? FastPreconditions) : IRoutingLayer +{ + public ValueTask?> ApplyRouting(MutableRequest request, CancellationToken cancellationToken = default) + { + return Tasks.ValueResult(Function(request)); + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Layers/routing_design.md b/src/Imazen.Routing/Layers/routing_design.md new file mode 100644 index 00000000..c241aa22 --- /dev/null +++ b/src/Imazen.Routing/Layers/routing_design.md @@ -0,0 +1,61 @@ +# Routing Design + +For fallback, we want to maintain compatibility with the C# rewrite/auth event handlers, watermarking, and traditional +blob providers that specify their prefixes, can throw exceptions, and work based on just the virtual path. + + +Ideally we want a config that can be evaluated by a TypeScript, Rust, or C# library. Edge functions typically only support TypeScript/Rust, although we wouldn't be doing image processing at edge, we would want to handle caching and routing. Or, we allow the C# configuration to generate TypeScript/Javascript code that can be deployed to the edge. + + +Conditions and Actions + +Conditions can be nested, and have a parent condition. + +For example, a condition might be: + +* Filter by the host and/or port value +* Filter by the path extension +* Filter by the querystring version number or something +* Filter by keys/values in the ParsedData dictionary (this contains named groups captured as well as those set by actions directily) + + +Routes have unique identifiers. This allows them to be referenced by other routes and be used symmetrically for url generation. + +Routes have a parent route. This allows them to be nested. +Routes can rewrite the path, querystring, host, or headers. This can be used to set a base URL for a group of routes. +For multi-tenanting, this can be especially useful. + +Conditions can be grouped into a condition group. This allows them to be combined with AND or OR logic. +For example, a condition group might be: +* Extension is Imageflow Supported for reading +* OR there is no extension. + +Route patterns can be described with Regex (the fallback), or using a custom pattern language. + +Named groups are the core of route patterns. A named group will match all characters until a character matches the one that follows the named group. For example, the pattern: + +* `/s3/{container}/{key}` does not allow containers to contain '/', but any other character is allowed. + +Routes can specify 'where' clauses on the named groups. For example +* where {container} matches [a-z0-9] AND ({key} has supported image extenstion) or null. + +Route definitions can be described in C# data structures, in TOML, or in JSON/MessagePack/MemoryPack. + +Actions can adjust paths, inserting prefixes, removing prefixes, suffixes, overriding querystring values or setting them. +It can also scale numerical values with a from-range and to-range, with underflow and overflow-handling defined. + +A priority is fast startup (for action use), but also fast evaluation. MessagePack + +# To consider + +How to support remote reader signing, full url signing, and multi-tenanting support with different key sets. Add +support for seeds? And pub/private key support would allow untrusted edge functions to verify signatures. + +Minimizing utf32 overhead, perhaps keep utf8 version of string types? Or make the data type framework-conditional, with overloads so +strings can always be specified when using the C# interface? + +It can also execute static C# methods that are fully qualified. TODO: figure out how to manage this with AOT. + +Extending to support for POST/PUT and multipart uploads? + + diff --git a/src/Imazen.Routing/Matching/CharacterClass.cs b/src/Imazen.Routing/Matching/CharacterClass.cs new file mode 100644 index 00000000..bda860a0 --- /dev/null +++ b/src/Imazen.Routing/Matching/CharacterClass.cs @@ -0,0 +1,343 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using Imazen.Abstractions.Internal; + +namespace Imazen.Routing.Matching; + +/// +/// Parsed and interned representation of a character class like [0-9a-zA-Z] or [^/] or [^a-z] or [\\\t\n\r\[\]\{\)\,] +/// +public record CharacterClass( + bool IsNegated, + ReadOnlyCollection Ranges, + ReadOnlyCollection Characters) +{ + public override string ToString() + { + // 5 is our guess on escaped chars + var sb = new StringBuilder(3 + Ranges.Count * 3 + Characters.Count + 5); + sb.Append(IsNegated ? "[^" : "["); + foreach (var range in Ranges) + { + AppendEscaped(sb, range.Start); + sb.Append('-'); + AppendEscaped(sb, range.End); + } + foreach (var c in Characters) + { + AppendEscaped(sb, c); + } + sb.Append(']'); + return sb.ToString(); + } + private void AppendEscaped(StringBuilder sb, char c) + { + if (ValidCharsToEscape.Contains(c)) + { + sb.Append('\\'); + } + sb.Append(c); + } + + + public record struct CharRange(char Start, char End); + + + public bool Contains(char c) + { + return IsNegated ? !WithinSet(c) : WithinSet(c); + } + private bool WithinSet(char c) + { + if (Characters.Contains(c)) return true; + foreach (var range in Ranges) + { + if (c >= range.Start && c <= range.End) return true; + } + return false; + } + + + private static ulong HashSpan(ReadOnlySpan span) + { + var fnv = Fnv1AHash.Create(); + fnv.Add(span); + return fnv.CurrentHash; + } + private record ParseResult(bool Success, string Input, CharacterClass? Result, string? Error); + private static readonly Lazy> InternedParseResults = new Lazy>(); + private static readonly Lazy> InternedParseResultsByHash = new Lazy>(); + private static ParseResult? TryGetFromInterned(ReadOnlySpan syntax) + { + var hash = HashSpan(syntax); + if (InternedParseResultsByHash.Value.TryGetValue(hash, out var parseResult)) + { + if (!syntax.Is(parseResult.Input)) + { + // This is a hash collision, fall back to the slow path + if (InternedParseResults.Value.TryGetValue(syntax.ToString(), out parseResult)) + { + return parseResult; + } + return default; + } + return parseResult; + } + return default; + } + + + public static bool TryParseInterned(ReadOnlyMemory syntax, bool storeIfMissing, + [NotNullWhen(true)] out CharacterClass? result, + [NotNullWhen(false)] out string? error) + { + var span = syntax.Span; + var existing = TryGetFromInterned(span); + if (existing is not null) + { + result = existing.Result; + error = existing.Error; + return existing.Success; + } + var success = TryParse(syntax, out result, out error); + if (storeIfMissing) + { + var hash = HashSpan(span); + var str = syntax.ToString(); + InternedParseResultsByHash.Value.TryAdd(hash, new ParseResult(success, str, result, error)); + InternedParseResults.Value.TryAdd(str, new ParseResult(success, str, result, error)); + } + return success; + } + + + public static bool TryParse(ReadOnlyMemory syntax, + [NotNullWhen(true)] out CharacterClass? result, + [NotNullWhen(false)] out string? error) + { + var span = syntax.Span; + if (span.Length < 3) + { + error = "Character class must be at least 3 characters long"; + result = default; + return false; + } + if (span[0] != '[') + { + error = "Character class must start with ["; + result = default; + return false; + } + + if (span[^1] != ']') + { + error = "Character class must end with ]"; + result = default; + return false; + } + + var isNegated = span[1] == '^'; + if (isNegated) + { + if (span.Length < 4) + { + error = "Negated character class must be at least 4 characters long"; + result = default; + return false; + } + } + + var startFrom = isNegated ? 2 : 1; + return TryParseInner(isNegated, syntax[startFrom..^1], out result, out error); + } + + private enum LexTokenType : byte + { + ControlDash, + EscapedCharacter, + SingleCharacter, + PredefinedClass, + DanglingEscape, + IncorrectlyEscapedCharacter, + SuspiciousEscapedCharacter + } + + private readonly record struct LexToken(LexTokenType Type, char Value) + { + public bool IsValidCharacter => Type is LexTokenType.EscapedCharacter or LexTokenType.SingleCharacter; + } + + private static readonly char[] SuspiciousCharsToEscape = ['d', 'D', 's', 'S', 'w', 'W', 'b', 'B']; + + private static readonly char[] ValidCharsToEscape = + ['t', 'n', 'r', 'f', 'v', '0', '[', ']', '\\', '-', '^', ',', '(', ')', '{', '}', '|']; + + private static readonly LexToken ControlDashToken = new LexToken(LexTokenType.ControlDash, '-'); + + private static IEnumerable LexInner(ReadOnlyMemory syntax) + { + var i = 0; + while (i < syntax.Length) + { + var c = syntax.Span[i]; + if (c == '\\') + { + if (i == syntax.Length - 1) + { + yield return new LexToken(LexTokenType.DanglingEscape, '\\'); + } + var c2 = syntax.Span[i + 1]; + + if (c2 == 'w') + { + yield return new LexToken(LexTokenType.PredefinedClass, c2); + i += 2; + continue; + } + + if (ValidCharsToEscape.Contains(c2)) + { + yield return new LexToken(LexTokenType.EscapedCharacter, c2); + i += 2; + continue; + } + + if (SuspiciousCharsToEscape.Contains(c2)) + { + yield return new LexToken(LexTokenType.SuspiciousEscapedCharacter, c2); + i += 2; + continue; + } + + yield return new LexToken(LexTokenType.IncorrectlyEscapedCharacter, c2); + } + + if (c == '-') + { + yield return ControlDashToken; + i++; + continue; + } + + yield return new LexToken(LexTokenType.SingleCharacter, c); + i++; + } + } + + /// + /// Here we parse the inside, like "0-9a-z\t\\r\n\[\]\{\}\,A-Z" + /// First we break it into units - escaped characters, single characters, predefined classes, and the control character '-' + /// + /// + /// + /// + /// + /// + private static bool TryParseInner(bool negated, ReadOnlyMemory syntax, + [NotNullWhen(true)] out CharacterClass? result, + [NotNullWhen(false)] out string? error) + { + + List? ranges = null; + List? characters = null; + + var tokens = LexInner(syntax).ToList(); + // Reject if we have dangling escape, incorrectly escaped character, or suspicious escaped character + if (tokens.Any(t => t.Type is LexTokenType.DanglingEscape)) + { + error = "Dangling backslash in character class"; + result = default; + return false; + } + + if (tokens.Any(t => t.Type is LexTokenType.IncorrectlyEscapedCharacter)) + { + error = + $"Incorrectly escaped character '{tokens.First(t => t.Type is LexTokenType.IncorrectlyEscapedCharacter).Value}' in character class"; + result = default; + return false; + } + + if (tokens.Any(t => t.Type is LexTokenType.SuspiciousEscapedCharacter)) + { + var t = tokens.First(t => t.Type is LexTokenType.SuspiciousEscapedCharacter); + // This feature isn't supported + error = $"You probably meant to use a predefined character range with \'{t.Value}'; it is not supported."; + result = default; + return false; + } + + // Search for ranges + int indexOfDash = tokens.IndexOf(ControlDashToken); + while (indexOfDash != -1) + { + // if it's the first, the last, or bounded by a character class, then it's an error + if (indexOfDash == 0 || indexOfDash == tokens.Count - 1 || !tokens[indexOfDash - 1].IsValidCharacter || + !tokens[indexOfDash + 1].IsValidCharacter) + { + //TODO: improve error message + error = "Dashes can only be used between single characters in a character class"; + result = default; + return false; + } + + // Extract the range + var start = tokens[indexOfDash - 1].Value; + var end = tokens[indexOfDash + 1].Value; + if (start > end) + { + error = + $"Character class range must go from lower to higher, here [{syntax.ToString()}] it goes from {(int)start} to {(int)end}"; + result = default; + return false; + } + + ranges ??= new List(); + ranges.Add(new CharRange(start, end)); + // Mutate the collection and re-search + tokens.RemoveRange(indexOfDash - 1, 3); + indexOfDash = tokens.IndexOf(ControlDashToken); + } + + // The rest are single characters or predefined classes + foreach (var token in tokens) + { + if (token.Type is LexTokenType.SingleCharacter or LexTokenType.EscapedCharacter) + { + characters ??= []; + characters.Add(token.Value); + } + else if (token.Type is LexTokenType.PredefinedClass) + { + if (token.Value == 'w') + { + ranges ??= []; + ranges.AddRange(new[] + { + new CharRange('a', 'z'), new CharRange('A', 'Z'), + new CharRange('0', '9') + }); + characters ??= []; + characters.Add('_'); + } + else + { + throw new InvalidOperationException($"Unsupported predefined character class {token.Value}"); + } + } + else + { + throw new InvalidOperationException($"Unexpected token type {token.Type}"); + } + } + + characters ??= []; + ranges ??= []; + result = new CharacterClass(negated, new ReadOnlyCollection(ranges), + new ReadOnlyCollection(characters)); + error = null; + return true; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Matching/Conditions.cs b/src/Imazen.Routing/Matching/Conditions.cs new file mode 100644 index 00000000..4080ba92 --- /dev/null +++ b/src/Imazen.Routing/Matching/Conditions.cs @@ -0,0 +1,725 @@ +using System.Runtime.CompilerServices; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Layers; + +public interface IFastCond +{ + bool Matches(string path, IReadOnlyQueryWrapper query); +} +public interface IFastCondOr : IFastCond +{ + IReadOnlyCollection AlternateConditions { get; } +} + +internal interface IFastCondCanMerge : IFastCond +{ + IFastCond? TryMerge(IFastCond other); +} + +public interface IStringAndComparison +{ + string StringToCompare { get; } + StringComparison StringComparison { get; } +} + +public static class Conditions +{ + public static IFastCond Optimize(this IFastCond condition) + { + if (FastCondOptimizer.TryOptimize(condition, out var optimized)) + { + return optimized; + } + return condition; + } + public static bool Matches(this IFastCond condition, IHttpRequestStreamAdapter request) + { + var path = request.GetPath().Value; + if (path == null) return false; + return condition.Matches(path, request.GetQuery()); + } + public static bool Matches(this IFastCond condition, MutableRequest request) + { + return condition.Matches(request.MutablePath, request.ReadOnlyQueryWrapper); + } + // Exact match on path + public static FastCondPathEquals PathEqualsOrdinalIgnoreCase(string path) + => new FastCondPathEquals(path, StringComparison.OrdinalIgnoreCase); + + public static FastCondPathEquals PathEquals(string path) + => new FastCondPathEquals(path, StringComparison.Ordinal); + + // All paths are potentially handled + public static readonly FastCondTrue True = new FastCondTrue(); + public static readonly FastCondFalse False = new FastCondFalse(); + + // Contains one of these querystring keys + public static FastCondHasQueryStringKey HasQueryStringKey(params string[] keys) + => new FastCondHasQueryStringKey(keys); + + public static readonly FastCondPathHasNoExtension PathHasNoExtension = new FastCondPathHasNoExtension(); + + public static readonly FastCondHasPathSuffixes HasSupportedImageExtension = new FastCondHasPathSuffixes(PathHelpers.ImagePathSuffixes, StringComparison.OrdinalIgnoreCase); + + public static IFastCond HasPathPrefix(IEnumerable prefixes) where T : IStringAndComparison + { + return prefixes.GroupBy(p => p.StringComparison) + .Select(g => (IFastCond)(new FastCondHasPathPrefixes(g.Select(p => p.StringToCompare).ToArray(), g.Key))) + .AnyPrecondition().Optimize(); + } + public static FastCondHasPathPrefixes HasPathPrefixOrdinalIgnoreCase(params string[] prefixes) + => new FastCondHasPathPrefixes(prefixes, StringComparison.OrdinalIgnoreCase); + + public static FastCondHasPathPrefixes HasPathPrefixInvariantIgnoreCase(params string[] prefixes) + => new FastCondHasPathPrefixes(prefixes, StringComparison.InvariantCultureIgnoreCase); + public static FastCondAny AnyPrecondition(this IEnumerable preconditions) where T:IFastCond + => new(preconditions.Select(c => (IFastCond)c).ToList()); + + public static FastCondAny AnyPrecondition(IReadOnlyCollection preconditions) => new (preconditions); + + + public static FastCondHasPathPrefixes HasPathPrefix(params string[] prefixes) + => new FastCondHasPathPrefixes(prefixes, StringComparison.Ordinal); + + public static FastCondHasPathPrefixes HasPathPrefixInvariant(params string[] prefixes) + => new FastCondHasPathPrefixes(prefixes, StringComparison.InvariantCulture); + + public static FastCondHasPathSuffixes HasPathSuffixOrdinalIgnoreCase(params string[] suffixes) + => new FastCondHasPathSuffixes(suffixes, StringComparison.OrdinalIgnoreCase); + + public static FastCondHasPathSuffixes HasPathSuffix(params string[] suffixes) + => new FastCondHasPathSuffixes(suffixes, StringComparison.Ordinal); + + + public static FastCondAnd And(this T a, TU b) where T : IFastCond where TU : IFastCond + => new FastCondAnd(a, b); + + public static FastCondOr Or(this T a, TU b) where T : IFastCond where TU : IFastCond + => new FastCondOr(a, b); + + // NOT + public static FastCondNot Not(this T a) where T : IFastCond + => new FastCondNot(a); + + private interface IFastCondNot : IFastCond + { + IFastCond NotThis { get; } + } + + public readonly record struct FastCondNot(T A) : IFastCondNot where T : IFastCond + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + => !A.Matches(path, query); + + public override string ToString() => $"not({A})"; + public IFastCond NotThis => A; + } + + public readonly record struct FastCondTrue : IFastCond + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + => true; + + public override string ToString() => "true()"; + } + public readonly record struct FastCondFalse : IFastCond + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + => false; + + public override string ToString() => "false()"; + } + private interface IFastCondAnd : IFastCond + { + IEnumerable RequiredConditions { get; } + } + + public readonly record struct FastCondAnd(T A, TU B) + : IFastCondAnd where T : IFastCond where TU : IFastCond + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + => A.Matches(path, query) && B.Matches(path, query); + + public override string ToString() => $"and({A}, {B})"; + + public IEnumerable RequiredConditions + { + get + { + yield return A; + yield return B; + } + } + + } + + public readonly record struct FastCondOr(T A, TU B) : IFastCondOr where T : IFastCond where TU : IFastCond + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + => A.Matches(path, query) || B.Matches(path, query); + + public IReadOnlyCollection AlternateConditions => new IFastCond[]{A, B}; + + public override string ToString() + { + return $"or({A}, {B})"; + } + } + + internal static class FastCondOptimizer + { + internal static string[] CombineUnique(string[] a, string[] b) + { + if (a == b) return a; + if (a.Length == 0) return b; + if (b.Length == 0) return a; + if (a.Length == b.Length) + { + if (a.SequenceEqual(b)) return a; + } + // We have tiny arrays, so iteration is better than allocation + var combined = new List(a.Length + b.Length); + foreach (var s in a) + { + if (!combined.Contains(s, StringComparer.Ordinal)) + { + combined.Add(s); + } + } + foreach (var s in b) + { + if (!combined.Contains(s, StringComparer.Ordinal)) + { + combined.Add(s); + } + } + return combined.ToArray(); + } + private static void FlattenRecursiveAndOptimize(List flattened, IEnumerable alternateConditions) where T:IFastCond + { + // We flatten the Ors, and optimize everything else individually. + // Our caller is responsible for optimizing the final list of OR conditions + foreach (var condition in alternateConditions) + { + // Covers FastCondAnyNaive, FastCondAnyOptimized, FastCondOr + if (condition is IFastCondOr orCondition) + { + FlattenRecursiveAndOptimize(flattened, orCondition.AlternateConditions); + } + else{ + if (TryOptimize(condition, out var replacement)) + { + if (replacement is IFastCondOr replacementOr) + { + FlattenRecursiveAndOptimize(flattened, replacementOr.AlternateConditions); + } + else + { + flattened.Add(replacement); + } + } + else + { + flattened.Add(condition); + } + } + } + } + + + internal static List FlattenRecursiveOptimizeMergeAndOptimize(IEnumerable alternateConditions) where T:IFastCond + { + var flattened = new List((alternateConditions as IReadOnlyCollection)?.Count ?? 2 + 2); + FlattenRecursiveAndOptimize(flattened, alternateConditions); + //if any are True, all others can be removed + if (flattened.Any(f => f is FastCondTrue)) + { + return [new FastCondTrue()]; + } + // false can be removed + flattened.RemoveAll(f => f is FastCondFalse); + + var combinedCount = flattened.Count; + // Now try to combine, each with every other, stealing from the flattened list + for (var ix = 0; ix < flattened.Count; ix++) + { + var a = flattened[ix]; + if (a == null) continue; + for (var jx = ix + 1; jx < flattened.Count; jx++) + { + var b = flattened[jx]; + if (b == null) continue; + if (a is not IFastCondCanMerge mergeableA || b is not IFastCondCanMerge mergeableB) continue; + + var merged = mergeableA.TryMerge(mergeableB); + if (merged == null) continue; + // If we merged it, try to optimize it again. + if (TryOptimize(merged, out var optimized)) + { + merged = optimized; + } + + // Null the inner loop target when we merge, so we don't have to check it again + flattened[ix] = merged; + flattened[jx] = null; + a = merged; + combinedCount--; + } + } + // Now remove nulls + var final = new List(combinedCount); + foreach (var condition in flattened) + { + if (condition != null) + { + final.Add(condition); + } + } + // Now order most likely first + TryOptimizeOrder(ref final, true); + return final; + } + + internal static int GetSortRankFor(IFastCond? c, bool moreLikelyIsHigher) + { + // Anything to do with path extensions (typically path suffixes) + // Then anything to do with path prefixes + // Then anything to do with querystring keys + // we recurse into any nested ORs, using (moreLikelyIsBetter = true) and ANDs (moreLikelyIsBetter = false) + // And NOTs invert the previous + + var rank = c switch + { + null => 0, + FastCondPathHasNoExtension => 100, + FastCondHasPathSuffixes => 90, + FastCondHasPathPrefixes => 80, + FastCondHasQueryStringKey => 70, + FastCondPathEquals => 5, + FastCondPathEqualsAny => 10, + IFastCondAnd a => GetSortRankFor(a.RequiredConditions.First(),false), + IFastCondOr o => GetSortRankFor(o.AlternateConditions.First(),true), + IFastCondNot n => GetSortRankFor(n.NotThis, !moreLikelyIsHigher), + _ => 0 + }; + + return moreLikelyIsHigher ? rank : -rank; + } + internal static void TryOptimizeOrder(ref List conditions, bool MostLikelyOnTop) + { + // We will only have OptimizedAnys, nots, and various matchers. + // Primarily we want the most exclusive matchers first, so we can short-circuit. + // Anything to do with path extensions at the top + // Then anything to do with path prefixes + // Then anything to do with querystring keys + // then anything to do with path suffixes. + // we recurse into any nested ORs + if (MostLikelyOnTop) + conditions.Sort((a, b) => + GetSortRankFor(a, false).CompareTo(GetSortRankFor(b, false))); + else + { + conditions.Sort((a, b) => + GetSortRankFor(a, true).CompareTo(GetSortRankFor(b, true))); + } + } + + internal static bool TryOptimize(IFastCond logicalOp, out IFastCond replacement) + { + if (logicalOp is IFastCondAnd andOp) + { + var newAnd = + andOp.RequiredConditions + .Select(c => c.Optimize()) + .Where(c => c is not FastCondTrue) + .ToList(); + + if (newAnd.Any(c => c is FastCondFalse)) + { + replacement = Conditions.False; + return true; + } + if (newAnd.Any(c => c is IFastCondAnd)) + { + var expanded = new List(newAnd.Count * 2); + foreach (var c in newAnd) + { + if (c is IFastCondAnd and) + { + expanded.AddRange(and.RequiredConditions); + } + else + { + expanded.Add(c); + } + } + newAnd = expanded; + } + if (newAnd.Count == 1) + { + replacement = newAnd[0]; + return true; + } + + TryOptimizeOrder(ref newAnd, false); + replacement = new FastCondAll(newAnd); + return true; + + } + else if (logicalOp is IFastCondOr orOp) + { + var optimizedOr = new FastCondAny( + FastCondOptimizer.FlattenRecursiveOptimizeMergeAndOptimize(orOp.AlternateConditions)); + replacement = optimizedOr.AlternateConditions.Count == 1 ? optimizedOr.AlternateConditions.First() + : optimizedOr; + return true; + } + else if (logicalOp is IFastCondNot notOp) + { + + if (notOp.NotThis is FastCondTrue) + { + replacement = new FastCondFalse(); + return true; + } + if (notOp.NotThis is FastCondFalse) + { + replacement = new FastCondTrue(); + return true; + } + if (TryOptimize(notOp.NotThis, out var optimized)) + { + replacement = new FastCondNot(optimized); + return true; + } + // we could invert and -> not(each(or()) or or to and(each(not())) if some + // sub-conditions are faster that way, but I don't see any at this point. + + } + replacement = logicalOp; + return false; + } + } + + + public readonly record struct FastCondAny(IReadOnlyCollection Conditions) : IFastCondOr, IFastCondCanMerge + { + public IReadOnlyCollection AlternateConditions => Conditions; + + public bool Matches(string path, IReadOnlyQueryWrapper query) + { + if (Conditions.Count == 0) return false; + foreach (var condition in Conditions) + { + if (condition.Matches(path, query)) + { + return true; + } + } + return false; + } + + public IFastCond? TryMerge(IFastCond other) + { + if (other is IFastCondOr otherAny) + { + return new FastCondAny(Conditions.Concat(otherAny.AlternateConditions).ToArray()); + } + return null; + } + + public override string ToString() + { + return $"any({string.Join(", ", Conditions)})"; + } + } + + // All + public readonly record struct FastCondAll(IReadOnlyCollection Conditions) : IFastCondAnd, IFastCondCanMerge + { + public bool Matches(string path, IReadOnlyQueryWrapper query) + { + if (Conditions.Count == 0) return true; + foreach (var condition in Conditions) + { + if (!condition.Matches(path, query)) + { + return false; + } + } + return true; + } + + public IFastCond? TryMerge(IFastCond other) + { + if (other is IFastCondAnd otherAll) + { + return new FastCondAll(Conditions.Concat(otherAll.RequiredConditions).ToArray()); + } + return null; + } + + public override string ToString() + { + return $"all({string.Join(", ", Conditions)})"; + } + + public IEnumerable RequiredConditions => Conditions; + } + + public readonly record struct FastCondHasPathPrefixes(string[] Prefixes, StringComparison StringComparison) : IFastCondCanMerge + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + { + if (Prefixes.Length == 0) throw new InvalidOperationException("FastCondHasPathPrefixes must have at least one prefix"); + foreach (var prefix in Prefixes) + { + if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + public IFastCond? TryMerge(IFastCond other) + { + if (other is FastCondHasPathPrefixes otherPrefix && otherPrefix.StringComparison == StringComparison) + { + return new FastCondHasPathPrefixes(FastCondOptimizer.CombineUnique(Prefixes, otherPrefix.Prefixes), StringComparison); + } + return null; + } + + // tostring (path starts with one of { prefix1, prefix2, prefix3 } using StringComparison) + + public override string ToString() + { + if (StringComparison == StringComparison.OrdinalIgnoreCase) + { + return $"path.starts_with_i('{string.Join("', '", Prefixes)}')"; + } + if (StringComparison == StringComparison.Ordinal) + { + return $"path.starts_with('{string.Join("', '", Prefixes)}')"; + } + return $"path.starts_with({StringComparison}, '{string.Join("', '", Prefixes)}')"; + } + } + + // Has processable image extension + // public readonly record struct FastCondHasSupportedImageExtension : IFastCond + // { + // [MethodImpl(MethodImplOptions.AggressiveInlining)] + // public bool Matches(string path, IReadOnlyQueryWrapper query) + // { + // return PathHelpers.IsImagePath(path); + // } + // + // // tostring (has a supported image extension) + // + // public override string ToString() + // { + // return "path.extension.supported_image_type()"; + // } + // + // } + + // has one of the specified extensions (they must include the .) + + /// + /// + /// + /// The allowed suffixes (usually extensions, including the leading '.') + public readonly record struct FastCondHasPathSuffixes(string[] AllowedSuffixes, StringComparison StringComparison) : IFastCondCanMerge + { + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + { + if (AllowedSuffixes.Length == 0) throw new InvalidOperationException("FastCondHasPathSuffixes must have at least one suffix"); + foreach (var suffix in AllowedSuffixes) + { + if (path.EndsWith(suffix, StringComparison)) + { + return true; + } + } + return false; + } + + public IFastCond? TryMerge(IFastCond other) + { + if (other is FastCondHasPathSuffixes otherSuffixes && otherSuffixes.StringComparison == StringComparison) + { + return new FastCondHasPathSuffixes(FastCondOptimizer.CombineUnique(AllowedSuffixes, otherSuffixes.AllowedSuffixes), StringComparison); + } + return null; + } + + // tostring (path has one of { suffix1, suffix2, suffix3 } using StringComparison) + + public override string ToString() + { + if (StringComparison == StringComparison.OrdinalIgnoreCase) + { + return $"path.ends_with_i('{string.Join("', '", AllowedSuffixes)}')"; + } + if (StringComparison == StringComparison.Ordinal) + { + return $"path.ends_with('{string.Join("', '", AllowedSuffixes)}')"; + } + return $"path.ends_with({StringComparison}, '{string.Join("', '", AllowedSuffixes)}')"; + } + } + + + // Extensionless + public readonly record struct FastCondPathHasNoExtension : IFastCond + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + { + return path.LastIndexOf('.').Equals(-1); + } + + // tostring (path has no extension) + + public override string ToString() + { + return "path.extension.is_empty()"; + } + } + + // exact match on path + public readonly record struct FastCondPathEquals(string Path, StringComparison StringComparison) : IFastCondCanMerge + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + { + return Path.Equals(path, StringComparison); + } + + public IFastCond? TryMerge(IFastCond other) + { + if (other is FastCondPathEquals otherPath && otherPath.StringComparison == StringComparison) + { + return new FastCondPathEqualsAny(FastCondOptimizer.CombineUnique(new string[]{Path}, new string[]{otherPath.Path}), StringComparison); + } + // merge with FastCondPathEqualsAny + if (other is FastCondPathEqualsAny otherPaths && otherPaths.StringComparison == StringComparison) + { + return new FastCondPathEqualsAny(FastCondOptimizer.CombineUnique(new string[]{Path}, otherPaths.Paths), StringComparison); + } + return null; + } + + // tostring (path is { path } using StringComparison) + + public override string ToString() + { + if (StringComparison == StringComparison.OrdinalIgnoreCase) + { + return $"path.equals_i('{Path}')"; + } + if (StringComparison == StringComparison.Ordinal) + { + return $"path.equals('{Path}')"; + } + return $"path.equals({StringComparison}, '{Path}')"; + } + } + + public readonly record struct FastCondPathEqualsAny(string[] Paths, StringComparison StringComparison) : IFastCondCanMerge + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Matches(string path, IReadOnlyQueryWrapper query) + { + if (Paths.Length == 0) throw new InvalidOperationException("FastCondPathEqualsAny must have at least one path"); + foreach (var p in Paths) + { + if (p.Equals(path, StringComparison)) + { + return true; + } + } + return false; + } + + public IFastCond? TryMerge(IFastCond other) + { + if (other is FastCondPathEqualsAny otherPaths && otherPaths.StringComparison == StringComparison) + { + return new FastCondPathEqualsAny(FastCondOptimizer.CombineUnique(Paths, otherPaths.Paths), StringComparison); + } + // merge with FastCondPathEquals + if (other is FastCondPathEquals otherPath && otherPath.StringComparison == StringComparison) + { + return new FastCondPathEqualsAny(FastCondOptimizer.CombineUnique(Paths, new string[]{otherPath.Path}), StringComparison); + } + return null; + } + + // tostring (path is one of { path1, path2, path3 } using StringComparison) + + public override string ToString() + { + if (StringComparison == StringComparison.OrdinalIgnoreCase) + { + return $"path.equals_i('{string.Join("', '", Paths)}')"; + } + if (StringComparison == StringComparison.Ordinal) + { + return $"path.equals('{string.Join("', '", Paths)}')"; + } + return $"path.equals({StringComparison}, '{string.Join("', '", Paths)}')"; + + } + } + + + // Has querystring key (case sensitive) + public readonly record struct FastCondHasQueryStringKey(string[] Keys) : IFastCondCanMerge + { + public bool Matches(string path, IReadOnlyQueryWrapper query) + { + if (Keys.Length == 0) throw new InvalidOperationException("FastCondHasQueryStringKey must have at least one key"); + foreach (var key in Keys) + { + if (query.ContainsKey(key)) + { + return true; + } + } + return false; + } + + public IFastCond? TryMerge(IFastCond other) + { + if (other is FastCondHasQueryStringKey otherKeys) + { + return new FastCondHasQueryStringKey(FastCondOptimizer.CombineUnique(Keys, otherKeys.Keys)); + } + return null; + } + + // tostring (has a querystring key from { key1, key2, key3 }) + public override string ToString() + { + return $"query.has_key('{string.Join("', '", Keys)}')"; + } + + } + + +} + diff --git a/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs b/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs new file mode 100644 index 00000000..9733d40f --- /dev/null +++ b/src/Imazen.Routing/Matching/ExpressionParsingHelpers.cs @@ -0,0 +1,274 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Imazen.Routing.Matching; + +internal static partial class ExpressionParsingHelpers +{ + public static bool Is(this ReadOnlySpan text, string value) + { + if (text.Length != value.Length) return false; + for (int i = 0; i < text.Length; i++) + { + if (text[i] != value[i]) return false; + } + return true; + } + + [Flags] + internal enum GlobChars + { + None = 0, + Star = 2, + DoubleStar = 4, + StarOptional = Star | Optional, + DoubleStarOptional = DoubleStar | Optional, + Optional = 1, + } + internal static GlobChars GetGlobChars(ReadOnlySpan text) + { + if (text.Length == 1) + { + return text[0] switch + { + '*' => GlobChars.Star, + '?' => GlobChars.Optional, + _ => GlobChars.None + }; + } + if (text.Length == 2) + { + return text[0] switch + { + '*' => text[1] switch + { + '*' => GlobChars.DoubleStar, + '?' => GlobChars.StarOptional, + _ => GlobChars.None + }, + _ => GlobChars.None + }; + } + if (text.Length == 3 && text[0] == '*' && text[1] == '*' && text[2] == '?') + { + return GlobChars.DoubleStarOptional; + } + return GlobChars.None; + } + + /// + /// Find the first character that is not escaped by escapeChar. + /// A character is considered escaped if it is preceded by an odd number of consecutive escapeChars. + /// + /// + /// + /// + /// + internal static int FindCharNotEscaped(ReadOnlySpan str,char c, char escapeChar) + { + var consecutiveEscapeChars = 0; + for (var i = 0; i < str.Length; i++) + { + if (str[i] == escapeChar) + { + consecutiveEscapeChars++; + continue; + } + if (str[i] == c && consecutiveEscapeChars % 2 == 0) + { + return i; + } + consecutiveEscapeChars = 0; + } + return -1; + } + + +#if NET8_0_OR_GREATER + [GeneratedRegex(@"^[a-zA-Z_][a-zA-Z0-9_]*$", + RegexOptions.CultureInvariant | RegexOptions.Singleline)] + private static partial Regex ValidSegmentName(); +#else + + private static readonly Regex ValidSegmentNameVar = + new(@"^[a-zA-Z_][a-zA-Z0-9_]*$", + RegexOptions.CultureInvariant | RegexOptions.Singleline, TimeSpan.FromMilliseconds(50)); + private static Regex ValidSegmentName() => ValidSegmentNameVar; +#endif + + /// + /// We allow [a-zA-Z_][a-zA-Z0-9_]* for segment names + /// + /// + /// + /// + /// + internal static bool ValidateSegmentName(string name, ReadOnlySpan segmentExpression, [NotNullWhen(false)]out string? error) + { + if (name.Length == 0) + { + error = "Don't use empty segment names, only null or valid"; + return false; + } + if (name.Contains('*') || name.Contains('?')) + { + error = + $"Invalid segment expression {{{segmentExpression.ToString()}}} Conditions and modifiers such as * and ? belong after the colon. Ex: {{name:*:?}} "; + return false; + } + if (!ValidSegmentName().IsMatch(name)) + { + error = $"Invalid name '{name}' in segment expression {{{segmentExpression.ToString()}}}. Names must start with a letter or underscore, and contain only letters, numbers, or underscores"; + return false; + } + error = null; + return true; + } + + internal static bool TryReadConditionName(ReadOnlySpan text, + [NotNullWhen(true)] out int? conditionNameEnds, + [NotNullWhen(false)] out string? error) + { + // A "" condition name is valid (aliased to equals) + conditionNameEnds = text.IndexOf('('); + if (conditionNameEnds == -1) + { + conditionNameEnds = text.Length; + } + // check for invalid characters + for (var i = 0; i < conditionNameEnds; i++) + { + var c = text[i]; + if (c != '_' && c != '-' && c is < 'a' or > 'z') + { + error = $"Invalid character '{text[i]}' (a-z-_ only) in condition name. Condition expression: '{text.ToString()}'"; + return false; + } + } + error = null; + return true; + } + + // parse a condition call (function call style) + // condition_name(arg1, arg2, arg3) + // where condition_name is [a-z_] + // where arguments can be + // character classes: [a-z_] + // 'string' or "string" or + // numbers 123 or 123.456 + // a | delimited string array a|b|c + // a unquoted string + // we don't support nested function calls + // args are comma delimited + // The following characters must be escaped: , | ( ) ' " \ [ ] + + public static bool TryParseCondition(ReadOnlyMemory text, + [NotNullWhen(true)] + out ReadOnlyMemory? functionName, out List>? args, + [NotNullWhen(false)] out string? error) + { + var textSpan = text.Span; + if (!TryReadConditionName(textSpan, out var functionNameEndsMaybe, out error)) + { + functionName = null; + args = null; + return false; + } + int functionNameEnds = functionNameEndsMaybe.Value; + + functionName = text[..functionNameEnds]; + + if (functionNameEnds == text.Length) + { + args = null; + error = null; + return true; + } + + if (textSpan[functionNameEnds] != '(') + { + throw new InvalidOperationException("Unreachable code"); + } + + if (textSpan[^1] != ')') + { + error = $"Expected ')' at end of condition expression '{text.ToString()}'"; + functionName = null; + args = null; + return false; + } + + // now parse using unescaped commas + var argListMemory = text[(functionNameEnds + 1)..^1]; + var argListSpan = argListMemory.Span; + if (argListSpan.Length == 0) + { + error = null; + args = null; + return true; + } + // We have at least one character + args = new List>(); + // Split using FindCharNotEscaped(text, ',', '\\') + var start = 0; + while (start < argListSpan.Length) + { + var subSpan = argListSpan[start..]; + var commaIndex = FindCharNotEscaped(subSpan, ',', '\\'); + if (commaIndex == -1) + { + args.Add(argListMemory[start..]); + break; + } + args.Add(argListMemory[start..(start + commaIndex)]); + start += commaIndex + 1; + } + error = null; + return true; + } + + [Flags] + public enum ArgType + { + Empty = 0, + CharClass = 1, + Char = 2, + String = 4, + Array = 8, + UnsignedNumeric = 16, + DecimalNumeric = 32, + IntegerNumeric = 64, + UnsignedDecimal = UnsignedNumeric | DecimalNumeric, + UnsignedInteger = UnsignedNumeric | IntegerNumeric, + } + + public static ArgType GetArgType(ReadOnlySpan arg) + { + if (arg.Length == 0) return ArgType.Empty; + if (arg[0] == '[' && arg[^1] == ']') return ArgType.CharClass; + if (FindCharNotEscaped(arg, '|', '\\') != -1) return ArgType.Array; + var type = ArgType.IntegerNumeric | ArgType.DecimalNumeric | ArgType.UnsignedNumeric; + foreach (var c in arg) + { + if (c is >= '0' and <= '9') continue; + if (c == '.') + { + type &= ~ArgType.IntegerNumeric; + } + if (c == '-') + { + type &= ~ArgType.UnsignedNumeric; + } + type &= ~ArgType.DecimalNumeric; + } + if (arg.Length == 1) type |= ArgType.Char; + type |= ArgType.String; + return type; + } + + + public static bool IsCommonCaseInsensitiveChar(char c) + { + return c is (>= ' ' and < 'A') or (> 'Z' and < 'a') or (> 'z' and <= '~') or '\t' or '\r' or '\n'; + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Matching/MatchExpression.cs b/src/Imazen.Routing/Matching/MatchExpression.cs new file mode 100644 index 00000000..f2361d9f --- /dev/null +++ b/src/Imazen.Routing/Matching/MatchExpression.cs @@ -0,0 +1,980 @@ +using System.Collections; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Imazen.Routing.Matching; + + +public record MatchingContext +{ + public bool OrdinalIgnoreCase { get; init; } + public required IReadOnlyCollection SupportedImageExtensions { get; init; } + + internal static MatchingContext DefaultCaseInsensitive => new() + { + OrdinalIgnoreCase = true, + SupportedImageExtensions = new []{"jpg", "jpeg", "png", "gif", "webp"} + }; + internal static MatchingContext DefaultCaseSensitive => new() + { + OrdinalIgnoreCase = false, + SupportedImageExtensions = new []{"jpg", "jpeg", "png", "gif", "webp"} + }; +} + +public partial record class MatchExpression +{ + private MatchExpression(MatchSegment[] segments) + { + Segments = segments; + } + + private MatchSegment[] Segments; + + public int SegmentCount => Segments.Length; + + private static bool TryCreate(IReadOnlyCollection segments, [NotNullWhen(true)] out MatchExpression? result, [NotNullWhen(false)]out string? error) + { + if (segments.Count == 0) + { + result = null; + error = "Zero segments found in expression"; + return false; + } + + result = new MatchExpression(segments.ToArray()); + error = null; + return true; + } + + +#if NET8_0_OR_GREATER + [GeneratedRegex(@"^(([^{]+)|((? SplitSectionsVar; + #endif + public static MatchExpression Parse(MatchingContext context, string expression) + { + if (!TryParse(context, expression, out var result, out var error)) + { + throw new ArgumentException(error, nameof(expression)); + } + return result!; + } + + private static IEnumerable>SplitExpressionSections(ReadOnlyMemory input) + { + int lastOpen = -1; + int consumed = 0; + while (true) + { + if (lastOpen == -1) + { + lastOpen = ExpressionParsingHelpers.FindCharNotEscaped(input.Span[consumed..], '{', '\\'); + if (lastOpen != -1) + { + lastOpen += consumed; + // Return the literal before the open { + if (lastOpen > consumed) + { + yield return input[consumed..lastOpen]; + } + consumed = lastOpen + 1; + } + else + { + // The rest of the string is a literal + if (consumed < input.Length) + { + yield return input[consumed..]; + } + yield break; + } + } + else + { + // We have an open { pending + var close = ExpressionParsingHelpers.FindCharNotEscaped(input.Span[consumed..], '}', '\\'); + if (close != -1) + { + close += consumed; + // return the {segment} + yield return input[lastOpen..(close + 1)]; + consumed = close + 1; + lastOpen = -1; + } + else + { + // The rest of the string is a literal - a dangling one! + if (consumed < input.Length) + { + yield return input.Slice(consumed); + } + yield break; + } + } + } + } + + + public static bool TryParse(MatchingContext context, string expression, + [NotNullWhen(true)] out MatchExpression? result, + [NotNullWhen(false)]out string? error) + { + if (string.IsNullOrWhiteSpace(expression)) + { + error = "Match expression cannot be empty"; + result = null; + return false; + } + // enumerate the segments in expression using SplitSections. + // The entire regex should match. + // If it doesn't, return false and set error to the first unmatched character. + // If it does, create a MatchSegment for each match, and add it to the result. + // Work right-to-left + + var matches = SplitExpressionSections(expression.AsMemory()).ToArray(); + var segments = new Stack(); + for (int i = matches.Length - 1; i >= 0; i--) + { + var segment = matches[i]; + if (segment.Length == 0) throw new InvalidOperationException($"SplitSections returned an empty segment. {matches}"); + if (!MatchSegment.TryParseSegmentExpression(context, segment, segments, out var parsedSegment, out error)) + { + result = null; + return false; + } + segments.Push(parsedSegment.Value); + } + + return TryCreate(segments, out result, out error); + } + public readonly record struct MatchExpressionCapture(string Name, ReadOnlyMemory Value); + public readonly record struct MatchExpressionSuccess(IReadOnlyList? Captures); + + public bool IsMatch(in MatchingContext context, in ReadOnlyMemory input) + { + return TryMatch(context, input, out _, out _, out _); + } + public bool IsMatch(in MatchingContext context, string input) + { + return TryMatch(context, input.AsMemory(), out _, out _, out _); + } + + public bool TryMatchVerbose(in MatchingContext context, in ReadOnlyMemory input, + [NotNullWhen(true)] out MatchExpressionSuccess? result, + [NotNullWhen(false)] out string? error) + { + if (!TryMatch(context, input, out result, out error, out var ix)) + { + MatchSegment? segment = ix >= 0 && ix < Segments.Length ? Segments[ix.Value] : null; + error = $"{error}. Failing segment[{ix}]: {segment}"; + return false; + } + return true; + } + + public bool TryMatch(in MatchingContext context, in ReadOnlyMemory input, [NotNullWhen(true)] out MatchExpressionSuccess? result, + [NotNullWhen(false)] out string? error, [NotNullWhen(false)] out int? failingSegmentIndex) + { + // We scan with SegmentBoundary to establish + // A) the start and end of each segment's var capture + // B) the start and end of each segment's capture + // C) if the segment (when optional) is present + + // Consecutive optional segments or a glob followed by an optional segment are not allowed. + // At least not yet. + + // Once we have the segment boundaries, we can use the segment's conditions to validate the capture. + var inputSpan = input.Span; + List? captures = null; + var charactersConsumed = 0; + var remainingInput = inputSpan; + var openSegmentIndex = -1; + var openSegmentAbsoluteStart = -1; + var openSegmentAbsoluteEnd = -1; + var currentSegment = 0; + while (true) + { + var boundaryStarts = -1; + var boundaryFinishes = -1; + var foundBoundaryOrEnd = false; + var closingBoundary = false; + // No more segments to try? + if (currentSegment >= Segments.Length) + { + if (openSegmentIndex != -1) + { + // We still have an open segment, so we close it and capture it. + boundaryStarts = boundaryFinishes = inputSpan.Length; + foundBoundaryOrEnd = true; + closingBoundary = true; + }else if (remainingInput.Length == 0) + { + // We ran out of segments AND input. Success! + result = new MatchExpressionSuccess(captures); + error = null; + failingSegmentIndex = null; + return true; + } + else + { + result = null; + error = "The input was not fully consumed by the match expression"; + failingSegmentIndex = Segments.Length - 1; + return false; + } + } + else + { + // If there's an open segment and it's the same as the currentSegment, use the EndsOn + var searchingStart = openSegmentIndex != currentSegment; + closingBoundary = !searchingStart; + var searchSegment = + searchingStart ? Segments[currentSegment].StartsOn : Segments[currentSegment].EndsOn; + var startingFresh = (openSegmentIndex == -1); + if (!searchingStart && openSegmentIndex == currentSegment) + { + // Check for null-op end conditions + if (searchSegment.AsEndSegmentReliesOnStartSegment) + { + // The start segment must have been equals or a literal + boundaryStarts = boundaryFinishes = charactersConsumed; + foundBoundaryOrEnd = true; + } else if (searchSegment.AsEndSegmentReliesOnSubsequentSegmentBoundary) + { + // Move on to the next segment (or past the last segment, which triggers a match) + currentSegment++; + continue; + } + } + if (!foundBoundaryOrEnd && !startingFresh && !searchSegment.SupportsScanning) + { + error = $"The segment cannot cannot be scanned for"; + failingSegmentIndex = currentSegment; + result = null; + return false; + } + if (!foundBoundaryOrEnd && startingFresh && !searchSegment.SupportsMatching) + { + error = $"The segment cannot be matched for"; + failingSegmentIndex = currentSegment; + result = null; + return false; + } + + // Relying on these to throw exceptions if the constructed expression can + // not be matched deterministically. + var s = -1; + var f = -1; + if (!foundBoundaryOrEnd) + { + var searchResult = (startingFresh + ? searchSegment.TryMatch(remainingInput, out s, out f) + : searchSegment.TryScan(remainingInput, out s, out f)); + boundaryStarts = s == -1 ? -1 : charactersConsumed + s; + boundaryFinishes = f == -1 ? -1 : charactersConsumed + f; + foundBoundaryOrEnd = searchResult; + } + if (!foundBoundaryOrEnd) + { + + if (Segments[currentSegment].IsOptional) + { + // We didn't find the segment, but it's optional, so we can skip it. + currentSegment++; + continue; + } + // It's mandatory, and we didn't find it. + result = null; + error = searchingStart ? "The start of the segment could not be found in the input" + : "The end of the segment could not be found in the input"; + failingSegmentIndex = currentSegment; + return false; + } + } + + if (foundBoundaryOrEnd) + { + Debug.Assert(boundaryStarts != -1 && boundaryFinishes != -1); + // We can get here under 3 conditions: + // 1. We found the start of a segment and a previous segment is open + // 2. We found the end of a segment and the current segment is open. + // 3. We matched the start of a segment, no previous segment was open. + + // So first, we close and capture any open segment. + // This happens if we found the start of a segment and a previous segment is open. + // Or if we found the end of our current segment. + if (openSegmentIndex != -1) + { + var openSegment = Segments[openSegmentIndex]; + var variableStart = openSegment.StartsOn.IncludesMatchingTextInVariable + ? openSegmentAbsoluteStart + : openSegmentAbsoluteEnd; + var variableEnd = boundaryStarts; + var conditionsOk = openSegment.ConditionsMatch(context, inputSpan[variableStart..variableEnd]); + if (!conditionsOk) + { + // Even if the segment is optional, we refuse to match it if the conditions don't match. + // We could lift this restriction later + result = null; + error = $"The text did not meet the conditions of the segment"; + failingSegmentIndex = openSegmentIndex; + return false; + } + + if (openSegment.Name != null) + { + captures ??= new List(); + captures.Add(new(openSegment.Name, + input[variableStart..variableEnd])); + } + // We consume the characters (we had a formerly open segment). + charactersConsumed = boundaryFinishes; + remainingInput = inputSpan[charactersConsumed..]; + } + + if (!closingBoundary){ + openSegmentIndex = currentSegment; + openSegmentAbsoluteStart = boundaryStarts; + openSegmentAbsoluteEnd = boundaryFinishes; + // TODO: handle non-consuming char case? + charactersConsumed = boundaryFinishes; + remainingInput = inputSpan[charactersConsumed..]; + continue; // Move on to the next segment + } + else + { + openSegmentIndex = -1; + openSegmentAbsoluteStart = -1; + openSegmentAbsoluteEnd = -1; + currentSegment++; + } + + } + } + } +} + + + +internal readonly record struct + MatchSegment(string? Name, SegmentBoundary StartsOn, SegmentBoundary EndsOn, List? Conditions) +{ + override public string ToString() + { + if (StartsOn.MatchesEntireSegment && Name == null && EndsOn.AsEndSegmentReliesOnStartSegment) + { + return $"'{StartsOn}'"; + } + var conditionsString = Conditions == null ? "" : string.Join(":", Conditions); + if (conditionsString.Length > 0) + { + conditionsString = ":" + conditionsString; + } + return $"{Name ?? ""}:{StartsOn}:{EndsOn}{conditionsString}"; + } + public bool ConditionsMatch(MatchingContext context, ReadOnlySpan text) + { + if (Conditions == null) return true; + foreach (var condition in Conditions) + { + if (!condition.Evaluate(text, context)) + { + return false; + } + } + return true; + } + + public bool IsOptional => StartsOn.IsOptional; + + internal static bool TryParseSegmentExpression(MatchingContext context, + ReadOnlyMemory exprMemory, + Stack laterSegments, + [NotNullWhen(true)]out MatchSegment? segment, + [NotNullWhen(false)]out string? error) + { + var expr = exprMemory.Span; + if (expr.IsEmpty) + { + segment = null; + error = "Empty segment"; + return false; + } + error = null; + + if (expr[0] == '{') + { + if (expr[^1] != '}') + { + error = $"Unmatched '{{' in segment expression {{{expr.ToString()}}}"; + segment = null; + return false; + } + var innerMem = exprMemory[1..^1]; + if (innerMem.Length == 0) + { + error = "Segment {} cannot be empty. Try {*}, {name}, {name:condition1:condition2}"; + segment = null; + return false; + } + return TryParseLogicalSegment(context, innerMem, laterSegments, out segment, out error); + + } + // it's a literal + // Check for invalid characters like & + if (expr.IndexOfAny(new[] {'*', '?'}) != -1) + { + error = "Literals cannot contain * or ? operators, they must be enclosed in {} such as {name:?} or {name:*:?}"; + segment = null; + return false; + } + segment = CreateLiteral(expr, context); + return true; + } + + private static bool TryParseLogicalSegment(MatchingContext context, + in ReadOnlyMemory innerMemory, + Stack laterSegments, + [NotNullWhen(true)] out MatchSegment? segment, + [NotNullWhen(false)] out string? error) + { + + string? name = null; + SegmentBoundary? segmentStartLogic = null; + SegmentBoundary? segmentEndLogic = null; + segment = null; + + List? conditions = null; + var inner = innerMemory.Span; + // Enumerate segments delimited by : (ignoring \:, and breaking on \\:) + int startsAt = 0; + int segmentCount = 0; + while (true) + { + int colonIndex = ExpressionParsingHelpers.FindCharNotEscaped(inner[startsAt..], ':', '\\'); + var thisPartMemory = colonIndex == -1 ? innerMemory[startsAt..] : innerMemory[startsAt..(startsAt + colonIndex)]; + bool isCondition = true; + if (segmentCount == 0) + { + isCondition = ExpressionParsingHelpers.GetGlobChars(thisPartMemory.Span) != ExpressionParsingHelpers.GlobChars.None; + if (!isCondition && thisPartMemory.Length > 0) + { + name = thisPartMemory.ToString(); + if (!ExpressionParsingHelpers.ValidateSegmentName(name, inner, out error)) + { + return false; + } + } + } + + if (isCondition) + { + if (!TryParseConditionOrSegment(context, colonIndex == -1, thisPartMemory, inner, ref segmentStartLogic, ref segmentEndLogic, ref conditions, laterSegments, out error)) + { + return false; + } + } + segmentCount++; + if (colonIndex == -1) + { + break; // We're done + } + startsAt += colonIndex + 1; + } + + segmentStartLogic ??= SegmentBoundary.DefaultStart; + segmentEndLogic ??= SegmentBoundary.DefaultEnd; + + + segment = new MatchSegment(name, segmentStartLogic.Value, segmentEndLogic.Value, conditions); + + if (segmentEndLogic.Value.AsEndSegmentReliesOnSubsequentSegmentBoundary && laterSegments.Count > 0) + { + var next = laterSegments.Peek(); + // if (next.IsOptional) + // { + // error = $"The segment '{inner.ToString()}' cannot be matched deterministically since it precedes an optional segment. Add an until() condition or put a literal between them."; + // return false; + // } + if (!next.StartsOn.SupportsScanning) + { + error = $"The segment '{segment}' cannot be matched deterministically since it precedes non searchable segment '{next}'"; + return false; + } + } + + error = null; + return true; + } + + + + private static bool TryParseConditionOrSegment(MatchingContext context, + bool isFinalCondition, + in ReadOnlyMemory conditionMemory, + in ReadOnlySpan segmentText, + ref SegmentBoundary? segmentStartLogic, + ref SegmentBoundary? segmentEndLogic, + ref List? conditions, + Stack laterSegments, + [NotNullWhen(false)] out string? error) + { + error = null; + var conditionSpan = conditionMemory.Span; + var globChars = ExpressionParsingHelpers.GetGlobChars(conditionSpan); + var makeOptional = (globChars & ExpressionParsingHelpers.GlobChars.Optional) == + ExpressionParsingHelpers.GlobChars.Optional + || conditionSpan.Is("optional"); + if (makeOptional) + { + segmentStartLogic ??= SegmentBoundary.DefaultStart; + segmentStartLogic = segmentStartLogic.Value.SetOptional(true); + } + + // We ignore the glob chars, they don't constrain behavior any. + if (globChars != ExpressionParsingHelpers.GlobChars.None + || conditionSpan.Is("optional")) + { + return true; + } + + if (!ExpressionParsingHelpers.TryParseCondition(conditionMemory, out var functionNameMemory, out var args, + out error)) + { + return false; + } + + var functionName = functionNameMemory.ToString() ?? throw new InvalidOperationException("Unreachable code"); + var conditionConsumed = false; + if (args is { Count: 1 }) + { + var optional = segmentStartLogic?.IsOptional ?? false; + if (SegmentBoundary.TryCreate(functionName, context.OrdinalIgnoreCase, optional, args[0].Span, out var sb)) + { + if (segmentStartLogic is { MatchesEntireSegment: true }) + { + error = + $"The segment {segmentText.ToString()} already uses equals(), this cannot be combined with other conditions."; + return false; + } + if (sb.Value.IsEndingBoundary) + { + if (segmentEndLogic is { HasDefaultEndWhen: false }) + { + error = $"The segment {segmentText.ToString()} has conflicting end conditions; do not mix equals and ends-with and suffix conditions"; + return false; + } + segmentEndLogic = sb; + conditionConsumed = true; + } + else + { + if (segmentStartLogic is { HasDefaultStartWhen: false }) + { + error = $"The segment {segmentText.ToString()} has multiple start conditions; do not mix starts_with, after, and equals conditions"; + return false; + } + segmentStartLogic = sb; + conditionConsumed = true; + } + + } + } + if (!conditionConsumed) + { + conditions ??= new List(); + if (!TryParseCondition(context, conditions, functionName, args, out var condition, out error)) + { + //TODO: add more context to error + return false; + } + conditions.Add(condition.Value); + } + return true; + } + + private static bool TryParseCondition(MatchingContext context, + List conditions, string functionName, + List>? args, [NotNullWhen(true)]out StringCondition? condition, [NotNullWhen(false)] out string? error) + { + var c = StringCondition.TryParse(out var cError, functionName, args, context.OrdinalIgnoreCase); + if (c == null) + { + condition = null; + error = cError ?? throw new InvalidOperationException("Unreachable code"); + return false; + } + condition = c.Value; + error = null; + return true; + } + + + private static MatchSegment CreateLiteral(ReadOnlySpan literal, MatchingContext context) + { + return new MatchSegment(null, + SegmentBoundary.Literal(literal, context.OrdinalIgnoreCase), + SegmentBoundary.LiteralEnd, null); + } +} + + +internal readonly record struct SegmentBoundary +{ + private readonly SegmentBoundary.Flags Behavior; + private readonly SegmentBoundary.When On; + private readonly string? Chars; + private readonly char Char; + + + private SegmentBoundary( + SegmentBoundary.Flags behavior, + SegmentBoundary.When on, + string? chars, + char c + ) + { + this.Behavior = behavior; + this.On = on; + this.Chars = chars; + this.Char = c; + } + [Flags] + private enum SegmentBoundaryFunction + { + None = 0, + Equals = 1, + StartsWith = 2, + IgnoreCase = 16, + IncludeInVar = 32, + EndingBoundary = 64, + SegmentOptional = 128 + } + + private static SegmentBoundaryFunction FromString(string name, bool useIgnoreCaseVariant, bool segmentOptional) + { + var fn= name switch + { + "equals" or "" or "eq" => SegmentBoundaryFunction.Equals | SegmentBoundaryFunction.IncludeInVar, + "starts_with" or "starts-with" or "starts" => SegmentBoundaryFunction.StartsWith | SegmentBoundaryFunction.IncludeInVar, + "ends_with" or "ends-with" or "ends" => SegmentBoundaryFunction.StartsWith | SegmentBoundaryFunction.IncludeInVar | SegmentBoundaryFunction.EndingBoundary, + "prefix" => SegmentBoundaryFunction.StartsWith, + "suffix" => SegmentBoundaryFunction.StartsWith | SegmentBoundaryFunction.EndingBoundary, + _ => SegmentBoundaryFunction.None + }; + if (fn == SegmentBoundaryFunction.None) + { + return fn; + } + if (useIgnoreCaseVariant) + { + fn |= SegmentBoundaryFunction.IgnoreCase; + } + if (segmentOptional) + { + fn |= SegmentBoundaryFunction.SegmentOptional; + } + return fn; + } + + public static SegmentBoundary Literal(ReadOnlySpan literal, bool ignoreCase) => + StringEquals(literal, ignoreCase, false); + + public static SegmentBoundary LiteralEnd = new(Flags.EndingBoundary, When.SegmentFullyMatchedByStartBoundary, null, '\0'); + + public bool HasDefaultStartWhen => On == When.StartsNow; + public static SegmentBoundary DefaultStart = new(Flags.IncludeMatchingTextInVariable, When.StartsNow, null, '\0'); + public bool HasDefaultEndWhen => On == When.InheritFromNextSegment; + public static SegmentBoundary DefaultEnd = new(Flags.EndingBoundary, When.InheritFromNextSegment, null, '\0'); + public static SegmentBoundary EqualsEnd = new(Flags.EndingBoundary, When.SegmentFullyMatchedByStartBoundary, null, '\0'); + + public bool IsOptional => (Behavior & Flags.SegmentOptional) == Flags.SegmentOptional; + + + public bool IncludesMatchingTextInVariable => + (Behavior & Flags.IncludeMatchingTextInVariable) == Flags.IncludeMatchingTextInVariable; + + public bool IsEndingBoundary => + (Behavior & Flags.EndingBoundary) == Flags.EndingBoundary; + + public bool SupportsScanning => + On != When.StartsNow && + SupportsMatching; + + public bool SupportsMatching => + On != When.InheritFromNextSegment && + On != When.SegmentFullyMatchedByStartBoundary; + + public bool MatchesEntireSegment => + On == When.EqualsOrdinal || On == When.EqualsOrdinalIgnoreCase || On == When.EqualsChar; + + + public SegmentBoundary SetOptional(bool optional) + => new(optional ? Flags.SegmentOptional | Behavior : Behavior ^ Flags.SegmentOptional, On, Chars, Char); + + + public bool AsEndSegmentReliesOnStartSegment => + On == When.SegmentFullyMatchedByStartBoundary; + + public bool AsEndSegmentReliesOnSubsequentSegmentBoundary => + On == When.InheritFromNextSegment; + + + + public static bool TryCreate(string function, bool useIgnoreCase, bool segmentOptional, ReadOnlySpan arg0, + [NotNullWhen(true)] out SegmentBoundary? result) + { + var fn = FromString(function, useIgnoreCase, segmentOptional); + if (fn == SegmentBoundaryFunction.None) + { + result = null; + return false; + } + return TryCreate(fn, arg0, out result); + } + + private static bool TryCreate(SegmentBoundaryFunction function, ReadOnlySpan arg0, out SegmentBoundary? result) + { + var argType = ExpressionParsingHelpers.GetArgType(arg0); + if ((argType & ExpressionParsingHelpers.ArgType.String) == 0) + { + result = null; + return false; + } + var includeInVar = (function & SegmentBoundaryFunction.IncludeInVar) == SegmentBoundaryFunction.IncludeInVar; + var ignoreCase = (function & SegmentBoundaryFunction.IgnoreCase) == SegmentBoundaryFunction.IgnoreCase; + var startsWith = (function & SegmentBoundaryFunction.StartsWith) == SegmentBoundaryFunction.StartsWith; + var equals = (function & SegmentBoundaryFunction.Equals) == SegmentBoundaryFunction.Equals; + var segmentOptional = (function & SegmentBoundaryFunction.SegmentOptional) == SegmentBoundaryFunction.SegmentOptional; + var endingBoundary = (function & SegmentBoundaryFunction.EndingBoundary) == SegmentBoundaryFunction.EndingBoundary; + if (startsWith) + { + result = StartWith(arg0, ignoreCase, includeInVar, endingBoundary).SetOptional(segmentOptional); + return true; + } + if (equals) + { + if (endingBoundary) throw new InvalidOperationException("Equals cannot be an ending boundary"); + result = StringEquals(arg0, ignoreCase, includeInVar).SetOptional(segmentOptional); + return true; + } + throw new InvalidOperationException("Unreachable code"); + } + + private static SegmentBoundary StartWith(ReadOnlySpan asSpan, bool ordinalIgnoreCase, bool includeInVar,bool endingBoundary) + { + var flags = includeInVar ? Flags.IncludeMatchingTextInVariable : Flags.None; + if (endingBoundary) + { + flags |= Flags.EndingBoundary; + } + + if (asSpan.Length == 1 && + (!ordinalIgnoreCase || ExpressionParsingHelpers.IsCommonCaseInsensitiveChar(asSpan[0]))) + { + return new(flags, + When.AtChar, null, asSpan[0]); + } + + return new(flags, + ordinalIgnoreCase ? When.AtStringIgnoreCase : When.AtString, asSpan.ToString(), '\0'); + } + + private static SegmentBoundary StringEquals(ReadOnlySpan asSpan, bool ordinalIgnoreCase, bool includeInVar) + { + if (asSpan.Length == 1 && + (!ordinalIgnoreCase || ExpressionParsingHelpers.IsCommonCaseInsensitiveChar(asSpan[0]))) + { + return new(includeInVar ? Flags.IncludeMatchingTextInVariable : Flags.None, + When.EqualsChar, null, asSpan[0]); + } + + return new(includeInVar ? Flags.IncludeMatchingTextInVariable : Flags.None, + ordinalIgnoreCase ? When.EqualsOrdinalIgnoreCase : When.EqualsOrdinal, asSpan.ToString(), '\0'); + } + + [Flags] + private enum Flags : byte + { + None = 0, + SegmentOptional = 1, + IncludeMatchingTextInVariable = 4, + EndingBoundary = 64, + } + + + private enum When : byte + { + /// + /// Cannot be combined with Optional. + /// Cannot be used for determining the end of a segment. + /// + /// + StartsNow, + EndOfInput, + SegmentFullyMatchedByStartBoundary, + + /// + /// The default for ends + /// + InheritFromNextSegment, + AtChar, + AtString, + AtStringIgnoreCase, + EqualsOrdinal, + EqualsChar, + EqualsOrdinalIgnoreCase, + } + + + public bool TryMatch(ReadOnlySpan text, out int start, out int end) + { + if (!SupportsMatching) + { + throw new InvalidOperationException("Cannot match a segment boundary with " + On); + } + + start = 0; + end = 0; + if (On == When.EndOfInput) + { + return text.Length == 0; + } + + if (On == When.StartsNow) + { + return true; + } + + if (text.Length == 0) return false; + switch (On) + { + case When.AtChar or When.EqualsChar: + if (text[0] == Char) + { + start = 0; + end = 1; + return true; + } + + return false; + + case When.AtString or When.EqualsOrdinal: + var charSpan = Chars.AsSpan(); + if (text.StartsWith(charSpan, StringComparison.Ordinal)) + { + start = 0; + end = charSpan.Length; + return true; + } + + return true; + case When.AtStringIgnoreCase or When.EqualsOrdinalIgnoreCase: + var charSpan2 = Chars.AsSpan(); + if (text.StartsWith(charSpan2, StringComparison.OrdinalIgnoreCase)) + { + start = 0; + end = charSpan2.Length; + return true; + } + + return true; + default: + return false; + } + } + + public bool TryScan(ReadOnlySpan text, out int start, out int end) + { + if (!SupportsScanning) + { + throw new InvalidOperationException("Cannot scan a segment boundary with " + On); + } + + // Like TryMatch, but searches for the first instance of the boundary + start = 0; + end = 0; + if (On == When.EndOfInput) + { + start = end = text.Length; + return true; + } + + if (text.Length == 0) return false; + switch (On) + { + case When.AtChar or When.EqualsChar: + var index = text.IndexOf(Char); + if (index == -1) return false; + start = index; + end = index + 1; + return true; + case When.AtString or When.EqualsOrdinal: + var searchSpan = Chars.AsSpan(); + var searchIndex = text.IndexOf(searchSpan); + if (searchIndex == -1) return false; + start = searchIndex; + end = searchIndex + searchSpan.Length; + return true; + case When.AtStringIgnoreCase or When.EqualsOrdinalIgnoreCase: + var searchSpanIgnoreCase = Chars.AsSpan(); + var searchIndexIgnoreCase = text.IndexOf(searchSpanIgnoreCase, StringComparison.OrdinalIgnoreCase); + if (searchIndexIgnoreCase == -1) return false; + start = searchIndexIgnoreCase; + end = searchIndexIgnoreCase + searchSpanIgnoreCase.Length; + return true; + default: + return false; + } + + + } + private string? MatchString => On switch + { + When.AtChar or When.EqualsChar => Char.ToString(), + When.AtString or When.AtStringIgnoreCase or + When.EqualsOrdinal or When.EqualsOrdinalIgnoreCase => Chars, + _ => null + }; + public override string ToString() + { + var isStartBoundary = Flags.EndingBoundary == (Behavior & Flags.EndingBoundary); + var name = On switch + { + When.StartsNow => "now", + When.EndOfInput => "unterminated", + When.SegmentFullyMatchedByStartBoundary => "noop", + When.InheritFromNextSegment => "unterminated", + When.AtChar or When.AtString or When.AtStringIgnoreCase => + ((Behavior & Flags.IncludeMatchingTextInVariable) != 0) + ? (isStartBoundary ? "starts-with" : "ends-with") + : (isStartBoundary ? "prefix" : "suffix"), + When.EqualsOrdinal or When.EqualsChar or When.EqualsOrdinalIgnoreCase => "equals", + _ => throw new InvalidOperationException("Unreachable code") + }; + var ignoreCase = On is When.AtStringIgnoreCase or When.EqualsOrdinalIgnoreCase ? "-i" : ""; + var optional = (Behavior & Flags.SegmentOptional) != 0 ? "?": ""; + if (Chars != null) + { + name = $"{name}({Chars}){ignoreCase}{optional}"; + } + else if (Char != '\0') + { + name = $"{name}({Char}){ignoreCase}{optional}"; + } + return $"{name}{optional}"; + } +} + + + + diff --git a/src/Imazen.Routing/Matching/StringCondition.cs b/src/Imazen.Routing/Matching/StringCondition.cs new file mode 100644 index 00000000..55f8fc01 --- /dev/null +++ b/src/Imazen.Routing/Matching/StringCondition.cs @@ -0,0 +1,679 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using EnumFastToStringGenerated; + +namespace Imazen.Routing.Matching; + + + +// after(/): optional/?, +// equals(string), everything/** + +// alpha, alphanumeric, alphalower, alphaupper, guid, hex, int, i32, only([a-zA-Z0-9_\:,]), only([^/]) len(3), +// length(3), length(0,3),starts_with_only(3,[a-z]), +// +// ends_with(.jpg|.png|.gif), includes(str), supported_image_type +// ends_with(.jpg|.png|.gif), includes(stra|strb), long, int_range( +// } + +public readonly record struct StringCondition +{ + private StringCondition(StringConditionKind stringConditionKind, char? c, string? str, CharacterClass? charClass, string[]? strArray, int? int1, int? int2) + { + this.stringConditionKind = stringConditionKind; + this.c = c; + this.str = str; + this.charClass = charClass; + this.strArray = strArray; + this.int1 = int1; + this.int2 = int2; + } + private static StringCondition? TryCreate(out string? error, StringConditionKind stringConditionKind, char? c, string? str, CharacterClass? charClass, string[]? strArray, int? int1, int? int2) + { + var condition = new StringCondition(stringConditionKind, c, str, charClass, strArray, int1, int2); + if (!condition.ValidateArgsPresent(out error)) + { + return null; + } + if (!condition.IsInitialized) + { + error = "StringCondition Kind.Uninitialized is not allowed"; + return null; + } + return condition; + } + private static bool TryGetKindsForConditionAlias(string name, bool useIgnoreCaseVariant, [NotNullWhen(true)] out IReadOnlyCollection? kinds) + { + var normalized = StringConditionKindAliases.NormalizeAliases(name); + if (useIgnoreCaseVariant) + { + normalized = StringConditionKindAliases.GetIgnoreCaseVersion(normalized); + } + if (KindLookup.Value.TryGetValue(normalized, out var k)) + { + kinds = k; + return true; + } + kinds = null; + return false; + } + internal static StringCondition? TryParse(out string? error, string name, List>? args, bool useIgnoreCaseVariant) + { + + if (!TryGetKindsForConditionAlias(name, useIgnoreCaseVariant, out var kinds)) + { + error = $"Unknown condition kind '{name}'"; + return null; + } + + var errors = new List(); + foreach (var kind in kinds) + { + var condition = TryCreate(kind, name, args, out error); + if (error != null) + { + errors.Add($"Tried {kind} with {ArgsToStr(args)}: {error}"); + } + if (condition == null) continue; + if (!condition.Value.ValidateArgsPresent(out error)) + { + throw new InvalidOperationException($"Condition was created with invalid arguments. Received: ({ArgsToStr(args)})"); + } + return condition; + } + error = $"Invalid arguments for condition '{name}'. Received: ({ArgsToStr(args)}). Errors: {string.Join(", ", errors)}"; + return null; + } + private static string ArgsToStr(List>? args) => args == null ? "" : string.Join(",", args); + + private static StringCondition? TryCreate(StringConditionKind stringConditionKind, string name, List>? args, out string? error) + { + var expectedArgs = ForKind(stringConditionKind); + if (args == null) + { + if (HasFlagsFast(expectedArgs, ExpectedArgs.None)) + { + error = null; + return new StringCondition(stringConditionKind, null, null, null, null, null, null); + } + error = $"Expected argument type {expectedArgs} for condition '{name}'; received none."; + return null; + } + bool wantsFirstArgNumeric = HasFlagsFast(expectedArgs, ExpectedArgs.Int321) || HasFlagsFast(expectedArgs, ExpectedArgs.Int321OrInt322) + || stringConditionKind == StringConditionKind.StartsWithNCharClass; + + bool firstOptional = HasFlagsFast(expectedArgs, ExpectedArgs.Int321OrInt322); + var obj = TryParseArg(args[0], wantsFirstArgNumeric, firstOptional, out error); + if (error != null) + { + throw new InvalidOperationException($"Error parsing 1st argument: {error}"); + } + var twoIntArgs = HasFlagsFast(expectedArgs, ExpectedArgs.Int321OrInt322); + var intArgAndClassArg = HasFlagsFast(expectedArgs, ExpectedArgs.Int321 | ExpectedArgs.CharClass); + var secondArgRequired = intArgAndClassArg + || (twoIntArgs && obj == null); + var secondArgWanted = secondArgRequired || twoIntArgs; + var secondArgNumeric = twoIntArgs; + if (args.Count != 1 && !secondArgWanted) + { + error = $"Expected 1 argument for condition '{name}'; received {args.Count}: {ArgsToStr(args)}."; + return null; + } + if (secondArgRequired && args.Count != 2) + { + error = $"Expected 2 arguments for condition '{name}'; received {args.Count}: {ArgsToStr(args)}."; + return null; + } + + var obj2 = secondArgWanted && args.Count > 1 ? TryParseArg(args[1], secondArgNumeric, !secondArgRequired, out error) : null; + if (error != null) + { + throw new InvalidOperationException($"Error parsing 2nd argument: {error}"); + } + if (secondArgRequired && obj2 == null) + { + throw new InvalidOperationException($"Missing 2nd argument: {error}"); + } + + if (obj is double decVal) + { + error = $"Unexpected decimal argument for condition '{name}'; received '{args[0]}' {decVal}."; + return null; + } + if (obj is int intVal) + { + if (twoIntArgs) + { + var int2 = obj2 as int?; + error = null; + return new StringCondition(stringConditionKind, null, null, null, null, intVal, int2); + }else if (intArgAndClassArg) + { + if (obj2 is CharacterClass cc2) + { + error = null; + return new StringCondition(stringConditionKind, null, null, cc2, null, intVal, null); + } + else + { + error = $"Unexpected argument for condition '{name}'; received '{args[1]}'."; + return null; + } + } else if (HasFlagsFast(expectedArgs, ExpectedArgs.Int321)) + { + error = null; + return new StringCondition(stringConditionKind, null, null, null, null, intVal, null); + } + else + { + error = $"Unexpected int argument for condition '{name}'; received '{args[0]}'."; + return null; + } + + } + if (obj is char c) + { + if (HasFlagsFast(expectedArgs, ExpectedArgs.Char)) + { + error = null; + return new StringCondition(stringConditionKind, c, null, null, null, null, null); + } + // try converting it to a string + if (HasFlagsFast(expectedArgs, ExpectedArgs.String)) + { + error = null; + return new StringCondition(stringConditionKind, null, c.ToString(), null, null, null, null); + } + error = $"Unexpected char argument for condition '{name}'; received '{args[0]}'."; + return null; + } + if (obj is string str) + { + if (HasFlagsFast(expectedArgs, ExpectedArgs.String)) + { + error = null; + return new StringCondition(stringConditionKind, null, str, null, null, null, null); + } + error = $"Unexpected string argument for condition '{name}'; received '{args[0]}'."; + return null; + } + if (obj is string[] strArray) + { + if (HasFlagsFast(expectedArgs, ExpectedArgs.StringArray)) + { + error = null; + return new StringCondition(stringConditionKind, null, null, null, strArray, null, null); + } + error = $"Unexpected string array argument for condition '{name}'; received '{args[0]}'."; + return null; + } + if (obj is CharacterClass cc) + { + if (HasFlagsFast(expectedArgs, ExpectedArgs.CharClass)) + { + error = null; + return new StringCondition(stringConditionKind, null, null, cc, null, null, null); + } + error = $"Unexpected char class argument for condition '{name}'; received '{args[0]}'."; + return null; + } + throw new NotImplementedException("Unexpected argument type"); + } + + private static string? TryParseString(ReadOnlySpan arg, out string? error) + { + // Some characters must be escaped + // ' " \ [ ] | , ( ) + error = null; + return arg.ToString(); + } + + private static object? TryParseArg(ReadOnlyMemory argMemory, bool tryParseNumeric, bool allowEmpty, out string? error) + { + var arg = argMemory.Span; + var type = ExpressionParsingHelpers.GetArgType(arg); + if (allowEmpty && type == ExpressionParsingHelpers.ArgType.Empty) + { + error = null; + return null; + } + if ((type & ExpressionParsingHelpers.ArgType.CharClass) > 0) + { + if (!CharacterClass.TryParseInterned(argMemory,true, out var cc, out error)) + { + return null; + } + return cc; + } + if ((type & ExpressionParsingHelpers.ArgType.Array) > 0) + { + // use FindCharNotEscaped + var list = new List(); + var start = 0; + while (start < arg.Length) + { + var commaIndex = ExpressionParsingHelpers.FindCharNotEscaped(arg[start..], '|', '\\'); + if (commaIndex == -1) + { + var lastStr = TryParseString(arg[start..], out error); + if (lastStr == null) return null; + list.Add(lastStr); + break; + } + + var s = TryParseString(arg[start..(start + commaIndex)], out error); + if (s == null) return null; + list.Add(s); + start += commaIndex + 1; + } + error = null; + return list.ToArray(); + } + + if (tryParseNumeric & (type & ExpressionParsingHelpers.ArgType.IntegerNumeric) > 0) + { +#if NET6_0_OR_GREATER + if (int.TryParse(arg, out var i)) +#else + if (int.TryParse(arg.ToString(), out var i)) +#endif + { + error = null; + return i; + } + } + + if (tryParseNumeric & (type & ExpressionParsingHelpers.ArgType.DecimalNumeric) > 0) + { +#if NET6_0_OR_GREATER + if (double.TryParse(arg, out var d)) +#else + if (double.TryParse(arg.ToString(), out var d)) +#endif + { + error = null; + return d; + } + } + + if ((type & ExpressionParsingHelpers.ArgType.Char) > 0) + { + if (arg.Length != 1) + { + error = "Expected a single character"; + return null; + } + error = null; + return arg[0]; + } + + if ((type & ExpressionParsingHelpers.ArgType.String) > 0) + { + return TryParseString(arg, out error); + } + throw new NotImplementedException(); + } + + private static readonly Lazy>> KindLookup = new Lazy>>(() => + { + var dict = new Dictionary>(); + foreach (var kind in StringConditionKindEnumExtensions.GetValuesFast()) + { + var key = kind.ToDisplayFast(); + if (!dict.TryGetValue(key, out var list)) + { + list = new List(); + dict[key] = list; + } + ((List)list).Add(kind); + } + return dict; + }); + + + public static StringCondition Uninitialized => new StringCondition(StringConditionKind.Uninitialized, null, null, null, null, null, null); + private bool IsInitialized => stringConditionKind != StringConditionKind.Uninitialized; + + private readonly StringConditionKind stringConditionKind; + private readonly char? c; + private readonly string? str; + private readonly CharacterClass? charClass; + private readonly string[]? strArray; + private readonly int? int1; + private readonly int? int2; + + public bool Evaluate(ReadOnlySpan text, MatchingContext context) => stringConditionKind switch + { + StringConditionKind.EnglishAlphabet => text.IsEnglishAlphabet(), + StringConditionKind.NumbersAndEnglishAlphabet => text.IsNumbersAndEnglishAlphabet(), + StringConditionKind.LowercaseEnglishAlphabet => text.IsLowercaseEnglishAlphabet(), + StringConditionKind.UppercaseEnglishAlphabet => text.IsUppercaseEnglishAlphabet(), + StringConditionKind.Hexadecimal => text.IsHexadecimal(), + StringConditionKind.Int32 => text.IsInt32(), + StringConditionKind.Int64 => text.IsInt64(), + StringConditionKind.EndsWithSupportedImageExtension => context.EndsWithSupportedImageExtension(text), + StringConditionKind.IntegerRange => text.IsInIntegerRangeInclusive(int1, int2), + StringConditionKind.Guid => text.IsGuid(), + StringConditionKind.CharLength => text.LengthWithinInclusive(int1, int2), + StringConditionKind.EqualsOrdinal => text.EqualsOrdinal(str!), + StringConditionKind.EqualsOrdinalIgnoreCase => text.EqualsOrdinalIgnoreCase(str!), + StringConditionKind.EqualsAnyOrdinal => text.EqualsAnyOrdinal(strArray!), + StringConditionKind.EqualsAnyOrdinalIgnoreCase => text.EqualsAnyOrdinalIgnoreCase(strArray!), + StringConditionKind.StartsWithChar => text.StartsWithChar(c!.Value), + StringConditionKind.StartsWithOrdinal => text.StartsWithOrdinal(str!), + StringConditionKind.StartsWithOrdinalIgnoreCase => text.StartsWithOrdinalIgnoreCase(str!), + StringConditionKind.StartsWithAnyOrdinal => text.StartsWithAnyOrdinal(strArray!), + StringConditionKind.StartsWithAnyOrdinalIgnoreCase => text.StartsWithAnyOrdinalIgnoreCase(strArray!), + StringConditionKind.EndsWithChar => text.EndsWithChar(c!.Value), + StringConditionKind.EndsWithOrdinal => text.EndsWithOrdinal(str!), + StringConditionKind.EndsWithOrdinalIgnoreCase => text.EndsWithOrdinalIgnoreCase(str!), + StringConditionKind.EndsWithAnyOrdinal => text.EndsWithAnyOrdinal(strArray!), + StringConditionKind.EndsWithAnyOrdinalIgnoreCase => text.EndsWithAnyOrdinalIgnoreCase(strArray!), + StringConditionKind.IncludesOrdinal => text.IncludesOrdinal(str!), + StringConditionKind.IncludesOrdinalIgnoreCase => text.IncludesOrdinalIgnoreCase(str!), + StringConditionKind.IncludesAnyOrdinal => text.IncludesAnyOrdinal(strArray!), + StringConditionKind.IncludesAnyOrdinalIgnoreCase => text.IncludesAnyOrdinalIgnoreCase(strArray!), + StringConditionKind.True => true, + StringConditionKind.CharClass => text.IsCharClass(charClass!), + StringConditionKind.StartsWithNCharClass => text.StartsWithNCharClass(charClass!, int1!.Value), + StringConditionKind.StartsWithCharClass => text.StartsWithCharClass(charClass!), + StringConditionKind.EndsWithCharClass => text.EndsWithCharClass(charClass!), + StringConditionKind.Uninitialized => throw new InvalidOperationException("Uninitialized StringCondition was evaluated"), + _ => throw new NotImplementedException() + }; + + private bool ValidateArgsPresent([NotNullWhen(false)] out string? error) + { + var expected = ForKind(stringConditionKind); + if (HasFlagsFast(expected, ExpectedArgs.Char) != c.HasValue) + { + error = c.HasValue + ? "Unexpected char parameter in StringCondition" + : "char parameter missing in StringCondition"; + return false; + } + + if (HasFlagsFast(expected, ExpectedArgs.String) != (str != null)) + { + error = (str != null) + ? "Unexpected string parameter in StringCondition" + : "string parameter missing in StringCondition"; + return false; + } + + if (HasFlagsFast(expected, ExpectedArgs.StringArray) != (strArray != null)) + { + error = (strArray != null) + ? "Unexpected string array parameter in StringCondition" + : "string array parameter missing in StringCondition"; + return false; + } + if (HasFlagsFast(expected, ExpectedArgs.CharClass) != (charClass != null)) + { + error = (charClass != null) + ? "Unexpected char class parameter in StringCondition" + : "char class parameter missing in StringCondition"; + return false; + } + + if (HasFlagsFast(expected, ExpectedArgs.Int321OrInt322)) + { + if (int1.HasValue || int2.HasValue) + { + error = null; + return true; + } + error = "int parameter(s) missing in StringCondition"; + return false; + } + + if (HasFlagsFast(expected, ExpectedArgs.Int321) != int1.HasValue) + { + error = int1.HasValue + ? "Unexpected int parameter in StringCondition" + : "int parameter missing in StringCondition"; + return false; + } + + if (int2.HasValue) + { + error = "Unexpected 2nd int parameter in StringCondition"; + return false; + } + error = null; + return true; + } + + [Flags] + private enum ExpectedArgs + { + None = 0, + Char = 1, + String = 2, + StringArray = 4, + Int321 = 8, + Int321OrInt322 = 16 | Int321, + CharClass = 32, + Int32AndCharClass = Int321 | CharClass + } + private static bool HasFlagsFast(ExpectedArgs value, ExpectedArgs flags) => (value & flags) == flags; + + + private static ExpectedArgs ForKind(StringConditionKind stringConditionKind) => + stringConditionKind switch + { + StringConditionKind.StartsWithChar => ExpectedArgs.Char, + StringConditionKind.EndsWithChar => ExpectedArgs.Char, + StringConditionKind.EqualsOrdinal => ExpectedArgs.String, + StringConditionKind.EqualsOrdinalIgnoreCase => ExpectedArgs.String, + StringConditionKind.EqualsAnyOrdinal => ExpectedArgs.StringArray, + StringConditionKind.EqualsAnyOrdinalIgnoreCase => ExpectedArgs.StringArray, + StringConditionKind.StartsWithOrdinal => ExpectedArgs.String, + StringConditionKind.StartsWithOrdinalIgnoreCase => ExpectedArgs.String, + StringConditionKind.EndsWithOrdinal => ExpectedArgs.String, + StringConditionKind.EndsWithOrdinalIgnoreCase => ExpectedArgs.String, + StringConditionKind.IncludesOrdinal => ExpectedArgs.String, + StringConditionKind.IncludesOrdinalIgnoreCase => ExpectedArgs.String, + StringConditionKind.StartsWithAnyOrdinal => ExpectedArgs.StringArray, + StringConditionKind.StartsWithAnyOrdinalIgnoreCase => ExpectedArgs.StringArray, + StringConditionKind.EndsWithAnyOrdinal => ExpectedArgs.StringArray, + StringConditionKind.EndsWithAnyOrdinalIgnoreCase => ExpectedArgs.StringArray, + StringConditionKind.IncludesAnyOrdinal => ExpectedArgs.StringArray, + StringConditionKind.IncludesAnyOrdinalIgnoreCase => ExpectedArgs.StringArray, + StringConditionKind.CharLength => ExpectedArgs.Int321OrInt322, + StringConditionKind.IntegerRange => ExpectedArgs.Int321OrInt322, + StringConditionKind.CharClass => ExpectedArgs.CharClass, + StringConditionKind.StartsWithNCharClass => ExpectedArgs.Int32AndCharClass, + StringConditionKind.EnglishAlphabet => ExpectedArgs.None, + StringConditionKind.NumbersAndEnglishAlphabet => ExpectedArgs.None, + StringConditionKind.LowercaseEnglishAlphabet => ExpectedArgs.None, + StringConditionKind.UppercaseEnglishAlphabet => ExpectedArgs.None, + StringConditionKind.Hexadecimal => ExpectedArgs.None, + StringConditionKind.Int32 => ExpectedArgs.None, + StringConditionKind.Int64 => ExpectedArgs.None, + StringConditionKind.Guid => ExpectedArgs.None, + StringConditionKind.StartsWithCharClass => ExpectedArgs.CharClass, + StringConditionKind.EndsWithCharClass => ExpectedArgs.CharClass, + StringConditionKind.EndsWithSupportedImageExtension => ExpectedArgs.None, + StringConditionKind.Uninitialized => ExpectedArgs.None, + StringConditionKind.True => ExpectedArgs.None, + _ => throw new ArgumentOutOfRangeException(nameof(stringConditionKind), stringConditionKind, null) + }; + + public bool IsMatch(ReadOnlySpan varSpan, int i, int varSpanLength) + { + throw new NotImplementedException(); + } + + // ToString should return function call syntax + public override string ToString() + { + var name = stringConditionKind.ToDisplayFast() ?? stringConditionKind.ToStringFast(); + var sb = new StringBuilder(name); + sb.Append('('); + if (int1.HasValue) + { + sb.Append(int1); + } + if (int2.HasValue) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(int2); + } + // TODO: escape strings properly + else if (c.HasValue) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(c); + } + else if (str != null) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(str); + } + else if (strArray != null) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(string.Join("|", strArray)); + } + else if (charClass != null) + { + if (sb[^1] != '(') + sb.Append(","); + sb.Append(charClass); + } + sb.Append(')'); + return sb.ToString(); + } +} + +internal static class StringConditionKindAliases +{ + internal static string NormalizeAliases(string name) + { + return name switch + { + "int" => "int32", + "hexadecimal" => "hex", + "integer" => "int32", + "long" => "int64", + "i32" => "int32", + "i64" => "int64", + "integer-range" => "range", + "only" => "allowed-chars", + "starts-with-only" => "starts-with-chars", + "len" => "length", + "eq" => "equals", + "" => "equals", + "starts" => "starts-with", + "ends" => "ends-with", + "includes" => "contains", + "includes-i" => "contains-i", + "image-extension-supported" => "image-ext-supported", + "image-type-supported" => "image-ext-supported", + _ => name + }; + } + internal static string GetIgnoreCaseVersion(string name) + { + return name switch + { + "equals" => "equals-i", + "starts-with" => "starts-with-i", + "ends-with" => "ends-with-i", + "includes" => "includes-i", + _ => name + }; + } +} +[EnumGenerator] +public enum StringConditionKind: byte +{ + Uninitialized = 0, + [Display(Name = "true")] + True, + /// + /// Case-insensitive (a-zA-Z) + /// + [Display(Name = "alpha")] + EnglishAlphabet, + [Display(Name = "alphanumeric")] + NumbersAndEnglishAlphabet, + [Display(Name = "alpha-lower")] + LowercaseEnglishAlphabet, + [Display(Name = "alpha-upper")] + UppercaseEnglishAlphabet, + /// + /// Case-insensitive (a-f0-9A-F) + /// + [Display(Name = "hex")] + Hexadecimal, + [Display(Name = "int32")] + Int32, + [Display(Name = "int64")] + Int64, + [Display(Name = "range")] + IntegerRange, + [Display(Name = "allowed-chars")] + CharClass, + [Display(Name = "starts-with-chars")] + StartsWithNCharClass, + [Display(Name = "length")] + CharLength, + [Display(Name = "guid")] + Guid, + [Display(Name = "equals")] + EqualsOrdinal, + [Display(Name = "equals-i")] + EqualsOrdinalIgnoreCase, + [Display(Name = "equals")] + EqualsAnyOrdinal, + [Display(Name = "equals-i")] + EqualsAnyOrdinalIgnoreCase, + [Display(Name = "starts-with")] + StartsWithOrdinal, + [Display(Name = "starts-with")] + StartsWithChar, + [Display(Name = "starts-with")] + StartsWithCharClass, + [Display(Name = "starts-with-i")] + StartsWithOrdinalIgnoreCase, + [Display(Name = "starts-with")] + StartsWithAnyOrdinal, + [Display(Name = "starts-with-i")] + StartsWithAnyOrdinalIgnoreCase, + [Display(Name = "ends-with")] + EndsWithOrdinal, + [Display(Name = "ends-with")] + EndsWithChar, + [Display(Name = "ends-with")] + EndsWithCharClass, + [Display(Name = "ends-with-i")] + EndsWithOrdinalIgnoreCase, + [Display(Name = "ends-with")] + EndsWithAnyOrdinal, + [Display(Name = "ends-with-i")] + EndsWithAnyOrdinalIgnoreCase, + [Display(Name = "contains")] + IncludesOrdinal, + [Display(Name = "contains-i")] + IncludesOrdinalIgnoreCase, + [Display(Name = "contains")] + IncludesAnyOrdinal, + [Display(Name = "contains-i")] + IncludesAnyOrdinalIgnoreCase, + [Display(Name = "image-ext-supported")] + EndsWithSupportedImageExtension +} + +[AttributeUsage( + AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Method | AttributeTargets.Class, + AllowMultiple = false)] +internal sealed class DisplayAttribute : Attribute +{ + public string? Name { get; set; } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs b/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs new file mode 100644 index 00000000..be87bfa9 --- /dev/null +++ b/src/Imazen.Routing/Matching/StringConditionMatchingHelpers.cs @@ -0,0 +1,244 @@ +namespace Imazen.Routing.Matching; + +internal static class StringConditionMatchingHelpers +{ + internal static bool IsEnglishAlphabet(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'a' and <= 'z': + case >= 'A' and <= 'Z': + continue; + default: + return false; + } + } + return true; + } + internal static bool IsNumbersAndEnglishAlphabet(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'a' and <= 'z': + case >= 'A' and <= 'Z': + case >= '0' and <= '9': + continue; + default: + return false; + } + } + return true; + } + internal static bool IsLowercaseEnglishAlphabet(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'a' and <= 'z': + continue; + default: + return false; + } + } + return true; + } + internal static bool IsUppercaseEnglishAlphabet(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'A' and <= 'Z': + continue; + default: + return false; + } + } + return true; + } + internal static bool IsHexadecimal(this ReadOnlySpan chars) + { + foreach (var c in chars) + { + switch (c) + { + case >= 'a' and <= 'f': + case >= 'A' and <= 'F': + case >= '0' and <= '9': + continue; + default: + return false; + } + } + return true; + } +#if NET6_0_OR_GREATER + internal static bool IsInt32(this ReadOnlySpan chars) => int.TryParse(chars, out _); + internal static bool IsInt64(this ReadOnlySpan chars) => long.TryParse(chars, out _); + internal static bool IsInIntegerRangeInclusive(this ReadOnlySpan chars, int? min, int? max) + { + if (!int.TryParse(chars, out var value)) return false; + if (min.HasValue ^ max.HasValue) return value == min || value == max; + if (min != null && value < min) return false; + if (max != null && value > max) return false; + return true; + } + internal static bool IsGuid(this ReadOnlySpan chars) => Guid.TryParse(chars, out _); +#else + internal static bool IsGuid(this ReadOnlySpan chars) => Guid.TryParse(chars.ToString(), out _); + internal static bool IsInt32(this ReadOnlySpan chars) => int.TryParse(chars.ToString(), out _); + internal static bool IsInt64(this ReadOnlySpan chars) => long.TryParse(chars.ToString(), out _); + internal static bool IsInIntegerRangeInclusive(this ReadOnlySpan chars, int? min, int? max) + { + if (!int.TryParse(chars.ToString(), out var value)) return false; + if (min.HasValue ^ max.HasValue) return value == min || value == max; + if (min != null && value < min) return false; + if (max != null && value > max) return false; + return true; + } +#endif + + internal static bool IsOnlyCharsInclusive(this ReadOnlySpan chars, string[]? allowedChars) + { + if (allowedChars == null) return false; + foreach (var c in chars) + { + if (!allowedChars.Any(x => x.Contains(c))) return false; + } + return true; + } + internal static bool C(this ReadOnlySpan chars, string[]? disallowedChars) + { + if (disallowedChars == null) return false; + foreach (var c in chars) + { + if (disallowedChars.Any(x => x.Contains(c))) return false; + } + return true; + } + internal static bool LengthWithinInclusive(this ReadOnlySpan chars, int? min, int? max) + { + if (min.HasValue ^ max.HasValue) return chars.Length == min || chars.Length == max; + if (min != null && chars.Length < min) return false; + if (max != null && chars.Length > max) return false; + return true; + } + internal static bool EqualsOrdinal(this ReadOnlySpan chars, string value) => chars.Equals(value.AsSpan(), StringComparison.Ordinal); + internal static bool EqualsOrdinalIgnoreCase(this ReadOnlySpan chars, string value) => chars.Equals(value.AsSpan(), StringComparison.OrdinalIgnoreCase); + + + internal static bool StartsWithChar(this ReadOnlySpan chars, char c) => chars.Length > 0 && chars[0] == c; + internal static bool StartsWithOrdinal(this ReadOnlySpan chars, string value) => chars.StartsWith(value.AsSpan(), StringComparison.Ordinal); + internal static bool StartsWithOrdinalIgnoreCase(this ReadOnlySpan chars, string value) => chars.StartsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase); + + internal static bool StartsWithAnyOrdinal(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.StartsWith(value.AsSpan(), StringComparison.Ordinal)) return true; + } + return false; + } + internal static bool StartsWithAnyOrdinalIgnoreCase(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.StartsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + internal static bool EqualsAnyOrdinal(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.Equals(value.AsSpan(), StringComparison.Ordinal)) return true; + } + return false; + } + internal static bool EqualsAnyOrdinalIgnoreCase(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.Equals(value.AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + internal static bool EndsWithChar(this ReadOnlySpan chars, char c) => chars.Length > 0 && chars[^1] == c; + internal static bool EndsWithOrdinal(this ReadOnlySpan chars, string value) => chars.EndsWith(value.AsSpan(), StringComparison.Ordinal); + internal static bool EndsWithOrdinalIgnoreCase(this ReadOnlySpan chars, string value) => chars.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase); + internal static bool EndsWithAnyOrdinal(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.EndsWith(value.AsSpan(), StringComparison.Ordinal)) return true; + } + return false; + } + internal static bool EndsWithAnyOrdinalIgnoreCase(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + internal static bool IncludesOrdinal(this ReadOnlySpan chars, string value) => chars.IndexOf(value.AsSpan(), StringComparison.Ordinal) != -1; + internal static bool IncludesOrdinalIgnoreCase(this ReadOnlySpan chars, string value) => chars.IndexOf(value.AsSpan(), StringComparison.OrdinalIgnoreCase) != -1; + internal static bool IncludesAnyOrdinal(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.IndexOf(value.AsSpan(), StringComparison.Ordinal) != -1) return true; + } + return false; + } + internal static bool IncludesAnyOrdinalIgnoreCase(this ReadOnlySpan chars, string[] values) + { + foreach (var value in values) + { + if (chars.IndexOf(value.AsSpan(), StringComparison.OrdinalIgnoreCase) != -1) return true; + } + return false; + } + internal static bool IsCharClass(this ReadOnlySpan chars, CharacterClass charClass) + { + foreach (var c in chars) + { + if (!charClass.Contains(c)) return false; + } + return true; + } + internal static bool StartsWithNCharClass(this ReadOnlySpan chars, CharacterClass charClass, int n) + { + if (chars.Length < n) return false; + for (int i = 0; i < n; i++) + { + if (!charClass.Contains(chars[i])) return false; + } + return true; + } + internal static bool StartsWithCharClass(this ReadOnlySpan chars, CharacterClass charClass) + { + return chars.Length != 0 && charClass.Contains(chars[0]); + } + internal static bool EndsWithCharClass(this ReadOnlySpan chars, CharacterClass charClass) + { + return chars.Length != 0 && charClass.Contains(chars[^1]); + } + internal static bool EndsWithSupportedImageExtension(this MatchingContext context, ReadOnlySpan chars) + { + foreach (var ext in context.SupportedImageExtensions) + { + if (chars.EndsWith(ext.AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + +} \ No newline at end of file diff --git a/src/Imazen.Routing/Matching/matching.md b/src/Imazen.Routing/Matching/matching.md new file mode 100644 index 00000000..f8d72fe5 --- /dev/null +++ b/src/Imazen.Routing/Matching/matching.md @@ -0,0 +1,132 @@ +# Design of route matcher syntax + + + +/images/{seo_string_ignored}/{sku:guid}/{image_id:int:range(0,1111111)}{width:int:after(_):optional}.{format:only(jpg|png|gif)} +/azure/centralstorage/skus/${sku}/images/${image_id}.${format}?format=avif&w=${width} + + +/images/{path:*:has_supported_image_type} +/azure/container/${path} + + + + + + + + + +We only want non-backtracking functionality. +all conditions are AND, and variable strings are parsed before conditions are applied, with the following exceptions: +after, until. +If a condition lacks until, it is taken from the following character. + + + +Variables in match strings will be +{name:condition1:condition2} +They will terminate their matching when the character that follows them is reached. We explain that variables implictly match until their last character + +"/images/{id:int:until(/):optional}seoname" +"/images/{id:int}/seoname" +"/image_{id:int}_seoname" +"/image_{id:int}_{w:int}_seoname" +"/image_{id:int}_{w:int:until(_):optional}seoname" +"/image_{id:int}_{w:int:until(_)}/{**}" + +A trailing ? means the variable (and its trailing character (leading might be also useful?)) is optional. + +Partial matches +match_path="/images/{path:**}" +remove_matched_part_for_children + +or +consume_prefix "/images/" + + +match_path_extension +match_path +match_path_and_query +match_query + + +Variables can be inserted in target strings using ${name:transform} +where transform can be `lower`, `upper`, `trim`, `trim(a-zA-Z\t\:\\-)) + + +## conditions + +alpha, alphanumeric, alphalower, alphaupper, guid, hex, int, only([a-zA-Z0-9_\:\,]), only(^/) len(3), length(3), length(0,3),starts_with_only(3,a-z), until(/), after(/): optional/?, equals(string), everything/** + ends_with((.jpg|.png|.gif)), includes(), supported_image_type +ends_with(.jpg|.png|.gif), until(), after(), includes(), + +until and after specify trailing and leading characters that are part of the matching group, but are only useful if combined with `optional`. + +TODO: sha256/auth stuff + + +respond_400_on_variable_condition_failure=true +process_image=true +pass_throgh=true +allow_pass_through=true +stop_here=true +case_sensitive=true/false (IIS/ASP.NET default to insensitive, but it's a bad default) + +[routes.accepts_any] +accept_header_has_type="*/*" +add_query_value="accepts=*" +set_query_value="format=auto" + +[routes.accepts_webp] +accept_header_has_type="image/webp" +add_query_value="accepts=webp" +set_query_value="format=auto" + +[routes.accepts_avif] +accept_header_has_type="image/avif" +add_query_value="accepts=avif" +set_query_value="format=auto" + + +# Escaping characters + +JSON/TOML escapes include +\" +\\ +\/ (JSON only) +\b +\f +\n +\r +\t +\u followed by four-hex-digits +\UXXXXXXXX - unicode (U+XXXXXXXX) (TOML only?) + +Test with url decoded foreign strings too + +# Real-world examples + +Setting a max size by folder or authentication status.. + +We were thinking we could save as a GUID and have some mapping, where we asked for image “St-Croix-Legend-Extreme-Rod….” and we somehow did a lookup to see what the actual GUID image name was but seems we would introduce more issues, like needing to make ImageResizer and handler and not a module to do the lookup and creating an extra database call per image. Doesn’t seem like a great solution, any ideas? Just use the descriptive name? + +2. You're free to use any URL rewriting solution, the provided Config.Current.Pipeline.Rewrite event, or the included Presets plugin: http://imageresizing.net/plugins/presets + +You can also add a Rewrite handler and check the HOST header if you wanted +to use subdomains instead of prefixes. Prefixes are probably better though. + + + +## Example Accept header values +image/avif,image/webp,*/* +image/webp,*/* +*/* +image/png,image/*;q=0.8,*/*;q=0.5 +image/webp,image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5 +image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5 +image/avif,image/webp,image/apng,image/*,*/*;q=0.8 + +video +video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5 audio/webm,audio/ogg,audio/wav,audio/*;q=0.9,application/ogg;q=0.7,video/*;q=0.6,*/*;q=0.5 +*/* \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/IBlobPromisePipeline.cs b/src/Imazen.Routing/Promises/IBlobPromisePipeline.cs new file mode 100644 index 00000000..e10c3f22 --- /dev/null +++ b/src/Imazen.Routing/Promises/IBlobPromisePipeline.cs @@ -0,0 +1,19 @@ +using Imazen.Abstractions.Resulting; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Promises; + +public interface IBlobPromisePipeline +{ + /// + /// This probably needs more detail, such as - are we allowing job wrapping? cache wrapping? etc. + /// + /// + /// + /// + /// + /// + /// + ValueTask> GetFinalPromiseAsync(ICacheableBlobPromise promise, IBlobRequestRouter router, IBlobPromisePipeline promisePipeline, IHttpRequestStreamAdapter outerRequest, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/IHasCacheKeyBasis.cs b/src/Imazen.Routing/Promises/IHasCacheKeyBasis.cs new file mode 100644 index 00000000..31d8ca13 --- /dev/null +++ b/src/Imazen.Routing/Promises/IHasCacheKeyBasis.cs @@ -0,0 +1,8 @@ +using System.Buffers; + +namespace Imazen.Routing.Promises; + +public interface IHasCacheKeyBasis +{ + void WriteCacheKeyBasisPairsTo(IBufferWriter writer); +} \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/IInstantPromise.cs b/src/Imazen.Routing/Promises/IInstantPromise.cs new file mode 100644 index 00000000..4af61f74 --- /dev/null +++ b/src/Imazen.Routing/Promises/IInstantPromise.cs @@ -0,0 +1,161 @@ +using System.Buffers; +using System.Security.Cryptography; +using CommunityToolkit.HighPerformance.Buffers; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Promises; + +public interface IInstantPromise +{ + bool IsCacheSupporting { get; } + IRequestSnapshot FinalRequest { get; } + + // CodeResult would allow a lighter error path, but we also have http headers and maybe two ways to respond isn't best? + ValueTask CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, + IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default); +} +public record PredefinedResponsePromise(IRequestSnapshot FinalRequest, IAdaptableReusableHttpResponse Response) : IInstantPromise{ + + public bool IsCacheSupporting => false; + + public ValueTask CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default) + { + return new ValueTask(Response); + } +} + + +public interface IInstantCacheKeySupportingPromise: IInstantPromise +{ + // We only need to deal with dependencies if we are going to cache the final result. + bool HasDependencies { get; } + + bool ReadyToWriteCacheKeyBasisData { get; } + + ValueTask RouteDependenciesAsync(IBlobRequestRouter router, CancellationToken cancellationToken = default); + + // Must call resolve dependencies first + void WriteCacheKeyBasisPairsToRecursive(IBufferWriter writer); + + byte[] GetCacheKey32Bytes(); + + /// + /// Should be implemented by source file providers and image processors. + /// Cache promise layers should return null. + /// + LatencyTrackingZone? LatencyZone { get; } +} +public interface ICacheableBlobPromise : IInstantCacheKeySupportingPromise +{ + bool SupportsPreSignedUrls { get; } + + ValueTask> TryGetBlobAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default); +} + +public interface ISupportsPreSignedUrls : ICacheableBlobPromise +{ + bool TryGeneratePreSignedUrl(IRequestSnapshot request, out string? url); +} + +public static class CacheableBlobPromiseExtensions +{ + public static byte[] GetCacheKey32BytesUncached(this ICacheableBlobPromise promise) + { + var buffer = new byte[32]; + var used = promise.CopyCacheKeyBytesTo(buffer); + if (used.Length != 32) + { + throw new InvalidOperationException("Hash buffer failure 1125125"); + } + return buffer; + } + public static ReadOnlySpan CopyCacheKeyBytesTo(this ICacheableBlobPromise promise, Span buffer32Bytes) + { + if (buffer32Bytes.Length < 32) + { + throw new ArgumentException("Must be at least 32 bytes", nameof(buffer32Bytes)); + } + if (!promise.ReadyToWriteCacheKeyBasisData) + { + throw new InvalidOperationException("Promise is not ready to write cache key basis data. Did you call RouteDependenciesAsync? Check ReadyToWriteCacheKeyBasisData first"); + } + using var buffer = new ArrayPoolBufferWriter(4096); + promise.WriteCacheKeyBasisPairsToRecursive(buffer); + +#if NET5_0_OR_GREATER + var bytesWritten = SHA256.HashData(buffer.WrittenSpan, buffer32Bytes); +buffer.Dispose(); + return buffer32Bytes[..bytesWritten]; +#elif NETSTANDARD2_0 + using var hasher = SHA256.Create(); + var segment = buffer.DangerousGetArray(); + var count = buffer.WrittenCount; + if (count > segment.Count || segment.Array is null) + { + throw new InvalidOperationException("Hash buffer failure 1125125"); + } + var outputBuffer = hasher.ComputeHash(segment.Array, segment.Offset, count); + outputBuffer.CopyTo(buffer32Bytes); + return buffer32Bytes[..outputBuffer.Length]; +#else + throw new PlatformNotSupportedException(); +#endif + } +} + +public record CacheableBlobPromise(IRequestSnapshot FinalRequest, LatencyTrackingZone LatencyZone, Func>> BlobFunc) : ICacheableBlobPromise{ + + public bool IsCacheSupporting => true; + public bool HasDependencies => false; + public bool ReadyToWriteCacheKeyBasisData => true; + public bool SupportsPreSignedUrls => false; + + private byte[]? cacheKey32Bytes = null; + public byte[] GetCacheKey32Bytes() + { + return cacheKey32Bytes ??= this.GetCacheKey32BytesUncached(); + } + + public ValueTask RouteDependenciesAsync(IBlobRequestRouter router, CancellationToken cancellationToken = default) + { + return Tasks.ValueResult(CodeResult.Ok()); + } + + public void WriteCacheKeyBasisPairsToRecursive(IBufferWriter writer) + { + FinalRequest.WriteCacheKeyBasisPairsTo(writer); + } + + public async ValueTask CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default) + { + return new BlobResponse(await BlobFunc(FinalRequest, cancellationToken)); + } + + public ValueTask> TryGetBlobAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default) + { + return BlobFunc(FinalRequest, cancellationToken); + } +} + +public record PromiseFuncAsync(IRequestSnapshot FinalRequest, Func> Func) : IInstantPromise{ + + public bool IsCacheSupporting => false; + + public ValueTask CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default) + { + return Func(FinalRequest, cancellationToken); + } +} +public record PromiseFunc(IRequestSnapshot FinalRequest, Func Func) : IInstantPromise{ + + public bool IsCacheSupporting => false; + + public ValueTask CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default) + { + return Tasks.ValueResult(Func(FinalRequest)); + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/Pipelines/CacheEngine.cs b/src/Imazen.Routing/Promises/Pipelines/CacheEngine.cs new file mode 100644 index 00000000..13ffdb34 --- /dev/null +++ b/src/Imazen.Routing/Promises/Pipelines/CacheEngine.cs @@ -0,0 +1,426 @@ +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Imazen.Abstractions; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Concurrency; +using Imazen.Common.Concurrency.BoundedTaskCollection; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Requests; +using Microsoft.Extensions.Logging; + +namespace Imazen.Routing.Promises.Pipelines; + +/// +/// Optimized for quick startup, such as for serverless scenarios. No optimizations for multiple requests +/// +public class CacheEngine: IBlobPromisePipeline +{ + + public CacheEngine(IBlobPromisePipeline? next, CacheEngineOptions options) + { + Options = options; + Next = next; + AllCachesCount = Options.SeriesOfCacheGroups.Sum(x => x.Count); + if (options.LockByUniqueRequest) + { + Locks = new AsyncLockProvider(); + } + } + + public async ValueTask> GetFinalPromiseAsync(ICacheableBlobPromise promise, IBlobRequestRouter router, + IBlobPromisePipeline promisePipeline, IHttpRequestStreamAdapter outerRequest, CancellationToken cancellationToken = default) + { + var wrappedPromise = promise; + if (Next != null) + { + var result = await Next.GetFinalPromiseAsync(promise, router, promisePipeline, outerRequest, cancellationToken); + if (result.IsError) return result; + wrappedPromise = result.Unwrap(); + } + // TODO: we probably don't want to blob cache blobs that are of the same latency let alone blob cache local source files.. + // But we probably always want to check the memory cache? + + + return CodeResult.Ok( + new ServerlessCachePromise(wrappedPromise.FinalRequest, wrappedPromise, this)); + } + + private IBlobPromisePipeline? Next { get; } + private AsyncLockProvider? Locks { get; } + + protected int AllCachesCount; + + public CacheEngineOptions Options { get; } + + public static IBlobCacheRequest For(ICacheableBlobPromise promise, BlobGroup blobGroup) + { + var bytes = promise.GetCacheKey32Bytes(); + var hex = bytes.ToHexLowercase(); + return new BlobCacheRequest(blobGroup, bytes, hex, false); + } + + private async Task FinishUpload(IBlobCacheRequest cacheReq, ICacheableBlobPromise promise, BlobWrapper blob, CancellationToken cancellationToken = default) + { + var cacheEventDetails = CacheEventDetails.CreateFreshResultGeneratedEvent(cacheReq, Options.BlobFactory, BlobCacheFetchFailure.OkResult(blob)); + await Task.WhenAll(Options.SaveToCaches.Select(x => x.CachePut(cacheEventDetails, cancellationToken))); + } + + public async ValueTask> Fetch(ICacheableBlobPromise promise,IBlobRequestRouter router, CancellationToken cancellationToken = default) + { + if (!promise.ReadyToWriteCacheKeyBasisData) + { + throw new InvalidOperationException("Caller should have resolved the dependencies already"); + } + IBlobCacheRequest cacheRequest = For(promise, BlobGroup.GeneratedCacheEntry); + + if (Locks != null) + { + Options.Logger.LogDebug("Waiting for lock on {CacheKeyHashString}", cacheRequest.CacheKeyHashString); + var result = await Locks.TryExecuteAsync(cacheRequest.CacheKeyHashString, + Options.LockTimeoutMs, cancellationToken, ValueTuple.Create(cacheRequest, promise), + async (v, ct) => await FetchInner(v.Item1, v.Item2, router, ct)); + + var returns = result.IsError ? CodeResult.Err(HttpStatus.ServiceUnavailable.WithAddFrom("Timeout waiting for lock")) : result.Unwrap(); + Options.Logger.LogDebug("Lock on {CacheKeyHashString} released", cacheRequest.CacheKeyHashString); + if (returns.IsError) Options.Logger.LogDebug("Error fetching {CacheKeyHashString}: {Error}", cacheRequest.CacheKeyHashString, returns); + return returns; + } + else + { + return await FetchInner(cacheRequest, promise, router, cancellationToken); + } + } + + + + public async ValueTask> FetchInner(IBlobCacheRequest cacheRequest, ICacheableBlobPromise promise, IBlobRequestRouter router, CancellationToken cancellationToken = default) + { + // First check the upload queue. + if (Options.UploadQueue?.TryGet(cacheRequest.CacheKeyHashString, out var uploadTask) == true) + { + Options.Logger.LogTrace("Located requested resource from the upload queue {CacheKeyHashString}", cacheRequest.CacheKeyHashString); + return CodeResult.Ok(uploadTask.Blob); + } + // Then check the caches + List>>? allFetchAttempts = null; + foreach (var parallelGroup in Options.SeriesOfCacheGroups) + { + if (parallelGroup.Count == 0) continue; + if (parallelGroup.Count == 1) + { + Options.Logger.LogTrace("Checking {CacheName} for {Hash}", parallelGroup[0].UniqueName, cacheRequest.CacheKeyHashString); + var task = parallelGroup[0].CacheFetch(cacheRequest, cancellationToken); + if (!(await task).TryUnwrap(out var blobWrapper)) + { + allFetchAttempts ??= new List>>(AllCachesCount); + allFetchAttempts.Add(new KeyValuePair>(parallelGroup[0], task)); + continue; + } + // If there's another group, it might be relevant + EnqueueSaveToCaches(cacheRequest, ref blobWrapper,false, parallelGroup[0], allFetchAttempts); + return CodeResult.Ok(blobWrapper); + } + else + { + // Create a way to cancel the stragglers. + var subCancel = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + // create but don't await the tasks + allFetchAttempts ??= new List>>(AllCachesCount); + var cacheFetchTasks = new List>(parallelGroup.Count); + foreach (var cache in parallelGroup) + { + Options.Logger.LogTrace("Checking {CacheName} for {Hash}", cache.NameAndClass(), cacheRequest.CacheKeyHashString); + var t = cache.CacheFetch(cacheRequest, subCancel.Token); + cacheFetchTasks.Add(t); + allFetchAttempts.Add(new KeyValuePair>(cache, t)); + } + + // wait for the first one to succeed + CacheFetchResult? firstSuccess = null; + try + { + firstSuccess = + await ConcurrencyHelpers.WhenAnyMatchesOrDefault(cacheFetchTasks, x => x.IsOk, + cancellationToken); + } + catch (TaskCanceledException) + { + // Our caller cancelled us via cancellationToken. subCancel should automatically be cancelling too. + LogFetchTaskStatus(false, null, allFetchAttempts); + throw; + } + + // cancel all cacheFetchTasks, they won't cancel if they are already done/canceled/succeeded + subCancel.Cancel(); // We don't want to wait for them to cancel. + + // if we have a success, return it + if (firstSuccess != null && firstSuccess.TryUnwrap(out var blobWrapper)) + { + EnqueueSaveToCaches(cacheRequest, ref blobWrapper, false, null, allFetchAttempts); + return CodeResult.Ok(blobWrapper); + } + } + } + + var freshResult = await promise.TryGetBlobAsync(promise.FinalRequest, router, this, cancellationToken); + if (freshResult.IsError) return freshResult; + var blob = freshResult.Unwrap(); + EnqueueSaveToCaches(cacheRequest, ref blob,true, null, allFetchAttempts); + return CodeResult.Ok(blob); + } + + protected virtual bool ShouldSaveToCache(IBlobCache cache, bool isFresh, IBlobCacheFetchFailure? cacheResponse, [NotNullWhen(true)]out IBlobCache? cacheToSaveTo) + { + cacheToSaveTo = cache; + if (cacheResponse == null) return isFresh || cache.InitialCacheCapabilities.SubscribesToExternalHits; + switch (isFresh) + { + case false when cacheResponse.NotifyOfExternalHit != null: + cacheToSaveTo = cacheResponse.NotifyOfExternalHit; + return true; + case true when cacheResponse.NotifyOfResult != null: + cacheToSaveTo = cacheResponse.NotifyOfResult; + return true; + } + return false; + } + + private void LogFetchTaskStatus(bool isFresh, + IBlobCache? cacheHit, List>>? fetchTasks) + { + if (Options.Logger.IsEnabled(LogLevel.Trace)) + { + // list all fetch tasks, and their status [miss] [pending] [HIT] + // then if we generated a fresh result + if (fetchTasks == null) + { + if (cacheHit == null) + { + Log.LogTrace("No caches were queried."); + } + else + { + Log.LogTrace("[HIT] {CacheName} (first and only cache queried)", cacheHit.UniqueName); + } + } + else + { + foreach (var pair in fetchTasks) + { + var cache = pair.Key; + var task = pair.Value; + if (task is { Status: TaskStatus.RanToCompletion }) + { + if (task.Result.TryUnwrapError(out var err)) + { + if (err.Status == HttpStatus.NotFound) + { + Log.LogTrace("[miss] Cache {CacheName} returned 404", cache.NameAndClass()); + } + else + { + Log.LogTrace("[error] Cache {CacheName} failed with {Error}", cache.NameAndClass(), err); + } + Log.LogTrace("[miss] Cache {CacheName} failed with {Error}", cache.NameAndClass(), err); + } + else + { + var attrs = task.Result.Unwrap().Attributes; +#pragma warning disable CS0618 // Type or member is obsolete + Log.LogTrace("[HIT] {CacheName} - returned {ContentType}, {ByteEstimate} bytes", cache.NameAndClass(), attrs.ContentType, attrs.BlobByteCount); +#pragma warning restore CS0618 // Type or member is obsolete + } + } + else + { + Log.LogTrace("[pending] Cache {CacheName} task status: {Status}", cache.NameAndClass(), task.Status); + } + } + } + + if (isFresh) + { + Log.LogTrace("Generated a fresh result"); + } + } + } + + /// + /// Inheritors can further track cache health and limit the candidates + /// + /// + /// + /// + /// + protected List? GetUploadCacheCandidates(bool isFresh, + ref IBlobCache? cacheHit, List>>? fetchTasks) + { + if (Log.IsEnabled(LogLevel.Trace)) + { + LogFetchTaskStatus(isFresh, cacheHit, fetchTasks); + } + + + // Create the list of caches to save to. If cancelled before completion, we default to notifying it. Otherwise, we only upload to caches that resulted with a fetch failure. + List? cachesToSaveTo = null; + foreach (var candidate in Options.SaveToCaches) + { + if (candidate == cacheHit) continue; + var fetchTask = fetchTasks?.FirstOrDefault(x => x.Key == candidate).Value; + if (fetchTask is { Status: TaskStatus.RanToCompletion }){ + if (fetchTask.Result.TryUnwrapError(out var err)) + { + if (ShouldSaveToCache(candidate, isFresh, err, out var cacheToSaveTo2)) + { + cachesToSaveTo ??= new List(Options.SaveToCaches.Count); + cachesToSaveTo.AddIfUnique(cacheToSaveTo2); + } + } + else + { + if (!isFresh && cacheHit == null) + { + cacheHit = candidate; // Determine the candidate that was hit during a parallel fetch + } + } + } + else if (ShouldSaveToCache(candidate, isFresh, null, out var cacheToSaveTo)) + { + cachesToSaveTo ??= new List(Options.SaveToCaches.Count); + cachesToSaveTo.AddIfUnique(cacheToSaveTo); + } + } + + if (Options.Logger.IsEnabled(LogLevel.Debug)) + { + // list the unique names of all caches we're saving to + if (cachesToSaveTo == null) + { + Log.LogDebug("Uploading to 0 caches"); + } + else + { + Log.LogDebug("Uploading to {Count} caches: {CacheNames}", cachesToSaveTo.Count, cachesToSaveTo.Select(x => x.UniqueName)); + } + } + + return cachesToSaveTo; + } + + private IReLogger Log => Options.Logger; + + private void EnqueueSaveToCaches(IBlobCacheRequest cacheRequest, ref IBlobWrapper blob, bool isFresh, + IBlobCache? cacheHit, List>>? fetchTasks) + { + // if (Options.UploadQueue == null) return; + + var cachesToSaveTo = GetUploadCacheCandidates(isFresh, ref cacheHit, fetchTasks); + + if (cachesToSaveTo == null || Options.UploadQueue == null) return; // Nothing to do + if (!blob.IsNativelyReusable) throw new InvalidOperationException("Blob must be natively reusable"); + + CacheEventDetails? eventDetails = null; + if (isFresh) + { + eventDetails = CacheEventDetails.CreateFreshResultGeneratedEvent(cacheRequest, Options.BlobFactory, BlobCacheFetchFailure.OkResult(blob)); + } + else if (cacheHit != null) + { + eventDetails = CacheEventDetails.CreateExternalHitEvent(cacheRequest, cacheHit, Options.BlobFactory, BlobCacheFetchFailure.OkResult(blob)); + } + else + { + //TODO: log a bug, we should be able to find cacheHit among the set + throw new InvalidOperationException(); + } + + Options.UploadQueue.Queue(new BlobTaskItem(cacheRequest.CacheKeyHashString,blob), async (taskItem, cancellationToken) => + { + var tasks = cachesToSaveTo.Select(async cache => { + var sw = Stopwatch.StartNew(); + try + { + + var result = await cache.CachePut(eventDetails, cancellationToken); + sw.Stop(); + return new PutResult(cache, eventDetails, result, null, sw.ElapsedMilliseconds); + } + catch (Exception e) + { + sw.Stop(); + return new PutResult(cache, eventDetails, null, e, sw.ElapsedMilliseconds); + } + } + ).ToArray(); + HandleUploadAnswers(await Task.WhenAll(tasks)); + }); + } + record struct PutResult(IBlobCache Cache, CacheEventDetails EventDetails, CodeResult? Result, Exception? Exception, long DurationMs); + + private void HandleUploadAnswers(PutResult[] results) + { + //TODO? +// 1. If any cache failed, log it + + } + + + + +} + +internal record ServerlessCachePromise(IRequestSnapshot FinalRequest, ICacheableBlobPromise FreshPromise, CacheEngine CacheEngine): ICacheableBlobPromise +{ + public bool IsCacheSupporting => true; + public bool HasDependencies => FreshPromise.HasDependencies; + public bool ReadyToWriteCacheKeyBasisData => FreshPromise.ReadyToWriteCacheKeyBasisData; + public bool SupportsPreSignedUrls => FreshPromise.SupportsPreSignedUrls; + + public LatencyTrackingZone? LatencyZone => null; + + private byte[]? cacheKey32Bytes = null; + public byte[] GetCacheKey32Bytes() + { + return cacheKey32Bytes ??= this.GetCacheKey32BytesUncached(); + } + + public ValueTask RouteDependenciesAsync(IBlobRequestRouter router, CancellationToken cancellationToken = default) + { + return FreshPromise.RouteDependenciesAsync(router, cancellationToken); + } + + public void WriteCacheKeyBasisPairsToRecursive(IBufferWriter writer) + { + FreshPromise.WriteCacheKeyBasisPairsToRecursive(writer); + } + + public async ValueTask CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default) + { + return new BlobResponse(await TryGetBlobAsync(request, router, pipeline, cancellationToken)); + } + + public async ValueTask> TryGetBlobAsync(IRequestSnapshot request, IBlobRequestRouter router, IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default) + { + if (!FreshPromise.ReadyToWriteCacheKeyBasisData) + { + var res = await FreshPromise.RouteDependenciesAsync(router, cancellationToken); + if (res.TryUnwrapError(out var err)) + { + // pass through 404 + if (err.StatusCode == HttpStatus.NotFound) return CodeResult.Err(err); + + // throw the others for now + throw new InvalidOperationException("Promise has dependencies but could not be routed: " + err); + } + + } + + return await CacheEngine.Fetch(FreshPromise, router, cancellationToken); + } +} + \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/Pipelines/CacheEngineOptions.cs b/src/Imazen.Routing/Promises/Pipelines/CacheEngineOptions.cs new file mode 100644 index 00000000..772253f5 --- /dev/null +++ b/src/Imazen.Routing/Promises/Pipelines/CacheEngineOptions.cs @@ -0,0 +1,45 @@ +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Logging; +using Imazen.Common.Concurrency.BoundedTaskCollection; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Promises.Pipelines; + +public record CacheEngineOptions +{ + public CacheEngineOptions(){} + public CacheEngineOptions(List simultaneousFetchAndPut, BoundedTaskCollection? taskPool, IReLogger logger) + { + SeriesOfCacheGroups = new List>(){simultaneousFetchAndPut}; + SaveToCaches = simultaneousFetchAndPut; + UploadQueue = taskPool; + Logger = logger; + + } + // Each cache group is a list of caches that can be queried in parallel + public required List> SeriesOfCacheGroups { get; init; } + + public required List SaveToCaches { get; init; } + + [Obsolete("Use the parameterized one local to the request")] + public IBlobRequestRouter? RequestRouter { get; init; } + + public required IReusableBlobFactory BlobFactory { get; init; } + + public required BoundedTaskCollection? UploadQueue { get; init; } + + public required IReLogger Logger { get; init; } + + /// + /// If true, provides the opportunity for an IBlobCache to eliminate duplicate requests and prevent thundering herd. + /// + public bool LockByUniqueRequest { get; init; } + + /// + /// How long to wait for fetching and generation of the same request by another thread. + /// + public int LockTimeoutMs { get; init; } = 2000; + + +} \ No newline at end of file diff --git a/src/Imageflow.Server/ImageJobInstrumentation.cs b/src/Imazen.Routing/Promises/Pipelines/ImageJobInstrumentation.cs similarity index 82% rename from src/Imageflow.Server/ImageJobInstrumentation.cs rename to src/Imazen.Routing/Promises/Pipelines/ImageJobInstrumentation.cs index 9e1d1d89..3bfd67e8 100644 --- a/src/Imageflow.Server/ImageJobInstrumentation.cs +++ b/src/Imazen.Routing/Promises/Pipelines/ImageJobInstrumentation.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using Imageflow.Fluent; using Imazen.Common.Instrumentation; @@ -29,9 +27,9 @@ public ImageJobInstrumentation(BuildJobResult jobResult) public long TotalTicks { get; } public long DecodeTicks { get; } public long EncodeTicks { get; } - public string SourceFileExtension { get; } - public string ImageDomain { get; set; } - public string PageDomain { get; set; } - public IEnumerable FinalCommandKeys { get; set; } + public string? SourceFileExtension { get; } + public string? ImageDomain { get; set; } + public string? PageDomain { get; set; } + public IEnumerable? FinalCommandKeys { get; set; } } } \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/Pipelines/ImagingMiddleware.cs b/src/Imazen.Routing/Promises/Pipelines/ImagingMiddleware.cs new file mode 100644 index 00000000..3719970d --- /dev/null +++ b/src/Imazen.Routing/Promises/Pipelines/ImagingMiddleware.cs @@ -0,0 +1,406 @@ +using System.Buffers; +using System.Diagnostics; +using System.Text; +using Imageflow.Fluent; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.HttpStrings; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Promises.Pipelines.Watermarking; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Requests; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.Promises.Pipelines; + +// TODO: SemaphoreSlim for concurrency limits, with self-monitoring and adjusting perhaps? +// And likely some method of backpressure prior to large allocations. We probably want a max concurrency limit and a max waiters limit +// and if the max waiters limit is hit, we can wait *prior* to fetching sources. + + +// +// if (licenseChecker.RequestNeedsEnforcementAction(request)) +// { +// if (this.legacyOptions.EnforcementMethod == EnforceLicenseWith.RedDotWatermark) +// { +// FinalQuery["watermark_red_dot"] = "true"; +// } +// LicenseError = true; +// } + +public record ImagingMiddlewareOptions +{ + public required IReLogger Logger { get; init; } + + public required IReusableBlobFactory BlobFactory { get; init; } + + public required WatermarkingLogicOptions WatermarkingLogic { get; init; } + + + internal SecurityOptions JobSecurityOptions { get; init; } = new SecurityOptions(); + + + // Concurrency limits + + // Outsourcing the job to other servers. + // it would be best if they could PUT directly to the output cache + // but this middleware has no knowledge of that. + // and sometimes they might reply directly with the image result + + // we also have preview versions to consider + + // and watermarking handlers. + + // and chained versions, like intermediate conversions etc. +} + + +public record ImagingMiddleware(IBlobPromisePipeline? Next, ImagingMiddlewareOptions Options) : IBlobPromisePipeline +{ + + public async ValueTask> GetFinalPromiseAsync(ICacheableBlobPromise promise, IBlobRequestRouter router, + IBlobPromisePipeline promisePipeline, IHttpRequestStreamAdapter outerRequest, CancellationToken cancellationToken = default) + { + var wrappedPromise = promise; + if (Next != null) + { + var result = await Next.GetFinalPromiseAsync(promise, router, promisePipeline, outerRequest, cancellationToken); + if (result.IsError) return result; + wrappedPromise = result.Unwrap(); + } + + // Check for watermarking work to do + var appliedWatermarks = Options.WatermarkingLogic.GetAppliedWatermarks(promise.FinalRequest); + if (appliedWatermarks?.Count == 0) + { + // We need to route the watermarking dependencies + appliedWatermarks = null; + } + + // Filter to the supported querystring keys + Dictionary? commandDict = null; + var dict = promise.FinalRequest.QueryString; + if (dict != null && dict.Count > 0) + { + foreach (var supportedKey in PathHelpers.SupportedQuerystringKeys) + { + if (!dict.TryGetValue(supportedKey, out var value)) continue; + commandDict ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + commandDict[supportedKey] = value; + } + } + + if (commandDict == null && appliedWatermarks == null) + { + // No image processing to do... + return CodeResult.Ok(wrappedPromise); + } + var finalCommandString = commandDict == null ? "" : QueryHelpers.AddQueryString("", commandDict); + return CodeResult.Ok( + new ImagingPromise(Options,wrappedPromise, router, promisePipeline, appliedWatermarks, finalCommandString, outerRequest)); + } +} + + +internal record ImagingPromise : ICacheableBlobPromise +{ + public ImagingPromise(ImagingMiddlewareOptions options, + ICacheableBlobPromise sourcePromise, IBlobRequestRouter router, + IBlobPromisePipeline promisePipeline, IList? appliedWatermarks, + string finalCommandString, IHttpRequestStreamAdapter outerRequest) + + { + Input0 = sourcePromise; + RequestRouter = router; + PromisePipeline = promisePipeline; + Options = options; + AppliedWatermarks = appliedWatermarks; + FinalCommandString = finalCommandString.TrimStart('?'); + + // determine if we have extra dependencies + + if (AppliedWatermarks != null) + { + ExtraDependencyRequests = new List(AppliedWatermarks.Count); + foreach (var watermark in AppliedWatermarks) + { + //TODO: flaw in the system, we can't get the original request adapter from the snapshot! + // should never be null + var request = MutableRequest.ChildRequest(outerRequest, sourcePromise.FinalRequest, + watermark.VirtualPath, "GET"); + ExtraDependencyRequests.Add(request); + } + } + if (ExtraDependencyRequests == null) + { + Dependencies = new List(){Input0}; + } + + + } + private byte[]? cacheKey32Bytes = null; + public byte[] GetCacheKey32Bytes() + { + return cacheKey32Bytes ??= this.GetCacheKey32BytesUncached(); + } + + private LatencyTrackingZone? latencyZone = null; + /// + /// Must route dependencies first! + /// + public LatencyTrackingZone? LatencyZone { + get + { + if (!ReadyToWriteCacheKeyBasisData) throw new InvalidOperationException("Dependencies must be routed first"); + // produce a latency zone based on all dependency strings, joined, plus the sum of their latency defaults + if (latencyZone != null) return latencyZone; + var latency = 0; + var sb = new StringBuilder(); + sb.Append("imageJob("); + foreach (var dependency in Dependencies!) + { + latency += dependency.LatencyZone?.DefaultMs ?? 0; + sb.Append(dependency.LatencyZone?.TrackingZone ?? "(unknown)"); + } + sb.Append(")"); + latencyZone = new LatencyTrackingZone(sb.ToString(), latency, true); + return latencyZone; + } + } + + + private string FinalCommandString { get; init; } + + public async ValueTask> TryGetBlobAsync(IRequestSnapshot request, + IBlobRequestRouter router, + IBlobPromisePipeline pipeline, CancellationToken cancellationToken = default) + { + var sw = Stopwatch.StartNew(); + if (Dependencies == null) throw new InvalidOperationException("Dependencies must be routed first"); + + var toDispose = new List(Dependencies.Count); + try{ + // fetch all dependencies in parallel, but avoid allocating if there's only one. + List> dependencyResults = new List>(Dependencies.Count); + if (Dependencies.Count == 1) + { + var fetch1 = await Dependencies[0] + .TryGetBlobAsync(Dependencies[0].FinalRequest, router, pipeline, cancellationToken) + .ConfigureAwait(false); + dependencyResults.Add(fetch1); + if (fetch1.TryUnwrap(out var unwrapped)) + { + toDispose.Add(unwrapped); + } + } + else + { + // TODO: work on exception bubbling + var fetchTasks = new Task>[Dependencies.Count]; + for (var i = 0; i < Dependencies.Count; i++) + { + fetchTasks[i] = Dependencies[i] + .TryGetBlobAsync(Dependencies[i].FinalRequest, router, pipeline, cancellationToken).AsTask(); + } + + try + { + await Task.WhenAll(fetchTasks); + } + finally + { + // Collect everything that needs to be disposed + // Some may have completed and have pending connections or unmanaged resources + // So if there was an error we need to dispose whatever is still open + foreach (var task in fetchTasks) + { + if (task.Status != TaskStatus.RanToCompletion) continue; + if (task.Result.TryUnwrap(out var unwrapped)) + { + toDispose.Add(unwrapped); + } + } + } + foreach (var task in fetchTasks) + { + dependencyResults.Add(task.Result); + } + } + // Check for errors and create the sources + + var byteSources = new List(dependencyResults.Count); + foreach (var result in dependencyResults) + { + // TODO: aggregate them! + // and maybe specify it was a watermark or a source image + if (result.TryUnwrap(out var unwrapped)) + { + var consumable = unwrapped.MakeOrTakeConsumable(); + toDispose.Add(consumable); // Dispose the consumable + var source = new StreamSource(consumable.BorrowStream(DisposalPromise.CallerDisposesBlobOnly), false); + byteSources.Add(source); + } + else + { + return CodeResult.Err(result.UnwrapError()); + } + } + + List? watermarks = null; + if (AppliedWatermarks != null) + { + watermarks = new List(AppliedWatermarks.Count); + watermarks.AddRange( + AppliedWatermarks.Select((t, i) + => new InputWatermark( + byteSources[i + 1], + t.Watermark))); + } + + + using var buildJob = new ImageJob(); + var jobResult = + await (watermarks == null ? buildJob.BuildCommandString( + byteSources[0], + new BytesDestination(), FinalCommandString) + : buildJob.BuildCommandString( + byteSources[0], + new BytesDestination(), FinalCommandString, watermarks)) + + .Finish() + .SetSecurityOptions(Options.JobSecurityOptions) + .InProcessAsync(); + + + // TODO: restore instrumentation + // GlobalPerf.Singleton.JobComplete(new ImageJobInstrumentation(jobResult) + // { + // FinalCommandKeys = FinalQuery.Keys, + // ImageDomain = ImageDomain, + // PageDomain = PageDomain + // }); + + // TryGetBytes returns the buffer from a regular MemoryStream, not a recycled one + var encodeResult = jobResult.First ?? throw new InvalidOperationException("Image job did not return a resulting image (no encode result)"); + var resultBytes = encodeResult.TryGetBytes(); + if (!resultBytes.HasValue || resultBytes.Value.Count < 1 || resultBytes.Value.Array == null) + { + throw new InvalidOperationException("Image job returned zero bytes."); + } + + var attrs = new BlobAttributes() + { + LastModifiedDateUtc = DateTime.UtcNow, + ContentType = encodeResult.PreferredMimeType, + BlobByteCount = resultBytes.Value.Count + }; + sw.Stop(); + var reusable = new ReusableArraySegmentBlob(resultBytes.Value, attrs, sw.Elapsed); + return CodeResult.Ok(new BlobWrapper(LatencyZone, reusable)); + } + finally + { + foreach (var b in toDispose) + { + b?.Dispose(); + } + } + + } + + private IList? AppliedWatermarks { get; init; } + + private ImagingMiddlewareOptions Options { get; init; } + private IBlobRequestRouter RequestRouter { get; init; } + private IBlobPromisePipeline PromisePipeline { get; init; } + private ICacheableBlobPromise Input0 { get; init; } + + private List? ExtraDependencyRequests { get; init; } + private List? Dependencies { get; set; } + + + + + public bool IsCacheSupporting => Input0.IsCacheSupporting; + public IRequestSnapshot FinalRequest => Input0.FinalRequest; + + public async ValueTask CreateResponseAsync(IRequestSnapshot request, IBlobRequestRouter router, + IBlobPromisePipeline pipeline, + CancellationToken cancellationToken = default) + { + return new BlobResponse(await TryGetBlobAsync(request, router, pipeline, cancellationToken)); + } + + public bool SupportsPreSignedUrls => false; // not yet + + public bool HasDependencies => + Input0.HasDependencies || ExtraDependencyRequests == null || ExtraDependencyRequests?.Count > 0; + + public bool ReadyToWriteCacheKeyBasisData => Dependencies != null; + + public async ValueTask RouteDependenciesAsync(IBlobRequestRouter router, + CancellationToken cancellationToken = default) + { + if (Dependencies != null) + { + return CodeResult.Ok(); + } + + Dependencies = new List((ExtraDependencyRequests?.Count ?? 0) + 1) { Input0 }; + if (ExtraDependencyRequests != null) + { + HttpStatus aggregate = default; + foreach (var request in ExtraDependencyRequests) + { + // This needs to be robust, actually, it happens a lot. + var result = await router.RouteToPromiseAsync(request, cancellationToken) + ?? CodeResult.Err((404, $"Could not find dependency {request}")); + if (result.TryUnwrap(out var unwrapped)) + { + Dependencies.Add(unwrapped); + } + else + { + // Pass through if there's only one error, otherwise aggregate them into a single HttpStatus + var error = result.UnwrapError(); + aggregate = aggregate == default(HttpStatus) ? error : aggregate.WithAppend($" and {error}"); + } + } + + if (aggregate != default(HttpStatus)) + { + return CodeResult.Err(aggregate); + } + } + + // Now call recursively on each dependency + foreach (var dependency in Dependencies) + { + if (!dependency.HasDependencies) continue; + var result = await dependency.RouteDependenciesAsync(router, cancellationToken); + if (result.IsError) return result; + } + + return CodeResult.Ok(); + } + + public void WriteCacheKeyBasisPairsToRecursive(IBufferWriter writer) + { + if (Dependencies == null) throw new InvalidOperationException("Dependencies must be routed first"); + foreach (var dependency in Dependencies) + { + dependency.WriteCacheKeyBasisPairsToRecursive(writer); + } + + writer.WriteWtf(FinalCommandString); + + // Write watermarking cache key basis + WatermarkingLogicOptions.WriteWatermarkingCacheKeyBasis(AppliedWatermarks, writer); + } + + + public bool TryGeneratePreSignedUrl(IRequestSnapshot request, out string? url) => + throw new NotImplementedException(); + +} \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/Pipelines/PerformanceDetailsExtensions.cs b/src/Imazen.Routing/Promises/Pipelines/PerformanceDetailsExtensions.cs new file mode 100644 index 00000000..fd411fb7 --- /dev/null +++ b/src/Imazen.Routing/Promises/Pipelines/PerformanceDetailsExtensions.cs @@ -0,0 +1,31 @@ +using Imageflow.Fluent; + +namespace Imageflow.Server; + +internal static class PerformanceDetailsExtensions{ + + private static long GetWallMicroseconds(this PerformanceDetails d, Func nodeFilter) + { + long totalMicroseconds = 0; + foreach (var frame in d.Frames) + { + foreach (var node in frame.Nodes.Where(n => nodeFilter(n.Name))) + { + totalMicroseconds += node.WallMicroseconds; + } + } + + return totalMicroseconds; + } + + + + public static long GetTotalWallTicks(this PerformanceDetails d) => + d.GetWallMicroseconds(n => true) * TimeSpan.TicksPerSecond / 1000000; + + public static long GetEncodeWallTicks(this PerformanceDetails d) => + d.GetWallMicroseconds(n => n == "primitive_encoder") * TimeSpan.TicksPerSecond / 1000000; + + public static long GetDecodeWallTicks(this PerformanceDetails d) => + d.GetWallMicroseconds(n => n == "primitive_decoder") * TimeSpan.TicksPerSecond / 1000000; +} \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/Pipelines/PipelineBuilder.cs b/src/Imazen.Routing/Promises/Pipelines/PipelineBuilder.cs new file mode 100644 index 00000000..458e7f0d --- /dev/null +++ b/src/Imazen.Routing/Promises/Pipelines/PipelineBuilder.cs @@ -0,0 +1,26 @@ +namespace Imazen.Routing.Promises.Pipelines; + +public class PipelineBuilder +{ + // var sourceCacheOptions = new ServerlessCacheEngineOptions + // { + // SeriesOfCacheGroups = null, + // SaveToCaches = null, + // RequestRouter = routingEngine, + // BlobFactory = blobFactory, + // UploadQueue = null, + // Logger = logger + // }; + // var imagingOptions = new ImagingMiddlewareOptions + // { + // Logger = logger, + // BlobFactory = blobFactory, + // //TODO + // WatermarkingLogic = new WatermarkingLogicOptions(null, null) + // }; + // var outputCacheOptions = sourceCacheOptions; + // + // pipeline = new ServerlessCacheEngine(null, sourceCacheOptions); + // pipeline = new ImagingMiddleware(pipeline,imagingOptions); + // pipeline = new ServerlessCacheEngine(pipeline, outputCacheOptions); +} \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/Pipelines/Watermarking/WatermarkWithPath.cs b/src/Imazen.Routing/Promises/Pipelines/Watermarking/WatermarkWithPath.cs new file mode 100644 index 00000000..98295998 --- /dev/null +++ b/src/Imazen.Routing/Promises/Pipelines/Watermarking/WatermarkWithPath.cs @@ -0,0 +1,19 @@ +using Imageflow.Fluent; + +namespace Imazen.Routing.Promises.Pipelines.Watermarking; + +public interface IWatermark +{ + string? Name { get; } + string VirtualPath { get; } + WatermarkOptions Watermark { get; } +} + +public record WatermarkWithPath(string? Name, string VirtualPath, WatermarkOptions Watermark) + : IWatermark +{ + public static WatermarkWithPath FromIWatermark(IWatermark watermark) + { + return new WatermarkWithPath(watermark.Name, watermark.VirtualPath, watermark.Watermark); + } +} diff --git a/src/Imazen.Routing/Promises/Pipelines/Watermarking/WatermarkingLogicOptions.cs b/src/Imazen.Routing/Promises/Pipelines/Watermarking/WatermarkingLogicOptions.cs new file mode 100644 index 00000000..fb64e93b --- /dev/null +++ b/src/Imazen.Routing/Promises/Pipelines/Watermarking/WatermarkingLogicOptions.cs @@ -0,0 +1,96 @@ +using System.Buffers; +using Imageflow.Fluent; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Requests; + +namespace Imazen.Routing.Promises.Pipelines.Watermarking; + + + + +public record WatermarkingLogicOptions(Func? LookupWatermark, Func?, IList?>? MutateWatermarks) +{ + + + public IList? GetAppliedWatermarks(IRequestSnapshot request) + { + IList? appliedWatermarks = null; + if (request.QueryString?.TryGetValue("watermark", out var watermarkValues) == true) + { + foreach (var name in watermarkValues.Select(s => s?.Trim(' '))) + { + if (string.IsNullOrEmpty(name)) continue; + if (LookupWatermark == null) + { + throw new InvalidOperationException( + $"watermark {name} was referenced from the querystring but no watermarks were registered"); + } + + var watermark = LookupWatermark(name!); + if (watermark == null) + { + throw new InvalidOperationException( + $"watermark {name} was referenced from the querystring but no watermark by that name was found"); + } + + appliedWatermarks ??= []; + appliedWatermarks.Add(watermark); + } + } + + // After we've populated the defaults, run the event handlers for custom watermarking logic + + if (MutateWatermarks != null) + { + appliedWatermarks = MutateWatermarks(request, appliedWatermarks); + } + + return appliedWatermarks; + } + + // write cache key basis for watermarking + internal static void WriteWatermarkingCacheKeyBasis(IList? appliedWatermarks, IBufferWriter writer) + { + if (appliedWatermarks == null) return; + foreach (var watermark in appliedWatermarks) + { + writer.WriteWtf(watermark.Name ?? "(null)"); + writer.WriteWtf(watermark.VirtualPath ?? "(null)"); + writer.WriteWtf(watermark.Watermark.Gravity?.XPercent); + writer.WriteWtf(watermark.Watermark.Gravity?.YPercent); + switch (watermark.Watermark.FitBox) + { + case WatermarkMargins margins: + writer.WriteWtf("margins"); + writer.WriteWtf(margins.Left); + writer.WriteWtf(margins.Top); + writer.WriteWtf(margins.Right); + writer.WriteWtf(margins.Bottom); + break; + case WatermarkFitBox fitBox: + writer.WriteWtf("fit-box"); + writer.WriteWtf(fitBox.X1); + writer.WriteWtf(fitBox.Y1); + writer.WriteWtf(fitBox.X2); + writer.WriteWtf(fitBox.Y2); + break; + case null: + writer.WriteWtf("(null)"); + break; + default: + throw new InvalidOperationException("Unknown watermark fit box type"); + } + writer.WriteWtf(watermark.Watermark.FitMode); + writer.WriteWtf(watermark.Watermark.MinCanvasWidth); + writer.WriteWtf(watermark.Watermark.MinCanvasHeight); + writer.WriteWtf(watermark.Watermark.Opacity); + writer.WriteWtf(watermark.Watermark.Hints?.DownFilter); + writer.WriteWtf(watermark.Watermark.Hints?.UpFilter); + writer.WriteWtf(watermark.Watermark.Hints?.InterpolationColorspace); + writer.WriteWtf(watermark.Watermark.Hints?.ResampleWhen); + writer.WriteWtf(watermark.Watermark.Hints?.SharpenPercent); + writer.WriteWtf(watermark.Watermark.Hints?.SharpenWhen); + } + } + +} \ No newline at end of file diff --git a/src/Imazen.Routing/Promises/Pipelines/cache_design.md b/src/Imazen.Routing/Promises/Pipelines/cache_design.md new file mode 100644 index 00000000..93be229f --- /dev/null +++ b/src/Imazen.Routing/Promises/Pipelines/cache_design.md @@ -0,0 +1,148 @@ +# Other issues +ReLogStore diagnostic section report +Multi-tenant support across shared IBlobCache - blob prefixes. +Ensure clarity in whether routes will only handle image extension requests, +extensionless, or +arbitrary or +certain. CORS and mime type definitions are needed. +Diagnostics: check nuget for latest version of imageflow* +Skip signing requirement for dependencies, like watermarks? +Create IExternalService interface for Lambda/Functions/Replicate/other Imageflow JSON job builder +Establish /build.job json endpoint + +Establish IBlobStore interface, or convert IBlobCache interface into IBlobStore. It should support 4 use cases at minimum +1. Get/put blobs at a given path for user use, and list blobs within a given path (s3 directory vs bucket may add considerations) +2. Support the functions needed for IBlobCache +3. Get/put blobs for usage as a job status tracker (such as if the request has been queued with a third party, what the status is, and where the results are at) +4. Pre-signed URLs for upload/download are essential +5. Manage underlying path length and character limitations with encoding/and or hashing as needed; IBlobStore defines the limits and our system works around them. +Establish disk, s3, and azure implementations of IBlobStore. +All disk code should be aware that it could be on-network and be high-latency friendly. + + +# ImagingMiddleware design + +## TODO: Intermediate variant chaining +where we have a dependency on an intermediate variant. We could resolve the promise, determine its existence probability, +and then decide if we want to use it as input. If we do, we can use that promise as input instead. If not, we can use the original input. +We need to spawn a job if it is missing, however, so future calls can use it. + +## TODO: header->query rewriting: Accept-Type: image/webp;q=0.9,image/jpeg;q=0.8,image/png;q=0.7,image/*;q=0.6,*/*;q=0.5 + +# CacheEngine design + + +## TODO: add ability to mark some requests as top-priority for in-memory (such as watermarks) +## TODO: establish both serverless-optimized and long-running optimized CacheEngineOptions variants + +## TODO: Permacaching mode, where nothing gets invalidated? + + +## Test cache searching, purging, and deletion, and auditing everywhere something is stored + +## ExistenceProbability could be reported instantly with the promise... + +## Allow phasing in global invalidations / config changes + + +## Optimzizing the fetch/put sets (other than just caching everything with AlwaysShield=true) + // TODO: track and report fetch times for each cache + // Do a bit of randomization within capabilities so we don't avoid collecting data. For these randoms, let all fetch results complete. + // We also let the first (n) cache fetches complete when starting up, so we have data. + // We always ignore the first result from any Zone + + // We can make decisions based on the fresh promise zone VS the cache zone + // BlobWrapper returns the cache zone, the created date, and (when made reusable) the duration of the buffering process. + // But we need to externally track which IBlobCache instances return which BlobWrappers to properly gather intel + + // All middleware consumes BlobWrappers, so every middleware will need to instrument it.. but since all IBlobCache + // interaction is here, only we need to handle IBlobCache mapping... + // Theoretically multiple caches could use the same zone/bucket. So a single zone could have multiple IBlobCaches + // which is fine since we just want to know the zone perf for each IBlobCache + + + // We need to gather monitored TTFB/TTLB/byte data for each cache. + // When we have a fresh result, if it's from a blob provider, we can monitor that data too + // If it is from 'imaging', that middleware needs to monitor the blob results... + // Choose a cache strategy based on promise latency vs cache latency + + // And of course, if a promise has AlwaysShield=true, we cache it regardless. + // TODO: see what we can steal from instrumentation in licensing for sampling & stats + + +CacheEngine can be configured for serverless or long-running use. +In serverless mode, we rely more heavily on defaults and less on monitoring-based tuning. + + + +============ Old stuff + + +TrustedKeyGroups can allow public/private signing so that the public keys can be deployed to the edge, and the private keys can be used to sign requests. + + +### Put hard logic in BlobCacheExecutor + +* AsyncWriteQueue +* MemoryCache +* RecentlyCachedHashes (tracking where we've recently cached stuff) +* ExistenceProbability (tracking how likely a cache is to have a file) +* CircuitBreaker (tracking if a cache is failing to read or write) +* Parallel cache requests. +* CircuitBreaker failures may mean initializing a new cache (such as a hybrid cache to a backup location) and adding it to the chain. +* + +We are abandoning the callback system, because it limits us and makes caches complex. +We are obsoleting StreamCache. + +## Some paths + +* A cache is hit, but a later cache doesn't have a chance at the request. It needs an ExternalHit notificaiton +* A cache experiences a miss, but another cache has a hit. ExternalHitYouMissed +* A cache experiences a miss, and no other cache has a hit. ExternalMiss +* ExternalMiss and successful generation of the image. ResultGenerated +* ExternalMiss and failure to generate the image. ResultFailed +* Proxied request. ResultProxiedSuccessfully + + +----- +What we need to do is switch away from the callback system. While chaining is an option, what's really happening with callbacks is enabling locking for work deduplication. + +When a cache has a miss, it also returns a request to be notified of when data is available for that request. Caches get notifications when data is generated (or located in another cache) for something they missed - but also for when a request is hit via another cache earlier in the chain. + +If all caches miss, the engine locks on the image generation process. +When we succeed through the lock, we check the async write queue (which will now be shared among all caches). If it's a hit there, then we can respond using that reusable stream to serve the data. + +If it's not in the async write queue, we could either (a) current behavior for hybridcache - check the cache storage again) or (b) check a 'recently cached hashes log which is reset every few minutes to determine if we should re-run the cache fetch, or (c) not check caches, just generate the image. (c) assumes that writes will take enough time to complete that the images will always stay in the queue a little longer (and we could sleep 50ms or something to help that), or (d) check the write queue, then check the recent puts log, if present, then fetch from cache. + + +I think this approach, a central write queue and external cache coordination, offers the most flexibility and is the way to go, even though it means breaking the streamcache API. + +Now, for requests that do not involve any file processing - proxy requests. For these, creating a reusable stream involves extra costs and we want to avoid it. To reduce proxy latency, we should copy to the output stream simultaneously with a memory stream (maybe?) and then enqueue that write as normal. + +We also want to support a no-cache scenario efficiently - why s3 cache s3 files, right? But even then, some buffering is probably good + +We probably want a generalized memory cache, bounded, possibly not eagerly evicted, and one that can clear entries that failed their tasks or timed out. We want some insight into it as well, such as turnover and cache hit rate. + +To reduce fragmentation, we probably want a memory chunk provider that can reuse blocks. We would have to refcount reader streams before releasing, however. + +Dual framework imazen.common perhaps. + +We should probably utilize circuit breaker on cache reads and writes. And take latency stats for fetches and writes, perhaps to determine cache order? + +For hybridcache, we can add a look-in-alt-cache directories setting, or just circuit breaker cache writes independently from cache reads, and just create (n) caches. We could lockfile the whole write side of the cache too, and leave that memory index offline for it. + + +Fetches, writes, and tag searches should all return location references for audit/logging + +And for cache fetches, we probably want *some* caches to fetch in parallel and take whomever comes back first. + +So instead of a chain, we want a list of groups? + + + + + + + + + + + diff --git a/src/Imazen.Routing/Requests/BlobResponse.cs b/src/Imazen.Routing/Requests/BlobResponse.cs new file mode 100644 index 00000000..3f13e069 --- /dev/null +++ b/src/Imazen.Routing/Requests/BlobResponse.cs @@ -0,0 +1,32 @@ +using System.Collections.Immutable; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; +using Imazen.Routing.HttpAbstractions; + +namespace Imazen.Routing.Requests; + +public record class BlobResponse(CodeResult BlobResult) : IAdaptableHttpResponse +{ + public string? ContentType => BlobResult.Value?.Attributes.ContentType ?? "text/plain; charset=utf-8"; + public int StatusCode => BlobResult.IsError ? BlobResult.Error.StatusCode : 200; + public IImmutableDictionary? OtherHeaders { get; init; } //TODO: for errors, no-store and xrobots, for success, + // blob attributes + public async ValueTask WriteAsync(TResponse target, CancellationToken cancellationToken = default) where TResponse : IHttpResponseStreamAdapter + { + //TODO: optimize for pipelines? + + if (BlobResult.IsError) + { + target.WriteUtf8String(BlobResult.Error.ToString()); + } + else + { + using var blob = BlobResult.Value!.MakeOrTakeConsumable(); + await target.WriteBlobWrapperBody(blob, cancellationToken); + } + + } + + public IAdaptableHttpResponse AsResponse() => this; +} + diff --git a/src/Imazen.Routing/Requests/IBlobRequestRouter.cs b/src/Imazen.Routing/Requests/IBlobRequestRouter.cs new file mode 100644 index 00000000..855f5aed --- /dev/null +++ b/src/Imazen.Routing/Requests/IBlobRequestRouter.cs @@ -0,0 +1,9 @@ +using Imazen.Abstractions.Resulting; +using Imazen.Routing.Promises; + +namespace Imazen.Routing.Requests; + +public interface IBlobRequestRouter +{ + ValueTask?> RouteToPromiseAsync(MutableRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Imazen.Routing/Requests/IRequestSnapshot.cs b/src/Imazen.Routing/Requests/IRequestSnapshot.cs new file mode 100644 index 00000000..037045a5 --- /dev/null +++ b/src/Imazen.Routing/Requests/IRequestSnapshot.cs @@ -0,0 +1,100 @@ +using System.Buffers; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.HttpStrings; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Promises; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.Requests; + +/// +/// This intentionally does not include a querystring - +/// Vary-By data should be placed into RouteValues and AddCacheBasisItemsTo() should normalize the cache data into a StringBuilder. +/// +public interface IGetResourceSnapshot : IHasCacheKeyBasis +{ + /// + /// If all required mutations have been applied to the resource, (such as rewriting, parsing of routing values, adding querystring defaults, etc) + /// + bool MutationsComplete { get; } + + /// + /// Gets the rewritten path to the resource. + /// + /// + /// A representing the rewritten path. + /// + string Path { get; } + + + /// + /// Key/value pairs parsed from the Imageflow routing system. Often 'container' and 'key' are present. + /// If host values or CDN forwarded headers need to be considered, then the should be used to extract + /// and store that data here. + /// + /// + // TODO: This is mutable! this is an immutable interface! + IDictionary? ExtractedData { get; } // TODO: we need to be able to extract data that doesn't factor into the cache key, so we probably need another dict. + // And maybe we should keep resource data separate from processing commands? + + + /// + /// Tag pairs specified here will be stored alongside cached copies and can be searched/purcged by matching pairs. + /// + ICollection? StorageTags { get; } + +} + +public interface IRequestSnapshot : IGetResourceSnapshot +{ + + public IHttpRequestStreamAdapter? OriginatingRequest { get; } + + string HttpMethod { get; } + + /// + /// When merging multiple images (such as applying a watermark), the parent request is the original request. + /// Only the request for the watermark file will have a parent. + /// + IRequestSnapshot? ParentRequest { get; } + + /// + /// Typically, this should only be used for image processing commands, and shouldn't indicate the primary resource in any way. + /// + IDictionary? QueryString { get; } + +} +public record RequestSnapshot(bool MutationsComplete) : IRequestSnapshot{ + + public IHttpRequestStreamAdapter? OriginatingRequest { get; init; } + public required string Path { get; init; } + public IDictionary? QueryString { get; init;} + public IDictionary? ExtractedData { get; init;} + public ICollection? StorageTags { get; init;} + public IRequestSnapshot? ParentRequest { get; init; } + + public required string HttpMethod { get; init; } = HttpMethodStrings.Get; + public void WriteCacheKeyBasisPairsTo(IBufferWriter writer) + { + if (!MutationsComplete) throw new InvalidOperationException("Mutations must be complete before writing cache key basis pairs"); + + // TODO: write all pairs using memcasting and wtf16 + } + + public override string ToString() + { + return MutableRequest.RequestToString(this); + } +} + + + +public static class RequestSnapshotExtensions +{ + public static bool IsGet(this IRequestSnapshot request) + { + return request.HttpMethod == HttpMethodStrings.Get; + } + + // Write +} \ No newline at end of file diff --git a/src/Imazen.Routing/Requests/MutableRequest.cs b/src/Imazen.Routing/Requests/MutableRequest.cs new file mode 100644 index 00000000..0153f64f --- /dev/null +++ b/src/Imazen.Routing/Requests/MutableRequest.cs @@ -0,0 +1,164 @@ +using System.Buffers; +using System.Text; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.HttpStrings; +using Imazen.Routing.HttpAbstractions; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.Requests; + +/// +/// Mutable request object for routing layers to manipulate. +/// +public class MutableRequest : IRequestSnapshot +{ + private MutableRequest(IHttpRequestStreamAdapter originatingRequest, bool isChildRequest, string? childRequestUri, IRequestSnapshot? parent) + { + OriginatingRequest = originatingRequest; + IsChildRequest = isChildRequest; + ChildRequestUri = childRequestUri; + ParentRequest = parent; + MutablePath = originatingRequest.GetPath(); +#if DOTNET5_0_OR_GREATER + MutableQueryString = new Dictionary(originatingRequest.GetQuery(), StringComparer.OrdinalIgnoreCase); +#else + MutableQueryString = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kv in originatingRequest.GetQuery()) + { + MutableQueryString.Add(kv.Key, kv.Value); + } +#endif + } + + + public static MutableRequest OriginalRequest(IHttpRequestStreamAdapter originalRequest) + { + return new MutableRequest(originalRequest, false, null, null) + { + HttpMethod = originalRequest.Method, + + }; + + } + public static MutableRequest ChildRequest(IHttpRequestStreamAdapter originalRequest, IRequestSnapshot parent, string childRequestUri, string httpMethod) + { + return new MutableRequest(originalRequest, true, childRequestUri, parent){ + HttpMethod = httpMethod, + MutablePath = childRequestUri + }; + } + public IHttpRequestStreamAdapter? OriginatingRequest { get; } + + public IHttpRequestStreamAdapter UnwrapOriginatingRequest() + { + if (OriginatingRequest == null) + { + throw new InvalidOperationException("OriginatingRequest is required, but was null"); + } + return OriginatingRequest; + } + public required string HttpMethod { get; set; } + + public bool IsChildRequest { get; } + public string? ChildRequestUri { get; } + public IRequestSnapshot? ParentRequest { get; } + public IDictionary? QueryString => MutableQueryString; + + public string MutablePath { get; set; } + public IDictionary MutableQueryString { get; set; } + + + private DictionaryQueryWrapper? finalQuery; + + public IReadOnlyQueryWrapper ReadOnlyQueryWrapper + { + get + { + if (finalQuery == null || !object.ReferenceEquals(finalQuery.UnderlyingDictionary, MutableQueryString)) + { + finalQuery = new DictionaryQueryWrapper(MutableQueryString); + } + return finalQuery; + } + } + + public bool MutationsComplete { get; } + public string Path => MutablePath; + public IDictionary? ExtractedData { get; set; } + public ICollection? StorageTags { get; set; } + + public IRequestSnapshot ToSnapshot(bool mutationsComplete) + { + return new RequestSnapshot(mutationsComplete){ + Path = MutablePath, + QueryString = MutableQueryString, + ExtractedData = ExtractedData, + StorageTags = StorageTags, + ParentRequest = ParentRequest, + HttpMethod = HttpMethod, + MutationsComplete = mutationsComplete + }; + } + + public void WriteCacheKeyBasisPairsTo(IBufferWriter writer) + { + writer.WriteWtf16String(this.HttpMethod); + writer.WriteWtf16PathAndRouteValuesCacheKeyBasis(this); + } + + internal static void PrintDictionary(StringBuilder sb, IEnumerable> dict) + { + sb.Append("{"); + foreach (var kv in dict) + { + sb.Append(kv.Key); + sb.Append("="); + sb.Append(kv.Value); + sb.Append(","); + } + sb.Append("}"); + } + + internal static string RequestToString(IRequestSnapshot request) + { + var sb = new StringBuilder(); + sb.Append(request.HttpMethod); + sb.Append(" "); + sb.Append(request.QueryString == null ? request.Path : QueryHelpers.AddQueryString(request.Path, request.QueryString)); + // now add ExtractedData, StorageTags, ParentRequest, and MutationsComplete + // in the form {Name='values', Name2='values2'} + sb.Append(" {"); + if (request.ExtractedData != null) + { + PrintDictionary(sb, request.ExtractedData); + sb.Append(", "); + } + if (request.StorageTags != null) + { + PrintDictionary(sb, request.StorageTags.Select(t => t.ToKeyValuePair())); + sb.Append(", "); + } + if (request.ParentRequest != null) + { + sb.Append("ParentRequest: "); + sb.Append(request.ParentRequest); + sb.Append(", "); + } + if (request.OriginatingRequest != null) + { + sb.Append("OriginatingRequest: "); + sb.Append(request.OriginatingRequest.ToShortString()); + sb.Append(", "); + } + sb.Append("MutationsComplete: "); + sb.Append(request.MutationsComplete); + sb.Append("}"); + return sb.ToString(); + } + + public override string ToString() + { + return RequestToString(this); + } +} + diff --git a/src/Imazen.Routing/Serving/IImageServer.cs b/src/Imazen.Routing/Serving/IImageServer.cs new file mode 100644 index 00000000..3636abf7 --- /dev/null +++ b/src/Imazen.Routing/Serving/IImageServer.cs @@ -0,0 +1,20 @@ +using Imazen.Routing.HttpAbstractions; + +namespace Imazen.Routing.Serving; + +public interface IHasDiagnosticPageSection +{ + string? GetDiagnosticsPageSection(DiagnosticsPageArea section); +} + +public enum DiagnosticsPageArea +{ + Start, + End +} + +public interface IImageServer: IHasDiagnosticPageSection where TRequest : IHttpRequestStreamAdapter where TResponse : IHttpResponseStreamAdapter +{ + bool MightHandleRequest(string? path, TQ query, TContext context) where TQ : IReadOnlyQueryWrapper; + ValueTask TryHandleRequestAsync(TRequest request, TResponse response, TContext context, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Imazen.Routing/Serving/ILicenseChecker.cs b/src/Imazen.Routing/Serving/ILicenseChecker.cs new file mode 100644 index 00000000..f5ea12d5 --- /dev/null +++ b/src/Imazen.Routing/Serving/ILicenseChecker.cs @@ -0,0 +1,15 @@ +using Imageflow.Server; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Layers; + +namespace Imazen.Routing.Serving; + +public interface ILicenseChecker +{ + bool RequestNeedsEnforcementAction(IHttpRequestStreamAdapter request); + + string InvalidLicenseMessage { get; } + string GetLicensePageContents(); + void FireHeartbeat(); + void Initialize(LicenseOptions licenseOptions); +} \ No newline at end of file diff --git a/src/Imazen.Routing/Serving/IPerformanceTracker.cs b/src/Imazen.Routing/Serving/IPerformanceTracker.cs new file mode 100644 index 00000000..d3c48656 --- /dev/null +++ b/src/Imazen.Routing/Serving/IPerformanceTracker.cs @@ -0,0 +1,9 @@ +namespace Imazen.Routing.Serving; + +public interface IPerformanceTracker +{ + void IncrementCounter(string name); +} +internal class NullPerformanceTracker : IPerformanceTracker{ + public void IncrementCounter(string name){} +} \ No newline at end of file diff --git a/src/Imageflow.Server/ImageJobInfo.cs b/src/Imazen.Routing/Serving/ImageJobInfo.cs.txt similarity index 53% rename from src/Imageflow.Server/ImageJobInfo.cs rename to src/Imazen.Routing/Serving/ImageJobInfo.cs.txt index ff4ae2b5..cedde85e 100644 --- a/src/Imageflow.Server/ImageJobInfo.cs +++ b/src/Imazen.Routing/Serving/ImageJobInfo.cs.txt @@ -1,31 +1,32 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics; using Imageflow.Fluent; +using Imageflow.Server.Caching; +using Imazen.Abstractions.Blobs; using Imazen.Common.Helpers; using Imazen.Common.Instrumentation; using Imazen.Common.Storage; -using Microsoft.AspNetCore.Http; +using Imazen.Routing.Compatibility.ImageflowServer; +using Imazen.Routing.Helpers; -namespace Imageflow.Server +namespace Imazen.Routing.Serving { - internal class ImageJobInfo + internal class ImageJobInfo + where TRequest : IHttpRequestStreamAdapter + where TResponse : IHttpResponseStreamAdapter { - public static bool ShouldHandleRequest(HttpContext context, ImageflowMiddlewareOptions options) + public static bool ShouldHandleRequest(TRequest request, ImageServerOptions options, ImageflowMiddlewareOptions legacyOptions) { + + // If the path is empty or null we don't handle it // If the path is empty or null we don't handle it - var pathValue = context.Request.Path; + var pathValue = request.GetPath(); if (pathValue == null || !pathValue.HasValue) return false; var path = pathValue.Value; if (path == null) return false; - + // We handle image request extensions if (PathHelpers.IsImagePath(path)) { @@ -33,7 +34,7 @@ public static bool ShouldHandleRequest(HttpContext context, ImageflowMiddlewareO } // Don't do string parsing unless there are actually prefixes configured - if (options.ExtensionlessPaths.Count == 0) return false; + if (legacyOptions.ExtensionlessPaths.Count == 0) return false; // If there's no extension, then we can see if it's one of the prefixes we should handle var extension = Path.GetExtension(path); @@ -41,26 +42,17 @@ public static bool ShouldHandleRequest(HttpContext context, ImageflowMiddlewareO if (!string.IsNullOrEmpty(extension)) return false; // Return true if any of the prefixes match - return options.ExtensionlessPaths + return legacyOptions.ExtensionlessPaths .Any(extensionlessPath => path.StartsWith(extensionlessPath.Prefix, extensionlessPath.PrefixComparison)); } - public ImageJobInfo(HttpContext context, ImageflowMiddlewareOptions options, BlobProvider blobProvider) + public ImageJobInfo(TRequest request, TContext context, ImageflowMiddlewareOptions legacyOptions, CacheManager cacheManager, + ILicenseChecker licenseChecker) { - this.options = options; - Authorized = ProcessRewritesAndAuthorization(context, options); - - if (!Authorized) return; + this.legacyOptions = legacyOptions; + GlobalPerf.Singleton.PreRewriteQuery(request.GetQuery().Keys); HasParams = PathHelpers.SupportedQuerystringKeys.Any(FinalQuery.ContainsKey); - // Get the image and page domains - ImageDomain = context.Request.Host.Host; - var referer = context.Request.Headers["Referer"].ToString(); - if (!string.IsNullOrEmpty(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var result)) - { - PageDomain = result.DnsSafeHost; - } - var extension = Path.GetExtension(FinalVirtualPath); if (FinalQuery.TryGetValue("format", out var newExtension)) { @@ -69,16 +61,16 @@ public ImageJobInfo(HttpContext context, ImageflowMiddlewareOptions options, Blo EstimatedFileExtension = PathHelpers.SanitizeImageExtension(extension) ?? "jpg"; - primaryBlob = new BlobFetchCache(FinalVirtualPath, blobProvider); + primaryBlob = new BlobFetchCache(FinalVirtualPath, cacheManager); allBlobs = new List(1) {primaryBlob}; appliedWatermarks = new List(); if (HasParams) { - if (options.Licensing.RequestNeedsEnforcementAction(context.Request)) + if (licenseChecker.RequestNeedsEnforcementAction(request)) { - if (options.EnforcementMethod == EnforceLicenseWith.RedDotWatermark) + if (this.legacyOptions.EnforcementMethod == EnforceLicenseWith.RedDotWatermark) { FinalQuery["watermark_red_dot"] = "true"; } @@ -90,10 +82,10 @@ public ImageJobInfo(HttpContext context, ImageflowMiddlewareOptions options, Blo // Look up watermark names if (FinalQuery.TryGetValue("watermark", out var watermarkValues)) { - var watermarkNames = watermarkValues.Split(",").Select(s => s.Trim(' ')); + var watermarkNames = watermarkValues.Split(',').Select(s => s.Trim(' ')); foreach (var name in watermarkNames) { - var watermark = options.NamedWatermarks.FirstOrDefault(w => + var watermark = this.legacyOptions.NamedWatermarks.FirstOrDefault(w => w.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); if (watermark == null) { @@ -107,8 +99,8 @@ public ImageJobInfo(HttpContext context, ImageflowMiddlewareOptions options, Blo } // After we've populated the defaults, run the event handlers for custom watermarking logic - var args = new WatermarkingEventArgs(context, FinalVirtualPath, FinalQuery, appliedWatermarks); - foreach (var handler in options.Watermarking) + var args = new WatermarkingContextEventArgs(context, FinalVirtualPath, FinalQuery, appliedWatermarks); + foreach (var handler in this.legacyOptions.Watermarking) { var matches = string.IsNullOrEmpty(handler.PathPrefix) || ( FinalVirtualPath != null && @@ -124,7 +116,7 @@ public ImageJobInfo(HttpContext context, ImageflowMiddlewareOptions options, Blo // Add the watermark source files foreach (var w in appliedWatermarks) { - allBlobs.Add(new BlobFetchCache(w.VirtualPath, blobProvider)); + allBlobs.Add(new BlobFetchCache(w.VirtualPath, cacheManager)); } } @@ -140,153 +132,12 @@ public ImageJobInfo(HttpContext context, ImageflowMiddlewareOptions options, Blo public string EstimatedFileExtension { get; } public string AuthorizedMessage { get; private set; } - private string ImageDomain { get; } - private string PageDomain { get; } - private readonly List appliedWatermarks; private readonly List allBlobs; private readonly BlobFetchCache primaryBlob; - private readonly ImageflowMiddlewareOptions options; - - private bool ProcessRewritesAndAuthorization(HttpContext context, ImageflowMiddlewareOptions middlewareOptions) - { - if (!VerifySignature(context, middlewareOptions)) - { - AuthorizedMessage = "Invalid request signature"; - return false; - } - - var path = context.Request.Path.Value; - var args = new UrlEventArgs(context, context.Request.Path.Value, PathHelpers.ToQueryDictionary(context.Request.Query)); - - - GlobalPerf.Singleton.PreRewriteQuery(args.Query.Keys); - - foreach (var handler in middlewareOptions.PreRewriteAuthorization) - { - var matches = string.IsNullOrEmpty(handler.PathPrefix) || - path.StartsWith(handler.PathPrefix, StringComparison.OrdinalIgnoreCase); - if (matches && !handler.Handler(args)) return false; - } - - if (middlewareOptions.UsePresetsExclusively) - { - var firstKey = args.Query.FirstOrDefault().Key; - - if (args.Query.Count > 1 || (firstKey != null && firstKey != "preset")) - { - AuthorizedMessage = "Only presets are permitted in the querystring"; - return false; - } - } - - // Parse and apply presets before rewriting - if (args.Query.TryGetValue("preset", out var presetNames)) - { - var presetNamesList = presetNames - .Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries); - foreach (var presetName in presetNamesList) - { - if (middlewareOptions.Presets.TryGetValue(presetName, out var presetOptions)) - { - foreach (var pair in presetOptions.Pairs) - { - if (presetOptions.Priority == PresetPriority.OverrideQuery || - !args.Query.ContainsKey(pair.Key)) - { - args.Query[pair.Key] = pair.Value; - } - } - } - else - { - throw new InvalidOperationException($"The image preset {presetName} was referenced from the querystring but is not registered."); - } - } - } - - // Apply rewrite handlers - foreach (var handler in middlewareOptions.Rewrite) - { - var matches = string.IsNullOrEmpty(handler.PathPrefix) || - path.StartsWith(handler.PathPrefix, StringComparison.OrdinalIgnoreCase); - if (matches) - { - handler.Handler(args); - path = args.VirtualPath; - } - } - - // Set defaults if keys are missing, but at least 1 supported key is present - if (middlewareOptions.ApplyDefaultCommandsToQuerylessUrls || PathHelpers.SupportedQuerystringKeys.Any(args.Query.ContainsKey)) - { - foreach (var pair in middlewareOptions.CommandDefaults) - { - if (!args.Query.ContainsKey(pair.Key)) - { - args.Query[pair.Key] = pair.Value; - } - } - } - - // Run post-rewrite authorization - foreach (var handler in middlewareOptions.PostRewriteAuthorization) - { - var matches = string.IsNullOrEmpty(handler.PathPrefix) || - path.StartsWith(handler.PathPrefix, StringComparison.OrdinalIgnoreCase); - if (matches && !handler.Handler(args)) return false; - } - - FinalVirtualPath = args.VirtualPath; - FinalQuery = args.Query; - return true; - } - - private bool VerifySignature(HttpContext context, ImageflowMiddlewareOptions middlewareOptions) - { - if (middlewareOptions.RequestSignatureOptions == null) return true; - - var (requirement, signingKeys) = middlewareOptions.RequestSignatureOptions - .GetRequirementForPath(context.Request.Path.Value); - - var queryString = context.Request.QueryString.ToString(); - - var pathAndQuery = context.Request.PathBase.HasValue - ? "/" + context.Request.PathBase.Value.TrimStart('/') - : ""; - pathAndQuery += context.Request.Path.ToString() + queryString; - - pathAndQuery = Signatures.NormalizePathAndQueryForSigning(pathAndQuery); - if (context.Request.Query.TryGetValue("signature", out var actualSignature)) - { - foreach (var key in signingKeys) - { - var expectedSignature = Signatures.SignString(pathAndQuery, key, 16); - if (expectedSignature == actualSignature) return true; - } - - AuthorizedMessage = "Image signature does not match request, or used an invalid signing key."; - return false; - - } - - if (requirement == SignatureRequired.Never) - { - return true; - } - if (requirement == SignatureRequired.ForQuerystringRequests) - { - if (queryString.Length <= 0) return true; - - AuthorizedMessage = "Image processing requests must be signed. No &signature query key found. "; - return false; - } - AuthorizedMessage = "Image requests must be signed. No &signature query key found. "; - return false; - - } - + private readonly ImageflowMiddlewareOptions legacyOptions; + public bool PrimaryBlobMayExist() { @@ -298,15 +149,21 @@ public bool NeedsCaching() { return HasParams || primaryBlob?.GetBlobResult()?.IsFile == false; } + + public bool ServerCachingEnabledForRequest() + { + //TODO - actually consult the configurations here! + return this.NeedsCaching(); + } - public Task GetPrimaryBlob() + public Task GetPrimaryBlob() { return primaryBlob.GetBlob(); } private string HashStrings(IEnumerable strings) { - return PathHelpers.Base64Hash(string.Join('|',strings)); + return PathHelpers.CreateBase64UrlHash(string.Join('|',strings)); } internal async Task CopyPrimaryBlobToAsync(Stream stream) @@ -406,7 +263,7 @@ public static async Task FromCache(BlobFetchCache blobFetchCach } } - public async Task ProcessUncached() + public async Task ProcessUncached() { //Fetch all blobs simultaneously var blobs = await Task.WhenAll( @@ -436,9 +293,10 @@ public async Task ProcessUncached() blobs[0].GetBytesSource(), new BytesDestination(), CommandString, watermarks) .Finish() - .SetSecurityOptions(options.JobSecurityOptions) + .SetSecurityOptions(legacyOptions.JobSecurityOptions) .InProcessAsync(); + GlobalPerf.Singleton.JobComplete(new ImageJobInstrumentation(jobResult) { FinalCommandKeys = FinalQuery.Keys, @@ -454,11 +312,13 @@ public async Task ProcessUncached() throw new InvalidOperationException("Image job returned zero bytes."); } - return new ImageData + var attrs = new BlobAttributes() { + LastModifiedDateUtc = DateTime.UtcNow, ContentType = jobResult.First.PreferredMimeType, - ResultBytes = resultBytes.Value + BlobByteCount = resultBytes.Value.Count }; + return new ReusableArraySegmentBlob(resultBytes.Value, attrs); } finally { @@ -470,31 +330,5 @@ public async Task ProcessUncached() } } - internal static class PerformanceDetailsExtensions{ - - private static long GetWallMicroseconds(this PerformanceDetails d, Func nodeFilter) - { - long totalMicroseconds = 0; - foreach (var frame in d.Frames) - { - foreach (var node in frame.Nodes.Where(n => nodeFilter(n.Name))) - { - totalMicroseconds += node.WallMicroseconds; - } - } - - return totalMicroseconds; - } - - - - public static long GetTotalWallTicks(this PerformanceDetails d) => - d.GetWallMicroseconds(n => true) * TimeSpan.TicksPerSecond / 1000000; - - public static long GetEncodeWallTicks(this PerformanceDetails d) => - d.GetWallMicroseconds(n => n == "primitive_encoder") * TimeSpan.TicksPerSecond / 1000000; - - public static long GetDecodeWallTicks(this PerformanceDetails d) => - d.GetWallMicroseconds(n => n == "primitive_decoder") * TimeSpan.TicksPerSecond / 1000000; - } + } diff --git a/src/Imazen.Routing/Serving/ImageServer.cs b/src/Imazen.Routing/Serving/ImageServer.cs new file mode 100644 index 00000000..80a1e528 --- /dev/null +++ b/src/Imazen.Routing/Serving/ImageServer.cs @@ -0,0 +1,310 @@ +using System.Text; +using Imageflow.Server; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Blobs.LegacyProviders; +using Imazen.Abstractions.DependencyInjection; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Concurrency.BoundedTaskCollection; +using Imazen.Common.Instrumentation; +using Imazen.Common.Instrumentation.Support.InfoAccumulators; +using Imazen.Common.Licensing; +using Imazen.Routing.Caching; +using Imazen.Routing.Engine; +using Imazen.Routing.Helpers; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Layers; +using Imazen.Routing.Promises; +using Imazen.Routing.Promises.Pipelines; +using Imazen.Routing.Promises.Pipelines.Watermarking; +using Imazen.Routing.Requests; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Imazen.Routing.Serving; + +internal class ImageServer : IImageServer, IHostedService + where TRequest : IHttpRequestStreamAdapter + where TResponse : IHttpResponseStreamAdapter +{ + private readonly IReLogger logger; + private readonly ILicenseChecker licenseChecker; + private readonly RoutingEngine routingEngine; + private readonly IBlobPromisePipeline pipeline; + private readonly IPerformanceTracker perf; + private readonly CancellationTokenSource cts = new(); + private readonly BoundedTaskCollection uploadQueue; + private readonly bool shutdownRegisteredServices; + private readonly IImageServerContainer container; + public ImageServer(IImageServerContainer container, + LicenseOptions licenseOptions, + RoutingEngine routingEngine, + IPerformanceTracker perfTracker, + IReLogger logger, + bool shutdownRegisteredServices = true) + { + this.shutdownRegisteredServices = shutdownRegisteredServices; + perf = perfTracker; + this.container = container; + this.logger = logger.WithSubcategory("ImageServer"); + this.routingEngine = routingEngine; + + licenseChecker = container.GetService() ?? + new Licensing(LicenseManagerSingleton.GetOrCreateSingleton( + licenseOptions.KeyPrefix, licenseOptions.CandidateCacheFolders), null); + licenseChecker.Initialize(licenseOptions); + + licenseChecker.FireHeartbeat(); + var infoProviders = container.GetService>()?.ToList(); + if (infoProviders != null) + GlobalPerf.Singleton.SetInfoProviders(infoProviders); + + + var blobFactory = new SimpleReusableBlobFactory(); + + // ensure routingengine is registered + if (!container.Contains()) + container.Register(routingEngine); + + uploadQueue = container.GetService>(); + if (uploadQueue == null) + { + uploadQueue = new BoundedTaskCollection(1, cts); + container.Register>(uploadQueue); + } + + var memoryCache = container.GetService(); + if (memoryCache == null) + { + memoryCache = new MemoryCache(new MemoryCacheOptions( + "memCache", 100, + 1000, 1000 * 10, TimeSpan.FromSeconds(10))); + container.Register(memoryCache); + } + var allCaches = container.GetService>()?.ToList(); + var allCachesExceptMemory = allCaches?.Where(c => c != memoryCache)?.ToList(); + + var watermarkingLogic = container.GetService() ?? + new WatermarkingLogicOptions(null, null); + var sourceCacheOptions = new CacheEngineOptions + { + SeriesOfCacheGroups = + [ + ..new[] { [memoryCache], allCachesExceptMemory ?? [] } + ], + SaveToCaches = allCaches!, + BlobFactory = blobFactory, + UploadQueue = uploadQueue, + Logger = logger + }; + var imagingOptions = new ImagingMiddlewareOptions + { + Logger = logger, + BlobFactory = blobFactory, + WatermarkingLogic = watermarkingLogic + }; + + pipeline = new CacheEngine(null, sourceCacheOptions); + pipeline = new ImagingMiddleware(null, imagingOptions); + pipeline = new CacheEngine(pipeline, sourceCacheOptions); + } + + public string GetDiagnosticsPageSection(DiagnosticsPageArea area) + { + if (area != DiagnosticsPageArea.Start) + { + return ""; + } + var s = new StringBuilder(); + s.AppendLine("\nInstalled Caches"); + s.AppendLine("\nInstalled Providers and Caches"); + // imageServer.GetInstalledProvidersDiag(); + return s.ToString(); + } + + public bool MightHandleRequest(string? path, TQ query, TContext context) where TQ : IReadOnlyQueryWrapper + { + if (path == null) return false; + return routingEngine.MightHandleRequest(path, query); + } + + private ValueTask WriteHttpStatusErrAsync(TResponse response, HttpStatus error, CancellationToken cancellationToken) + { + perf.IncrementCounter($"http_{error.StatusCode}"); + return SmallHttpResponse.NoStoreNoRobots(error).WriteAsync(response, cancellationToken); + } + + private string CreateEtag(ICacheableBlobPromise promise) + { + if (!promise.ReadyToWriteCacheKeyBasisData) + { + throw new InvalidOperationException("Promise is not ready to write cache key basis data"); + } + var weakEtag = promise.CopyCacheKeyBytesTo(stackalloc byte[32]) + .ToHexLowercaseWith("W\"".AsSpan(), "\"".AsSpan()); + return weakEtag; + } + public async ValueTask TryHandleRequestAsync(TRequest request, TResponse response, TContext context, CancellationToken cancellationToken = default) + { + licenseChecker?.FireHeartbeat(); // Perhaps limit this to imageflow-handled requests? + try + { + + var mutableRequest = MutableRequest.OriginalRequest(request); + var result = await routingEngine.Route(mutableRequest, cancellationToken); + if (result == null) + return false; // We don't have matching routing for this. Let the rest of the app handle it. + if (result.IsError) + { + await WriteHttpStatusErrAsync(response, result.UnwrapError(), cancellationToken); + return true; + } + + var snapshot = mutableRequest.ToSnapshot(true); + var endpoint = result.Unwrap(); + var promise = await endpoint.GetInstantPromise(snapshot, cancellationToken); + if (promise is ICacheableBlobPromise blobPromise) + { + var pipelineResult = + await pipeline.GetFinalPromiseAsync(blobPromise, routingEngine, pipeline, request, cancellationToken); + if (pipelineResult.IsError) + { + await WriteHttpStatusErrAsync(response, pipelineResult.UnwrapError(), cancellationToken); + return true; + } + + var finalPromise = pipelineResult.Unwrap(); + + if (finalPromise.HasDependencies) + { + var dependencyResult = await finalPromise.RouteDependenciesAsync(routingEngine, cancellationToken); + if (dependencyResult.IsError) + { + await WriteHttpStatusErrAsync(response, dependencyResult.UnwrapError(), cancellationToken); + return true; + } + } + + string? promisedEtag = null; + // Check for If-None-Match + if (request.TryGetHeader(HttpHeaderNames.IfNoneMatch, out var conditionalEtag)) + { + promisedEtag = CreateEtag(finalPromise); + if (promisedEtag == conditionalEtag) + { + perf.IncrementCounter("etag_hit"); + response.SetContentLength(0); + response.SetStatusCode(304); + return true; + } + + perf.IncrementCounter("etag_miss"); + } + + // Now, let's get the actual blob. + var blobResult = + await finalPromise.TryGetBlobAsync(snapshot, routingEngine, pipeline, cancellationToken); + if (blobResult.IsError) + { + await WriteHttpStatusErrAsync(response, blobResult.UnwrapError(), cancellationToken); + return true; + } + + var blob = blobResult.Unwrap(); + + // TODO: if the blob provided an etag, it could be from blob storage, or it could be from a cache. + // TODO: TryGetBlobAsync already calculates the cache if it's a serverless promise... + // Since cache provider has to calculate the cache key anyway, can't we figure out how to improve this? + promisedEtag ??= CreateEtag(finalPromise); + + if (blob.Attributes.Etag != null && blob.Attributes.Etag != promisedEtag) + { + perf.IncrementCounter("etag_internal_external_mismatch"); + } + + response.SetHeader(HttpHeaderNames.ETag, promisedEtag); + + // TODO: Do routing layers configure this stuff? Totally haven't thought about it. + // if (options.DefaultCacheControlString != null) + // response.SetHeader(HttpHeaderNames.CacheControl, options.DefaultCacheControlString); + + if (blob.Attributes.ContentType != null) + { + response.SetContentType(blob.Attributes.ContentType); + using var consumable = blob.MakeOrTakeConsumable(); + await response.WriteBlobWrapperBody(consumable, cancellationToken); + } + else + { + using var consumable = blob.MakeOrTakeConsumable(); + using var stream = consumable.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob); + await MagicBytes.ProxyToStream(stream, response, cancellationToken); + } + + perf.IncrementCounter("middleware_ok"); + return true; + } + else + { + var nonBlobResponse = + await promise.CreateResponseAsync(snapshot, routingEngine, pipeline, cancellationToken); + await nonBlobResponse.WriteAsync(response, cancellationToken); + perf.IncrementCounter("middleware_ok"); + return true; + } + + + + + } + catch (BlobMissingException e) + { + perf.IncrementCounter("http_404"); + await SmallHttpResponse.Text(404, "The specified resource does not exist.\r\n" + e.Message) + .WriteAsync(response, cancellationToken); + return true; + + } + catch (Exception e) + { + var errorName = e.GetType().Name; + var errorCounter = "middleware_" + errorName; + perf.IncrementCounter(errorCounter); + perf.IncrementCounter("middleware_errors"); + throw; + } + finally + { + // Increment counter for type of file served + var imageExtension = PathHelpers.GetImageExtensionFromContentType(response.ContentType); + if (imageExtension != null) + { + perf.IncrementCounter("module_response_ext_" + imageExtension); + } + } + + throw new NotImplementedException("Unreachable"); + + } + + public Task StartAsync(CancellationToken cancellationToken) + { + return uploadQueue.StartAsync(cancellationToken); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + //TODO: error handling or no? + cts.Cancel(); + await uploadQueue.StopAsync(cancellationToken); + if (shutdownRegisteredServices) + { + var services = this.container.GetInstanceOfEverythingLocal(); + foreach (var service in services) + { + await service.StopAsync(cancellationToken); + } + } + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Serving/ImageServerOptions.cs b/src/Imazen.Routing/Serving/ImageServerOptions.cs new file mode 100644 index 00000000..224c600c --- /dev/null +++ b/src/Imazen.Routing/Serving/ImageServerOptions.cs @@ -0,0 +1,10 @@ +using System.Collections.Immutable; +using Imazen.Abstractions.BlobCache; +using Imazen.Common.Storage; +using Imazen.Routing.Caching; + +namespace Imazen.Routing.Serving; + +internal class ImageServerOptions +{ +} \ No newline at end of file diff --git a/src/Imazen.Routing/Serving/MagicBytes.cs b/src/Imazen.Routing/Serving/MagicBytes.cs new file mode 100644 index 00000000..e496372b --- /dev/null +++ b/src/Imazen.Routing/Serving/MagicBytes.cs @@ -0,0 +1,164 @@ +using System.Buffers; +using System.IO.Pipelines; +using Imazen.Routing.HttpAbstractions; + +namespace Imazen.Routing.Serving +{ + internal static class MagicBytes + { + private static string GetContentTypeFromBytes(Span data) + { + if (data.Length < 12) + { + return "application/octet-stream"; + } + return Imazen.Common.FileTypeDetection.FileTypeDetector.GuessMimeType(data) ?? "application/octet-stream"; + } + + interface IByteWritable + { + ValueTask Write(ArraySegment bytes, CancellationToken cancellationToken); + } + struct ByteWritable : IByteWritable + { + private readonly Stream _stream; + + public ByteWritable(Stream stream) + { + _stream = stream; + } + + public ValueTask Write(ArraySegment bytes, CancellationToken cancellationToken) + { + if (bytes.Array == null) + { + return new ValueTask(); + } + return new System.Threading.Tasks.ValueTask(_stream.WriteAsync(bytes.Array, bytes.Offset, bytes.Count, + cancellationToken)); + } + } + struct BufferWriterWritable : IByteWritable + { + private readonly IBufferWriter _writer; + + public BufferWriterWritable(IBufferWriter writer) + { + _writer = writer; + } + + public ValueTask Write(ArraySegment bytes, CancellationToken cancellationToken) + { + _writer.Write(bytes); + return new ValueTask(); + } + } + struct PipeWriterWritable : IByteWritable + { + private readonly PipeWriter _writer; + + public PipeWriterWritable(PipeWriter writer) + { + _writer = writer; + } + + public async ValueTask Write(ArraySegment bytes, CancellationToken cancellationToken) + { + var flushResult = await _writer.WriteAsync(bytes, cancellationToken); + if (flushResult.IsCanceled || flushResult.IsCompleted) + { + throw new OperationCanceledException(); + } + } + } + + + private static IByteWritable WrapWritable(TResponse response) where TResponse: IHttpResponseStreamAdapter + { + if (response.SupportsBufferWriter) + { + return new BufferWriterWritable(response.GetBodyBufferWriter()); + } + else if (response.SupportsStream) + { + return new ByteWritable(response.GetBodyWriteStream()); + } + else if (response.SupportsPipelines) + { + return new PipeWriterWritable(response.GetBodyPipeWriter()); + } + else + { + throw new InvalidOperationException("Response does not support writing"); + } + } + + /// + /// Proxies the given stream to the HTTP response, while also setting the content length + /// and the content type based off the magic bytes of the image + /// + /// TODO: use pipelines + /// + /// + /// + /// + /// + internal static async Task ProxyToStream(Stream sourceStream, TResponse response, CancellationToken cancellationToken) where TResponse: IHttpResponseStreamAdapter + { + if (sourceStream.CanSeek) + { + response.SetContentLength(sourceStream.Length - sourceStream.Position); + } +#if DOTNET5_0_OR_GREATER + if (response.SupportsPipelines) + { + var writer = response.GetBodyPipeWriter(); + + // read the first 12 of 4096 bytes to determine the content type + var memory = writer.GetMemory(4096); + var bytesRead = await sourceStream.ReadAsync(memory, cancellationToken).ConfigureAwait(false); + response.SetContentType(bytesRead >= 12 + ? GetContentTypeFromBytes(memory.Span.Slice(0, bytesRead)) + : "application/octet-stream"); + writer.Advance(bytesRead); + await sourceStream.CopyToAsync(writer, cancellationToken).ConfigureAwait(false); + writer.Complete(); + return; + } +#endif + if (response.SupportsStream) + { + var responseStream = response.GetBodyWriteStream(); + // We really only need 12 bytes but it would be a waste to only read that many. + const int bufferSize = 4096; + var buffer = ArrayPool.Shared.Rent(bufferSize); + try + { + var bytesRead = await sourceStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken) + .ConfigureAwait(false); + if (bytesRead == 0) + { + throw new InvalidOperationException("Source blob has zero bytes."); + } + + response.SetContentType(bytesRead >= 12 + ? GetContentTypeFromBytes(buffer) + : "application/octet-stream"); + await responseStream.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + await sourceStream.CopyToAsync(responseStream, 81920, cancellationToken).ConfigureAwait(false); + return; + } + else + { + throw new InvalidOperationException("Response does not support writing to stream"); + } + + } + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Unused/CachedNonOverlappingRunner.cs b/src/Imazen.Routing/Unused/CachedNonOverlappingRunner.cs new file mode 100644 index 00000000..d2b80ae3 --- /dev/null +++ b/src/Imazen.Routing/Unused/CachedNonOverlappingRunner.cs @@ -0,0 +1,49 @@ +using Imazen.Routing.Caching.Health; +using Microsoft.Extensions.Hosting; + +namespace Imazen.Routing.Unused; + +internal class CachedNonOverlappingRunner(NonOverlappingAsyncRunner runner, TimeSpan staleAfter) + : IHostedService, IDisposable +{ + public void Dispose() + { + runner.Dispose(); + } + + public T? LastResult { get; private set; } + public DateTimeOffset? LastRun { get; private set; } + public TimeSpan? GetTimeSinceLastRun() => LastRun.HasValue ? DateTimeOffset.UtcNow - LastRun : null; + + public async ValueTask GetResultAsync(bool forceFresh, TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) return LastResult!; + if (!forceFresh && LastRun.HasValue && GetTimeSinceLastRun() < staleAfter) return LastResult!; + var result = await runner.RunNonOverlappingAsync(timeout, cancellationToken); + LastResult = result; + LastRun = DateTimeOffset.UtcNow; + return result; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return runner.StopAsync(cancellationToken); + } +} + + + + + + + + + + + \ No newline at end of file diff --git a/src/Imazen.Routing/Unused/ExistenceProbableMap.cs b/src/Imazen.Routing/Unused/ExistenceProbableMap.cs new file mode 100644 index 00000000..0bce9a31 --- /dev/null +++ b/src/Imazen.Routing/Unused/ExistenceProbableMap.cs @@ -0,0 +1,169 @@ +using System.Buffers; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Instrumentation.Support; + +namespace Imazen.Routing.Caching +{ + /// + /// Uses a (2MB) ConcurrentBitArray of size 2^24 to track whether a key is likely to exist in the cache. + /// Syncs via blob serialization. Bits representing buckets can only be set to 1, never 0. Works for any string key using SHA256 subsequence. Could potentially be extended to have multiple layers in the future if 2MB takes up too much bandwidth + /// + internal class ExistenceProbableMap + { + private readonly ConcurrentBitArray buckets; + + private const int hashBits = 24; + + private const int bucketCount = 1 << hashBits; + public ExistenceProbableMap(){ + buckets = new ConcurrentBitArray(bucketCount); + } + + private static byte[] HashKeyBasisStatic(byte[] keyBasis) + { + using (var h = SHA256.Create()) + { + return h.ComputeHash(keyBasis); + } + } + + private static string BlobName => $"existence-probable-map-{hashBits}-bit.bin"; + private static Lazy BlobNameHash => new Lazy(() => HashKeyBasisStatic(Encoding.UTF8.GetBytes(BlobName))); + + private static Lazy BlobCacheRequest => new Lazy(() + => new BlobCacheRequest(BlobGroup.Essential, BlobNameHash.Value, Convert.ToBase64String(BlobNameHash.Value), false)); + + + //ReadAllBytesAsync ArrayPool + + internal static async Task ReadAllBytesIntoBitArrayAsync(Stream stream, ConcurrentBitArray target, ArrayPool pool, CancellationToken cancellationToken = default) + { + if (stream == null) return; + byte[]? buffer = null; + try + { + + if (stream.CanSeek) + { + if (stream.Length != target.ByteCount) + { + throw new Exception($"ExistenceProbableMap.ReadAllBytesIntoBitArrayAsync: Expected {target.ByteCount} bytes in stream, got {stream.Length}"); + } + } + + buffer = pool.Rent(target.ByteCount); + //var bytesRead = await stream.ReadAsync(bufferOwner.Memory, cancellationToken).ConfigureAwait(false); + var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + if (bytesRead == 0) + { + //pool.Return(buffer); //bufferOwner.Dispose(); + throw new IOException("ExistenceProbableMap.ReadAllBytesIntoBitArrayAsync: Stream was empty."); + } + if (bytesRead != target.ByteCount) + { + //pool.Return(buffer); + throw new IOException($"ExistenceProbableMap.ReadAllBytesIntoBitArrayAsync: Expected {target.ByteCount} bytes, got {bytesRead}"); + } + // ensure no more bytes can be read + if (stream.ReadByte() != -1) + { + //pool.Return(buffer); + throw new IOException("ExistenceProbableMap.ReadAllBytesIntoBitArrayAsync: Stream was longer than {target.ByteCount} bytes."); + } + // copy to target + target.LoadFromSpan(buffer); + + } + finally + { + if (buffer != null) pool.Return(buffer); + stream.Dispose(); + } + } + + internal static async Task FetchSync(IBlobCache cache, ConcurrentBitArray? target = null, + CancellationToken cancellationToken = default) + { + + var fetchResult = await cache.CacheFetch(BlobCacheRequest.Value, cancellationToken); + if (fetchResult.IsError) + { + throw new Exception( + $"ExistenceProbableMap.FetchSync: fetchResult.IsError was true: {fetchResult.Error}"); + } + + using var wrapper = fetchResult.Unwrap(); + using var blob = wrapper.MakeOrTakeConsumable(); + using var stream = blob.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob); + target ??= new ConcurrentBitArray(bucketCount); + await ReadAllBytesIntoBitArrayAsync(stream, target, ArrayPool.Shared, + cancellationToken); + return target; + } + + + public async Task Sync(IBlobCache cache, IReusableBlobFactory blobFactory, CancellationToken cancellationToken = default){ + + var sw = Stopwatch.StartNew(); + // Fetch + var existingData = await FetchSync(cache, cancellationToken: cancellationToken); + if (existingData != null){ + //Merge + buckets.MergeTrueBitsFrom(existingData); + } + //Put + var bytes = buckets.ToBytes(); + sw.Stop(); + var putRequest = CacheEventDetails.CreateFreshResultGeneratedEvent(BlobCacheRequest.Value, + blobFactory, Result.Ok(new BlobWrapper(null, + new ReusableArraySegmentBlob(new ArraySegment(bytes), new BlobAttributes() + { + ContentType = "application/octet-stream" + }, sw.Elapsed)))); + + var putResponse = await cache.CachePut(putRequest, cancellationToken); + if (putResponse.IsError) + { + throw new Exception( + $"ExistenceProbableMap.Sync: putResponse.IsError was true: {putResponse.Error}"); + } + } + + + internal static int HashFor(byte[] data) + { + // sha256 the data + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(data); + var getBits = System.BitConverter.ToUInt32(hash, 0); + return (int)(getBits >> (32 - hashBits)); + } + + internal static int HashFor(string s) => HashFor(System.Text.Encoding.UTF8.GetBytes(s)); + + public bool MayExist(string key){ + var hash = HashFor(key); + return buckets[hash]; + } + public void MarkExists(string key){ + var hash = HashFor(key); + buckets[hash] = true; + } + + public void MarkExists(byte[] data){ + var hash = HashFor(data); + buckets[hash] = true; + } + public bool MayExist(byte[] data){ + var hash = HashFor(data); + return buckets[hash]; + } + + + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Unused/ImageflowRoute.cs b/src/Imazen.Routing/Unused/ImageflowRoute.cs new file mode 100644 index 00000000..fa13ac4f --- /dev/null +++ b/src/Imazen.Routing/Unused/ImageflowRoute.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.Unused +{ + public record ImageflowRoute(string pathPrefix, IImageSource imageSource) + { + public string PathPrefix { get; set; } = pathPrefix; + + public StringComparison PathPrefixComparison { get; set; } = StringComparison.Ordinal; + + // match extensionless? + public bool HandleExtensionlessUrls { get; set; } = false; + + internal IList? HostStringPatterns { get; } + + public ImageflowRouteOptions? Options { get; set; } + + public IImageSource ImageSource { get; set; } = imageSource; + } + + public interface IImageSource + { + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Unused/ImageflowRouteOptions.cs b/src/Imazen.Routing/Unused/ImageflowRouteOptions.cs new file mode 100644 index 00000000..5d21546a --- /dev/null +++ b/src/Imazen.Routing/Unused/ImageflowRouteOptions.cs @@ -0,0 +1,50 @@ +using System.Collections.Immutable; + +namespace Imazen.Routing.Unused +{ + public record ImageflowRouteOptions + { + + + public IEnumerable? UseSourceCaches { get; init; } + public IEnumerable? UseOutputCaches { get; init; } + + public string? CacheControlString { get; init; } + + + + //public SignatureRequired? RequestSignatureRequirement { get; } = null; + public IImmutableList? RequestSigningKeys { get; } = null; + + + public bool LowercasePathRemainder { get; init; } = false; + + + public bool OnlyAllowPresets { get; init; } + + public IImmutableDictionary? ApplyDefaultCommands { get; init; } = null; + + // perhaps make an enum + public bool ApplyDefaultCommandsToQuerylessUrls { get; init; } = false; + + + // allows diag, licensing, heartbeat, etc + public bool? AllowUtilityEndpoints { get; init; } + + // public IImmutableList>? PreRewriteAuthorization { get; init; } = null; + // public IImmutableList>? Rewrite { get; init; } = null; + // public IImmutableList>? PostRewriteAuthorization { get; init; } = null; + // public IImmutableList>? AdjustWatermarking { get; init; } = null; + // + // public SecurityOptions? JobSecurityOptions { get; set; } + // + // internal readonly Dictionary Presets = new Dictionary(StringComparer.OrdinalIgnoreCase); + + + // watermark definitions + // license key enforcement + // license key + // DIAGNOSTICS ACCEESS + // DIAGNOSTICS PASSWORD + } +} \ No newline at end of file diff --git a/src/Imazen.Routing/Unused/LegacyStreamCacheAdapter.cs b/src/Imazen.Routing/Unused/LegacyStreamCacheAdapter.cs new file mode 100644 index 00000000..15798d11 --- /dev/null +++ b/src/Imazen.Routing/Unused/LegacyStreamCacheAdapter.cs @@ -0,0 +1,290 @@ +using System.Diagnostics; +using Imazen.Abstractions.BlobCache; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Logging; +using Imazen.Abstractions.Resulting; +using Imazen.Common.Concurrency; +using Imazen.Common.Concurrency.BoundedTaskCollection; +using Imazen.Common.Extensibility.StreamCache; +using Imazen.Common.Issues; +using Microsoft.Extensions.Logging; + +#pragma warning disable CS0618 // Type or member is obsolete + +namespace Imazen.Routing.Caching; + +internal class StreamCacheAdapterOptions +{ + public int WaitForIdenticalRequestsTimeoutMs { get; set; } = 1000; + public bool FailRequestsOnEnqueueLockTimeout { get; set; } = true; + public bool WriteSynchronouslyWhenQueueFull { get; set; } = false; + public bool EvictOnQueueFull { get; set; } = true; +} +internal class StreamCacheResult : IStreamCacheResult +{ + public StreamCacheResult(Stream data, string? contentType, string status) + { + Data = data; + ContentType = contentType; + Status = status; + } + + public Stream Data { get; } + public string? ContentType { get; } + public string Status { get; } +} +internal class LegacyStreamCacheAdapter : IStreamCache +{ + public LegacyStreamCacheAdapter(IBlobCache cache, StreamCacheAdapterOptions options, IReLogger logger) + { + Options = options; + QueueLocks = new AsyncLockProvider(); + CurrentWrites = new BoundedTaskCollection(1000); + this.cache = cache; + Logger = logger; + } + + private IReLogger? Logger { get; set; } + private StreamCacheAdapterOptions Options { get; } + private readonly IBlobCache cache; + private readonly IReusableBlobFactory reusableBlobFactory = new SimpleReusableBlobFactory(); + + // /// + // /// Provides string-based locking for image resizing (not writing, just processing). Prevents duplication of efforts in asynchronous mode, where 'Locks' is not being used. + // /// + private AsyncLockProvider QueueLocks { get; } + + /// + /// Contains all the queued and in-progress writes to the cache. + /// + private BoundedTaskCollection CurrentWrites {get; } + + + public IEnumerable GetIssues() + { + return new List(); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task CacheFetch(IBlobCacheRequest cacheRequest,CancellationToken cancellationToken) + { + var result = await cache.CacheFetch(cacheRequest, cancellationToken); + if (result.IsOk) + { + // We intentionally don't make CreateConsumable cancellable, as an incomplete operation + // could cause other users to fail. + var resultBlob = result.Unwrap().CanTakeConsumable + ? result.Unwrap().TakeConsumable() + : await result.Unwrap().CreateConsumable(reusableBlobFactory, default); + + //TODO: We never dispose the resultBlob. This is a bug. + return new StreamCacheResult(resultBlob.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob), resultBlob.Attributes?.ContentType, "Hit"); + } + + return null; + } + + public async Task GetOrCreateBytes(byte[] key, AsyncBytesResult dataProviderCallback, + CancellationToken cancellationToken, + bool retrieveContentType) + { + var cacheRequest = new BlobCacheRequest(BlobGroup.GeneratedCacheEntry, key); + + var wrappedCallback = new Func>>(async (ct) => + { + var sw = Stopwatch.StartNew(); + var cacheInputEntry = await dataProviderCallback(ct); + sw.Stop(); + if (cacheInputEntry == null) + throw new InvalidOperationException("Null entry provided by dataProviderCallback"); + if (cacheInputEntry.Bytes.Array == null) + throw new InvalidOperationException("Null entry byte array provided by dataProviderCallback"); + IReusableBlob blobGenerated + = new ReusableArraySegmentBlob(cacheInputEntry.Bytes, new BlobAttributes(){ + ContentType = cacheInputEntry.ContentType}, sw.Elapsed); + return Result.Ok(new BlobWrapper(null, blobGenerated)); + }); + + var result = await GetOrCreateResult(cacheRequest, wrappedCallback, cancellationToken, retrieveContentType); + if (result.IsOk) + { + var resultBlob = result.Unwrap().CanTakeConsumable + ? result.Unwrap().TakeConsumable() + : await result.Unwrap().CreateConsumable(reusableBlobFactory, default); + //TODO: We never dispose the resultBlob. This is a bug. + return new StreamCacheResult(resultBlob.BorrowStream(DisposalPromise.CallerDisposesStreamThenBlob), resultBlob.Attributes?.ContentType, "Hit"); + } + else + { + throw new InvalidOperationException(result.UnwrapError().ToString()); + } + } + + public async Task> GetOrCreateResult(IBlobCacheRequest cacheRequest, + Func>> dataProviderCallback, + CancellationToken cancellationToken, + bool retrieveContentType) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + var swGetOrCreateBytes = Stopwatch.StartNew(); + + // Tries to fetch the result from disk cache, the memory queue, or create it. If the memory queue has space, + // the writeCallback() will be executed and the resulting bytes put in a queue for writing to disk. + // If the memory queue is full, writing to disk will be attempted synchronously. + // In either case, writing to disk can also fail if the disk cache is full and eviction fails. + // If the memory queue is full, eviction will be done synchronously and can cause other threads to time out + // while waiting for QueueLock + + + // Tell cleanup what we're using + // TODO: raise event with all caches for utilization + // CleanupManager.NotifyUsed(entry); + + // Fast path on disk hit + var swFileExists = Stopwatch.StartNew(); + + var fastResult = await cache.CacheFetch(cacheRequest.WithFailFast(true), cancellationToken); + if (fastResult.IsOk) return fastResult; + + // Just continue on creating the file. It must have been deleted between the calls + + swFileExists.Stop(); + + //Looks like a miss. Let's enter a lock for the creation of the file. This is a different locking system + // than for writing to the file + //This prevents two identical requests from duplicating efforts. Different requests don't lock. + + //Lock execution using relativePath as the sync basis. Ignore casing differences. This prevents duplicate entries in the write queue and wasted CPU/RAM usage. + var lockResult + = await QueueLocks.TryExecuteAsync + , IBlobCacheRequest>(cacheRequest.CacheKeyHashString, + Options.WaitForIdenticalRequestsTimeoutMs, cancellationToken, cacheRequest, + async (IBlobCacheRequest r, CancellationToken ct) => + { + var swInsideQueueLock = Stopwatch.StartNew(); + + // Now, if the item we seek is in the queue, we have a memcached hit. + // If not, we should check the filesystem. It's possible the item has been written to disk already. + // If both are a miss, we should see if there is enough room in the write queue. + // If not, switch to in-thread writing. + + var existingQueuedWrite = CurrentWrites.Get(cacheRequest.CacheKeyHashString); + + if (existingQueuedWrite != null) + { + return Result.Ok( + existingQueuedWrite.Blob); + } + + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + swFileExists.Start(); + // Fast path on disk hit, now that we're in a synchronized state + var slowResult = await cache.CacheFetch(cacheRequest.WithFailFast(false), cancellationToken); + if (slowResult.IsOk) return slowResult; + + // Just continue on creating the file. It must have been deleted between the calls + + swFileExists.Stop(); + + var swDataCreation = Stopwatch.StartNew(); + //Read, resize, process, and encode the image. Lots of exceptions thrown here. + var generatedResult = await dataProviderCallback(cancellationToken); + if (!generatedResult.IsOk) + { + return BlobCacheFetchFailure.ErrorResult(generatedResult.UnwrapError() + .WithAppend("Error generating image")); + } + + await generatedResult.Unwrap().EnsureReusable(reusableBlobFactory, cancellationToken); + swDataCreation.Stop(); + + //Create AsyncWrite object to enqueue + var w = new BlobTaskItem(cacheRequest.CacheKeyHashString, generatedResult.Unwrap()); + + + var cachePutEvent = CacheEventDetails.CreateFreshResultGeneratedEvent + (cacheRequest, reusableBlobFactory, + Result.Ok(w.Blob)); + + + + var swEnqueue = Stopwatch.StartNew(); + var queueResult = CurrentWrites.Queue(w, async delegate + { + try + { + var putResult = await cache.CachePut(cachePutEvent, CancellationToken.None); + if (!putResult.IsOk) + { + // Logger?.LogError("HybridCache failed to write to disk, {Exception} {Path}\n{StackTrace}", putResult.Error, w.RelativePath, putResult.Error.StackTrace); + // cacheResult.Detail = AsyncCacheDetailResult.QueueLockTimeoutAndFailed; + // return; + } + //var unused = await EvictWriteAndLogSynchronized(false, swDataCreation.Elapsed, CancellationToken.None); + } + catch (Exception ex) + { + Logger?.LogError(ex, + "HybridCache failed to flush async write, {Exception} {Path}\n{StackTrace}", + ex.ToString(), + w.Blob.Attributes.BlobStorageReference?.GetFullyQualifiedRepresentation(), + ex.StackTrace); + } + + }); + swEnqueue.Stop(); + swInsideQueueLock.Stop(); + swGetOrCreateBytes.Stop(); + + if (queueResult == BoundedTaskCollection.EnqueueResult.QueueFull) + { + if (Options.WriteSynchronouslyWhenQueueFull) + { + var putResult = await cache.CachePut(cachePutEvent, CancellationToken.None); + if (putResult.TryUnwrapError(out var putError)) + { + Logger?.LogError("HybridCache failed to write to disk, {Error} {Event}", putError, + cachePutEvent); + //cacheResult.Detail = AsyncCacheDetailResult.QueueLockTimeoutAndFailed; + return BlobCacheFetchFailure.ErrorResult(putResult.UnwrapError()); + } + //cacheResult.Detail = writerDelegateResult; + } + } + + return BlobCacheFetchFailure.OkResult(w.Blob); + }); + if (lockResult.IsOk) return lockResult.Unwrap(); + + //On queue lock failure + if (!Options.FailRequestsOnEnqueueLockTimeout) + { + // We run the callback with no intent of caching + var generationResult = await dataProviderCallback(cancellationToken); + if (!generationResult.IsOk) + return BlobCacheFetchFailure.ErrorResult(generationResult.UnwrapError()); + + // It's not completely ok, but we can return the bytes + return Result.Ok(generationResult.Unwrap()); + + } + else + { + return BlobCacheFetchFailure.ErrorResult((500, "Queue lock timeout")); + } + } + + +} \ No newline at end of file diff --git a/src/Imazen.Routing/packages.lock.json b/src/Imazen.Routing/packages.lock.json new file mode 100644 index 00000000..15c37d5d --- /dev/null +++ b/src/Imazen.Routing/packages.lock.json @@ -0,0 +1,559 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.0": { + "CommunityToolkit.HighPerformance": { + "type": "Direct", + "requested": "[8.*, )", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Imageflow.Net": { + "type": "Direct", + "requested": "[0.12.0, )", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.*, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "Supernova.Enum.Generators": { + "type": "Direct", + "requested": "[1.0.14, )", + "resolved": "1.0.14", + "contentHash": "++5zyUFNF5WUm50ZmokgSmnBIkGNnWnT8byrd7cbab1nczrZOawePke9VSOeO/sU43Q4GHq1NJGGOhhK6Kot+g==" + }, + "System.Collections.Immutable": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Direct", + "requested": "[4.*, )", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "2.3.2", + "contentHash": "Oh1qXXFdJFcHozvb4H6XYLf2W0meZFuG0A+TfapFPj9z5fd4vxiARGEhAaLj/6XWQaMYIv4SH/9Q6H78Hw0E2Q==", + "dependencies": { + "System.Memory": "4.5.5" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0", + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + } + }, + "net6.0": { + "CommunityToolkit.HighPerformance": { + "type": "Direct", + "requested": "[8.*, )", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "Imageflow.Net": { + "type": "Direct", + "requested": "[0.12.0, )", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.*, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "Supernova.Enum.Generators": { + "type": "Direct", + "requested": "[1.0.14, )", + "resolved": "1.0.14", + "contentHash": "++5zyUFNF5WUm50ZmokgSmnBIkGNnWnT8byrd7cbab1nczrZOawePke9VSOeO/sU43Q4GHq1NJGGOhhK6Kot+g==" + }, + "System.Collections.Immutable": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "2.3.2", + "contentHash": "Oh1qXXFdJFcHozvb4H6XYLf2W0meZFuG0A+TfapFPj9z5fd4vxiARGEhAaLj/6XWQaMYIv4SH/9Q6H78Hw0E2Q==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + } + }, + "net8.0": { + "CommunityToolkit.HighPerformance": { + "type": "Direct", + "requested": "[8.*, )", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "Imageflow.Net": { + "type": "Direct", + "requested": "[0.12.0, )", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "hKTrehpfVzOhAz0mreaTAZgbz0DrMEbWq4n3hAo8Ks6WdxdqQhNPvzOqn9VygKuWf1bmxPdraqzTaXriO/sn0A==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "8.0.0", + "Microsoft.SourceLink.Common": "8.0.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.*, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "Supernova.Enum.Generators": { + "type": "Direct", + "requested": "[1.0.14, )", + "resolved": "1.0.14", + "contentHash": "++5zyUFNF5WUm50ZmokgSmnBIkGNnWnT8byrd7cbab1nczrZOawePke9VSOeO/sU43Q4GHq1NJGGOhhK6Kot+g==" + }, + "System.Collections.Immutable": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Direct", + "requested": "[6.*, )", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "2.3.2", + "contentHash": "Oh1qXXFdJFcHozvb4H6XYLf2W0meZFuG0A+TfapFPj9z5fd4vxiARGEhAaLj/6XWQaMYIv4SH/9Q6H78Hw0E2Q==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/src/NugetPackageDefaults.targets b/src/NugetPackageDefaults.targets new file mode 100644 index 00000000..36c9523d --- /dev/null +++ b/src/NugetPackageDefaults.targets @@ -0,0 +1,9 @@ + + + net8.0;netstandard2.0;net6.0 + true + true + true + + + \ No newline at end of file diff --git a/src/NugetPackages.targets b/src/NugetPackages.targets index 15a0b368..2e757643 100644 --- a/src/NugetPackages.targets +++ b/src/NugetPackages.targets @@ -1,17 +1,37 @@ - + + enable + true + latest + enable + - + <_Parameter1>$([System.DateTime]::UtcNow.ToString("o")) - - + + + <_Parameter1>Imageflow.Server* + <_Parameter2>Imazen.Routing + <_Parameter3>Imazen.Common + <_Parameter4>Imazen.Abstractions + <_Parameter5>Imazen.HybridCache + <_Parameter6>Imazen.DiskCache + + + <_Parameter1>{Assembly.Name} + + + + <_Parameter1>$(GITHUB_SHA) + + - + @@ -20,8 +40,8 @@ - $(MSBuildThisFileDirectory)..\obj\$(Configuration) - $(MSBuildThisFileDirectory)..\bin\$(Configuration) + obj\$(Configuration)\$(Platform)\ + $(SolutionDir)NuGetPackages\$(Configuration)\ diff --git a/src/ProjectDefaults.targets b/src/ProjectDefaults.targets new file mode 100644 index 00000000..d930f987 --- /dev/null +++ b/src/ProjectDefaults.targets @@ -0,0 +1,15 @@ + + + enable + true + net8.0;netstandard2.0;net6.0 + latest + enable + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + \ No newline at end of file diff --git a/src/design_scratch.md b/src/design_scratch.md new file mode 100644 index 00000000..4d4e99f0 --- /dev/null +++ b/src/design_scratch.md @@ -0,0 +1,30 @@ + +Scenarios + +Routing to promise +Browser -> IServer -> Disk (returns promise) + +Promise ?Pipeline? + +DiskPromise -> Caching -> Imaging -> Caching -> IServer (Etag/304) + +But what if caching is blob caching? source caching wouldn't make sense. +What if we are using Azure Functions or AWS Lambda? + + + +Design task: +Preview editions of results, for long-running jobs. Say an AI upscale is requested, but it takes 10 minutes. + +Figure out how response HTTP headers and browser caching hints are determined - does routing? another layer? +Promise-based authentication? +Extracting data from a request in the most organized way? +- Azure, AWS, OpenWhisk serverless support? + +JSON Job endpoint +- pre-signed HTTPS GET and PUT URLs +- If there's just one output, can respond with it, depending on timeout. +- Can execute a callback on completion instead of waiting for response. +- cached license file (for azure fn) +- Can run an Imageflow job, a super-crunch, salience analysis, bonus format conversion, or a different backend job, or AI (replicate?) + diff --git a/tests/Imageflow.Server.Configuration.Tests/Imageflow.Server.Configuration.Tests.csproj b/tests/Imageflow.Server.Configuration.Tests/Imageflow.Server.Configuration.Tests.csproj index 42411f46..0ad48401 100644 --- a/tests/Imageflow.Server.Configuration.Tests/Imageflow.Server.Configuration.Tests.csproj +++ b/tests/Imageflow.Server.Configuration.Tests/Imageflow.Server.Configuration.Tests.csproj @@ -1,9 +1,9 @@ - net7.0 + net8.0 enable - 11 + 12 enable false @@ -11,13 +11,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -32,4 +32,3 @@ - diff --git a/tests/Imageflow.Server.Configuration.Tests/packages.lock.json b/tests/Imageflow.Server.Configuration.Tests/packages.lock.json index 222abcc2..9a61d9dc 100644 --- a/tests/Imageflow.Server.Configuration.Tests/packages.lock.json +++ b/tests/Imageflow.Server.Configuration.Tests/packages.lock.json @@ -1,50 +1,55 @@ { "version": 1, "dependencies": { - "net7.0": { + "net8.0": { "coverlet.collector": { "type": "Direct", - "requested": "[3.2.0, )", - "resolved": "3.2.0", - "contentHash": "xjY8xBigSeWIYs4I7DgUHqSNoGqnHi7Fv7/7RZD02rvZyG3hlsjnQKiVKVWKgr9kRKgmV+dEfu8KScvysiC0Wg==" + "requested": "[6.0.1, )", + "resolved": "6.0.1", + "contentHash": "q/8r0muHlF7TbhMUTBAk43KteZYG2ECV96pbzkToQwgjp3BvbS766svUcO6va+cohWOkigeBWmHs0Vu5vQnESA==" }, "Microsoft.NET.Test.Sdk": { "type": "Direct", - "requested": "[17.5.0, )", - "resolved": "17.5.0", - "contentHash": "IJ4eSPcsRbwbAZehh1M9KgejSy0u3d0wAdkJytfCh67zOaCl5U3ltruUEe15MqirdRqGmm/ngbjeaVeGapSZxg==", + "requested": "[17.9.0, )", + "resolved": "17.9.0", + "contentHash": "7GUNAUbJYn644jzwLm5BD3a2p9C1dmP8Hr6fDPDxgItQk9hBs1Svdxzz07KQ/UphMSmgza9AbijBJGmw5D658A==", "dependencies": { - "Microsoft.CodeCoverage": "17.5.0", - "Microsoft.TestPlatform.TestHost": "17.5.0" + "Microsoft.CodeCoverage": "17.9.0", + "Microsoft.TestPlatform.TestHost": "17.9.0" } }, "xunit": { "type": "Direct", - "requested": "[2.4.2, )", - "resolved": "2.4.2", - "contentHash": "6Mj73Ont3zj2CJuoykVJfE0ZmRwn7C+pTuRP8c4bnaaTFjwNG6tGe0prJ1yIbMe9AHrpDys63ctWacSsFJWK/w==", + "requested": "[2.7.0, )", + "resolved": "2.7.0", + "contentHash": "KcCI5zxh8zbUfQTeErc4oT7YokViVND2V0p4vDJ2VD4lhF9V5qCYMMDNixme7FdwYy3SwPHF+2xC2Dq4Z9GSlA==", "dependencies": { - "xunit.analyzers": "1.0.0", - "xunit.assert": "2.4.2", - "xunit.core": "[2.4.2]" + "xunit.analyzers": "1.11.0", + "xunit.assert": "2.7.0", + "xunit.core": "[2.7.0]" } }, "xunit.runner.visualstudio": { "type": "Direct", - "requested": "[2.4.5, )", - "resolved": "2.4.5", - "contentHash": "OwHamvBdUKgqsXfBzWiCW/O98BTx81UKzx2bieIOQI7CZFE5NEQZGi8PBQGIKawDW96xeRffiNf20SjfC0x9hw==" + "requested": "[2.5.7, )", + "resolved": "2.5.7", + "contentHash": "31Rl7dBJriX0DNwZfDp8gqFOPsiM0c9kqpcH/HvNi9vDp+K7Ydf42H7mVIvYT918Ywzn1ymLg1c4DDC6iU754w==" + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" }, "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", "dependencies": { "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", - "Imageflow.Net": "0.10.2" + "Imageflow.Net": "0.12.0" } }, "Imageflow.NativeRuntime.osx-x86_64": { @@ -69,23 +74,22 @@ }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", "dependencies": { - "Microsoft.CSharp": "4.7.0", - "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", - "Newtonsoft.Json": "[13.0.3, 14.0.0)" + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" } }, - "Microsoft.CodeCoverage": { + "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "17.5.0", - "contentHash": "6FQo0O6LKDqbCiIgVQhJAf810HSjFlOj7FunWaeOGDKxy8DAbpHzPk4SfBTXz9ytaaceuIIeR6hZgplt09m+ig==" + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, - "Microsoft.CSharp": { + "Microsoft.CodeCoverage": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + "resolved": "17.9.0", + "contentHash": "RGD37ZSrratfScYXm7M0HjvxMxZyWZL4jm+XgMZbkIY1UPgjUpbNA/t+WTGj/rC/0Hm9A3IrH3ywbKZkOCnoZA==" }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", @@ -135,457 +139,58 @@ }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", - "resolved": "1.2.2", - "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" - }, - "Microsoft.NETCore.Platforms": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" - }, - "Microsoft.NETCore.Targets": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", - "resolved": "17.5.0", - "contentHash": "QwiBJcC/oEA1kojOaB0uPWOIo4i6BYuTBBYJVhUvmXkyYqZ2Ut/VZfgi+enf8LF8J4sjO98oRRFt39MiRorcIw==", + "resolved": "17.9.0", + "contentHash": "1ilw/8vgmjLyKU+2SKXKXaOqpYFJCQfGqGz+x0cosl981VzjrY74Sv6qAJv+neZMZ9ZMxF3ArN6kotaQ4uvEBw==", "dependencies": { - "NuGet.Frameworks": "5.11.0", "System.Reflection.Metadata": "1.6.0" } }, "Microsoft.TestPlatform.TestHost": { "type": "Transitive", - "resolved": "17.5.0", - "contentHash": "X86aikwp9d4SDcBChwzQYZihTPGEtMdDk+9t64emAl7N0Tq+OmlLAoW+Rs+2FB2k6QdUicSlT4QLO2xABRokaw==", + "resolved": "17.9.0", + "contentHash": "Spmg7Wx49Ya3SxBjyeAR+nQpjMTKZwTwpZ7KyeOTIqI/WHNPnBU4HUvl5kuHPQAwGWqMy4FGZja1HvEwvoaDiA==", "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "17.5.0", + "Microsoft.TestPlatform.ObjectModel": "17.9.0", "Newtonsoft.Json": "13.0.1" } }, - "Microsoft.Win32.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "NETStandard.Library": { - "type": "Transitive", - "resolved": "1.6.1", - "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.Win32.Primitives": "4.3.0", - "System.AppContext": "4.3.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Console": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.Compression.ZipFile": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.Net.Http": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Net.Sockets": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Timer": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0", - "System.Xml.XDocument": "4.3.0" - } - }, "Newtonsoft.Json": { "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" - }, - "NuGet.Frameworks": { - "type": "Transitive", - "resolved": "5.11.0", - "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q==" - }, - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" - }, - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" - }, - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" - }, - "runtime.native.System": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", - "dependencies": { - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" - } - }, - "runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", - "dependencies": { - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" - }, - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" - }, - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" - }, - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" - }, - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" - }, - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" - }, - "System.AppContext": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Buffers": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Collections": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Collections.Concurrent": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Console": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Diagnostics.Debug": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "tD6kosZnTAGdrEa0tZSuFyunMbt/5KYDnHdndJYGqZoNy00XVXyACd5d6KnE1YgYv3ne2CjtAfNXo/fwEhnKUA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Diagnostics.Tools": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.Tracing": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" }, - "System.Globalization.Calendars": { + "System.Collections.Immutable": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Runtime": "4.3.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.Globalization.Extensions": { + "System.Interactive.Async": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0" + "System.Linq.Async": "6.0.1" } }, - "System.IO": { + "System.IO.Pipelines": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" }, - "System.IO.Compression": { + "System.Linq.Async": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Buffers": "4.3.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.IO.Compression": "4.3.0" - } - }, - "System.IO.Compression.ZipFile": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", - "dependencies": { - "System.Buffers": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.IO.FileSystem": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.FileSystem.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Linq": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Linq.Expressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Linq": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Emit.Lightweight": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" } }, "System.Memory": { @@ -593,496 +198,37 @@ "resolved": "4.5.1", "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" }, - "System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.DiagnosticSource": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Net.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Net.Sockets": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.ObjectModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Reflection": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", - "dependencies": { - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.ILGeneration": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.Lightweight": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, "System.Reflection.Metadata": { "type": "Transitive", "resolved": "1.6.0", "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" }, - "System.Reflection.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.TypeExtensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Resources.ResourceManager": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" - }, - "System.Runtime.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.Handles": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.InteropServices": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Runtime.InteropServices.RuntimeInformation": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } - }, - "System.Runtime.Numerics": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", - "dependencies": { - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.Apple": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Cng": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Security.Cryptography.Csp": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Linq": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", - "dependencies": { - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Cng": "4.3.0", - "System.Security.Cryptography.Csp": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Text.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Text.Encoding.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Text.RegularExpressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", - "dependencies": { - "System.Runtime": "4.3.0" - } + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" }, - "System.Threading": { + "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", "dependencies": { - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.Threading.Tasks": { + "System.Text.Json": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Threading.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "npvJkVKl5rKXrtl1Kkm6OhOUaYGEiF9wFbppFRWSMoApKzt2PiPHT2Bb8a5sAWxprvdOAtvaARS9QYMznEUtug==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Threading.Timer": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Xml.ReaderWriter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Tasks.Extensions": "4.3.0" - } - }, - "System.Xml.XDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" } }, "Tomlyn": { "type": "Transitive", - "resolved": "0.16.2", - "contentHash": "NVvOlecYWwhqQdE461UGHUeJ1t2DtGXU+L00LgtBTgWA16bUmMhUIRaCpSkRX5HqAeid/KlXmdH7Sul0mr6HJA==" + "resolved": "0.17.0", + "contentHash": "3BRbxOjZwgdXBGemOvJWuydaG/KsKrnG1Z15Chruvih8Tc8bgtLcmDgo6LPRqiF5LNh6X4Vmos7YuGteBHfePA==" }, "xunit.abstractions": { "type": "Transitive", @@ -1091,49 +237,46 @@ }, "xunit.analyzers": { "type": "Transitive", - "resolved": "1.0.0", - "contentHash": "BeO8hEgs/c8Ls2647fPfieMngncvf0D0xYNDfIO59MolxtCtVjFRd6SRc+7tj8VMqkVOuJcnc9eh4ngI2cAmLQ==" + "resolved": "1.11.0", + "contentHash": "SCv+Ihxv+fCqotGeM8sVwLhw8nzAJ2aFRN5lcoKn9QtGdbVJ79JqDc+4u8/Ddnp2udxtmv+xYFWkHNlb/sk01w==" }, "xunit.assert": { "type": "Transitive", - "resolved": "2.4.2", - "contentHash": "pxJISOFjn2XTTi1mcDCkRZrTFb9OtRRCtx2kZFNF51GdReLr1ls2rnyxvAS4JO247K3aNtflvh5Q0346K5BROA==", - "dependencies": { - "NETStandard.Library": "1.6.1" - } + "resolved": "2.7.0", + "contentHash": "CCTs3bUhmIS4tDwK6Cn/IiabG3RhYzdf65eIkO7u9/grKoN9MrN780LzVED3E8v+vwmmj7b5TW3/GFuZHPAzWA==" }, "xunit.core": { "type": "Transitive", - "resolved": "2.4.2", - "contentHash": "KB4yGCxNqIVyekhJLXtKSEq6BaXVp/JO3mbGVE1hxypZTLEe7h+sTbAhpA+yZW2dPtXTuiW+C1B2oxxHEkrmOw==", + "resolved": "2.7.0", + "contentHash": "98tzqYAbtc/p/2Ba455XTNbD12Qoo8kPehjC4oDT46CAsLli5JOCU9hFF2MV3HHWMw/Y3yFUV2Vcukplbs6kuA==", "dependencies": { - "xunit.extensibility.core": "[2.4.2]", - "xunit.extensibility.execution": "[2.4.2]" + "xunit.extensibility.core": "[2.7.0]", + "xunit.extensibility.execution": "[2.7.0]" } }, "xunit.extensibility.core": { "type": "Transitive", - "resolved": "2.4.2", - "contentHash": "W1BoXTIN1C6kpVSMw25huSet25ky6IAQUNovu3zGOGN/jWnbgSoTyCrlIhmXSg0tH5nEf8q7h3OjNHOjyu5PfA==", + "resolved": "2.7.0", + "contentHash": "JLnx4PI0vn1Xr1Ust6ydrp2t/ktm2dyGPAVoDJV5gQuvBMSbd2K7WGzODa2ttiz030CeQ8nbsXl05+cvf7QNyA==", "dependencies": { - "NETStandard.Library": "1.6.1", "xunit.abstractions": "2.0.3" } }, "xunit.extensibility.execution": { "type": "Transitive", - "resolved": "2.4.2", - "contentHash": "CZmgcKkwpyo8FlupZdWpJCryrAOWLh1FBPG6gmVZuPQkGQsim/oL4PcP4nfrC2hHgXUFtluvaJ0Sp9PQKUMNpg==", + "resolved": "2.7.0", + "contentHash": "bjY+crT1jOyxKagFjCMdEVzoenO2v66ru8+CK/0UaXvyG4U9Q3UTieJkbQXbi7/1yZIK1sGh01l5/jh2CwLJtQ==", "dependencies": { - "NETStandard.Library": "1.6.1", - "xunit.extensibility.core": "[2.4.2]" + "xunit.extensibility.core": "[2.7.0]" } }, "imageflow.server": { "type": "Project", "dependencies": { - "Imageflow.AllPlatforms": "[0.10.2, )", - "Imazen.Common": "[0.1.0--notset, )" + "Imageflow.AllPlatforms": "[0.12.0, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.IO.RecyclableMemoryStream": "[3.0.0, )" } }, "imageflow.server.configuration": { @@ -1142,7 +285,7 @@ "Imageflow.Server": "[0.1.0--notset, )", "Imageflow.Server.HybridCache": "[0.1.0--notset, )", "Imazen.Common": "[0.1.0--notset, )", - "Tomlyn": "[0.16.2, )" + "Tomlyn": "[0.17.0, )" } }, "imageflow.server.hybridcache": { @@ -1153,16 +296,36 @@ "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imazen.hybridcache": { "type": "Project", "dependencies": { - "Imazen.Common": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" } } } diff --git a/tests/Imageflow.Server.Tests/HostBuilderExtensions.cs b/tests/Imageflow.Server.Tests/HostBuilderExtensions.cs new file mode 100644 index 00000000..f1e6862f --- /dev/null +++ b/tests/Imageflow.Server.Tests/HostBuilderExtensions.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Hosting; + +namespace Imageflow.Server.Tests; + + +internal class AsyncDisposableHost(IHost host) : IAsyncDisposable, IHost +{ + public async ValueTask DisposeAsync() + { + await host.StopAsync(); + host.Dispose(); + } + + public void Dispose() + { + host.Dispose(); + } + + public Task StartAsync(CancellationToken cancellationToken = new CancellationToken()) + { + return host.StartAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken = new CancellationToken()) + { + return host.StopAsync(cancellationToken); + } + + public IServiceProvider Services => host.Services; +} + +internal static class HostBuilderExtensions +{ + internal static async Task StartDisposableHost(this IHostBuilder hostBuilder) + { + var host = await hostBuilder.StartAsync(); + return new AsyncDisposableHost(host); + } +} \ No newline at end of file diff --git a/tests/Imageflow.Server.Tests/Imageflow.Server.Tests.csproj b/tests/Imageflow.Server.Tests/Imageflow.Server.Tests.csproj index 3c446846..7112d54c 100644 --- a/tests/Imageflow.Server.Tests/Imageflow.Server.Tests.csproj +++ b/tests/Imageflow.Server.Tests/Imageflow.Server.Tests.csproj @@ -1,34 +1,23 @@ - - false - - net6.0;net7.0 + net8.0;net6.0 + - + - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - + diff --git a/tests/Imageflow.Server.Tests/IntegrationTest.cs b/tests/Imageflow.Server.Tests/IntegrationTest.cs index a3ad2eaf..425e5e1e 100644 --- a/tests/Imageflow.Server.Tests/IntegrationTest.cs +++ b/tests/Imageflow.Server.Tests/IntegrationTest.cs @@ -1,29 +1,43 @@ -using System; -using System.Collections.Generic; -using System.IO; using System.Net; -using System.Runtime.CompilerServices; -using System.Threading; using Amazon; using Amazon.Runtime; using Amazon.S3; using Imageflow.Fluent; -using Imageflow.Server.DiskCache; +using Imageflow.Server.HybridCache; using Imageflow.Server.Storage.RemoteReader; using Imageflow.Server.Storage.S3; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Xunit; +using Xunit.Abstractions; namespace Imageflow.Server.Tests { - public class IntegrationTest + public static class LoggingExtensions + { + internal static void AddXunitLoggingDefaults(this IServiceCollection services, ITestOutputHelper outputHelper) + { + services.AddLogging(builder => + { + // log to output so we get it on xunit failures. + builder.AddXunit(outputHelper, LogLevel.Trace); + builder.AddFilter("Microsoft", LogLevel.Warning); + builder.AddFilter("System", LogLevel.Warning); + // trace log to console for when xunit crashes with stack overflow + builder.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Trace; + }); + }); + } + } + public class IntegrationTest(ITestOutputHelper OutputHelper) { + [Fact] public async void TestLocalFiles() { @@ -38,6 +52,10 @@ public async void TestLocalFiles() { var hostBuilder = new HostBuilder() + .ConfigureServices(services => + { + services.AddXunitLoggingDefaults(OutputHelper); + }) .ConfigureWebHost(webHost => { // Add TestServer @@ -88,15 +106,15 @@ await Assert.ThrowsAsync(async () => using var wrongImageExtension1 = await client.GetAsync("/wrong.webp"); wrongImageExtension1.EnsureSuccessStatusCode(); - Assert.Equal("image/png", wrongImageExtension1.Content.Headers.ContentType.MediaType); + Assert.Equal("image/png", wrongImageExtension1.Content.Headers.ContentType?.MediaType); using var wrongImageExtension2 = await client.GetAsync("/wrong.jpg"); wrongImageExtension2.EnsureSuccessStatusCode(); - Assert.Equal("image/png", wrongImageExtension2.Content.Headers.ContentType.MediaType); + Assert.Equal("image/png", wrongImageExtension2.Content.Headers.ContentType?.MediaType); using var extensionlessRequest = await client.GetAsync("/extensionless/file"); extensionlessRequest.EnsureSuccessStatusCode(); - Assert.Equal("image/png", extensionlessRequest.Content.Headers.ContentType.MediaType); + Assert.Equal("image/png", extensionlessRequest.Content.Headers.ContentType?.MediaType); using var response2 = await client.GetAsync("/fire.jpg?width=1"); @@ -149,8 +167,8 @@ public async void TestDiskCache() var hostBuilder = new HostBuilder() .ConfigureServices(services => { - services.AddImageflowDiskCache( - new DiskCacheOptions(diskCacheDir) {AsyncWrites = false}); + services.AddImageflowHybridCache(new HybridCacheOptions(diskCacheDir)); //TODO: sync writes wanted + services.AddXunitLoggingDefaults(OutputHelper); }) .ConfigureWebHost(webHost => { @@ -188,15 +206,15 @@ public async void TestDiskCache() using var wrongImageExtension1 = await client.GetAsync("/wrong.webp"); wrongImageExtension1.EnsureSuccessStatusCode(); - Assert.Equal("image/png", wrongImageExtension1.Content.Headers.ContentType.MediaType); + Assert.Equal("image/png", wrongImageExtension1.Content.Headers.ContentType?.MediaType); using var wrongImageExtension2 = await client.GetAsync("/wrong.jpg"); wrongImageExtension2.EnsureSuccessStatusCode(); - Assert.Equal("image/png", wrongImageExtension2.Content.Headers.ContentType.MediaType); + Assert.Equal("image/png", wrongImageExtension2.Content.Headers.ContentType?.MediaType); using var extensionlessRequest = await client.GetAsync("/extensionless/file"); extensionlessRequest.EnsureSuccessStatusCode(); - Assert.Equal("image/png", extensionlessRequest.Content.Headers.ContentType.MediaType); + Assert.Equal("image/png", extensionlessRequest.Content.Headers.ContentType?.MediaType); await host.StopAsync(CancellationToken.None); @@ -218,11 +236,13 @@ public async void TestAmazonS3() var hostBuilder = new HostBuilder() .ConfigureServices(services => { + services.AddXunitLoggingDefaults(OutputHelper); services.AddSingleton(new AmazonS3Client(new AnonymousAWSCredentials(), RegionEndpoint.USEast1)); - services.AddImageflowDiskCache(new DiskCacheOptions(diskCacheDir) {AsyncWrites = false}); + services.AddImageflowHybridCache(new HybridCacheOptions(diskCacheDir)); services.AddImageflowS3Service( new S3ServiceOptions() .MapPrefix("/ri/", "resizer-images")); + }) .ConfigureWebHost(webHost => { @@ -252,6 +272,9 @@ public async void TestAmazonS3() await host.StopAsync(CancellationToken.None); + // This could be failing because writes are still in the queue, or because no caches are deemed worthy of writing to, or health status reasons + // TODO: diagnose + var cacheFiles = Directory.GetFiles(diskCacheDir, "*.png", SearchOption.AllDirectories); Assert.Single(cacheFiles); } @@ -269,8 +292,9 @@ public async void TestAmazonS3WithCustomClient() var hostBuilder = new HostBuilder() .ConfigureServices(services => { + services.AddXunitLoggingDefaults(OutputHelper); services.AddSingleton(new AmazonS3Client(new AnonymousAWSCredentials(), RegionEndpoint.USEast1)); - services.AddImageflowDiskCache(new DiskCacheOptions(diskCacheDir) {AsyncWrites = false}); + services.AddImageflowHybridCache(new HybridCacheOptions(diskCacheDir)); services.AddImageflowS3Service( new S3ServiceOptions() .MapPrefix("/ri/", new AmazonS3Client(new AnonymousAWSCredentials(), RegionEndpoint.USEast1), "resizer-images", "", false, false)); @@ -307,60 +331,58 @@ public async void TestAmazonS3WithCustomClient() Assert.Single(cacheFiles); } } - + + [Fact] public async void TestPresetsExclusive() { - using (var contentRoot = new TempContentRoot() - .AddResource("images/fire.jpg", "TestFiles.fire-umbrella-small.jpg")) - { - - var hostBuilder = new HostBuilder() - .ConfigureWebHost(webHost => + using var contentRoot = new TempContentRoot() + .AddResource("images/fire.jpg", "TestFiles.fire-umbrella-small.jpg"); + await using var host = await new HostBuilder() + .ConfigureServices(services => { services.AddXunitLoggingDefaults(OutputHelper); }) + .ConfigureWebHost(webHost => + { + // Add TestServer + webHost.UseTestServer(); + webHost.ConfigureServices(services => { - // Add TestServer - webHost.UseTestServer(); - webHost.Configure(app => - { - app.UseImageflow(new ImageflowMiddlewareOptions() - .SetMapWebRoot(false) - // Maps / to ContentRootPath/images - .MapPath("/", Path.Combine(contentRoot.PhysicalPath, "images")) - .SetUsePresetsExclusively(true) - .AddPreset(new PresetOptions("small", PresetPriority.OverrideQuery) - .SetCommand("maxwidth", "1") - .SetCommand("maxheight", "1")) - ); - }); + services.AddImageflowHybridCache( + new HybridCacheOptions(Path.Combine(contentRoot.PhysicalPath, "diskcache"))); + services.AddXunitLoggingDefaults(OutputHelper); + }); + webHost.Configure(app => + { + app.UseImageflow(new ImageflowMiddlewareOptions() + .SetMapWebRoot(false) + // Maps / to ContentRootPath/images + .MapPath("/", Path.Combine(contentRoot.PhysicalPath, "images")) + .SetUsePresetsExclusively(true) + .AddPreset(new PresetOptions("small", PresetPriority.OverrideQuery) + .SetCommand("maxwidth", "1") + .SetCommand("maxheight", "1")) + ); }); + }).StartDisposableHost(); + + // Create an HttpClient to send requests to the TestServer + using var client = host.GetTestClient(); - // Build and start the IHost - using var host = await hostBuilder.StartAsync(); + using var notFoundResponse = await client.GetAsync("/not_there.jpg"); + Assert.Equal(HttpStatusCode.NotFound, notFoundResponse.StatusCode); - // Create an HttpClient to send requests to the TestServer - using var client = host.GetTestClient(); + using var foundResponse = await client.GetAsync("/fire.jpg"); + foundResponse.EnsureSuccessStatusCode(); - using var notFoundResponse = await client.GetAsync("/not_there.jpg"); - Assert.Equal(HttpStatusCode.NotFound,notFoundResponse.StatusCode); - - using var foundResponse = await client.GetAsync("/fire.jpg"); - foundResponse.EnsureSuccessStatusCode(); - - - using var presetValidResponse = await client.GetAsync("/fire.jpg?preset=small"); - presetValidResponse.EnsureSuccessStatusCode(); - - - await Assert.ThrowsAsync(async () => - { - using var watermarkInvalidResponse = await client.GetAsync("/fire.jpg?preset=not-a-preset"); - }); - - using var nonPresetResponse = await client.GetAsync("/fire.jpg?width=1"); - Assert.Equal(HttpStatusCode.Forbidden,nonPresetResponse.StatusCode); - - await host.StopAsync(CancellationToken.None); - } + + using var presetValidResponse = await client.GetAsync("/fire.jpg?preset=small"); + presetValidResponse.EnsureSuccessStatusCode(); + + + using var watermarkInvalidResponse = await client.GetAsync("/fire.jpg?preset=not-a-preset"); + Assert.Equal(HttpStatusCode.BadRequest,watermarkInvalidResponse.StatusCode); + + using var nonPresetResponse = await client.GetAsync("/fire.jpg?width=1"); + Assert.Equal(HttpStatusCode.Forbidden, nonPresetResponse.StatusCode); } [Fact] @@ -371,6 +393,10 @@ public async void TestPresets() { var hostBuilder = new HostBuilder() + .ConfigureServices(services => + { + services.AddXunitLoggingDefaults(OutputHelper); + }) .ConfigureWebHost(webHost => { // Add TestServer @@ -427,6 +453,10 @@ public async void TestRequestSigning() { var hostBuilder = new HostBuilder() + .ConfigureServices(services => + { + services.AddXunitLoggingDefaults(OutputHelper); + }) .ConfigureWebHost(webHost => { // Add TestServer @@ -500,6 +530,10 @@ public async void TestRemoteReaderPlusRequestSigning() { var hostBuilder = new HostBuilder() + .ConfigureServices(services => + { + services.AddXunitLoggingDefaults(OutputHelper); + }) .ConfigureServices(services => { services.AddHttpClient(); diff --git a/tests/Imageflow.Server.Tests/TempContentRoot.cs b/tests/Imageflow.Server.Tests/TempContentRoot.cs index 70cd11f4..89bbf162 100644 --- a/tests/Imageflow.Server.Tests/TempContentRoot.cs +++ b/tests/Imageflow.Server.Tests/TempContentRoot.cs @@ -1,7 +1,4 @@ -using System; -using System.IO; using System.Reflection; -using System.Text.RegularExpressions; using Microsoft.Extensions.FileProviders; namespace Imageflow.Server.Tests @@ -29,7 +26,7 @@ public TempContentRoot AddResource(string relativePath, string resourceName) using var reader = embeddedProvider.GetFileInfo(resourceName).CreateReadStream(); var newFilePath = Path.Combine(PhysicalPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); var parentDir = Path.GetDirectoryName(newFilePath); - if (!Directory.Exists(parentDir)) + if (parentDir != null && !Directory.Exists(parentDir)) Directory.CreateDirectory(parentDir); using var newFile = File.Create(newFilePath); reader.CopyTo(newFile); diff --git a/tests/Imageflow.Server.Tests/TestLicensing.cs b/tests/Imageflow.Server.Tests/TestLicensing.cs index d103bba8..18183ef5 100644 --- a/tests/Imageflow.Server.Tests/TestLicensing.cs +++ b/tests/Imageflow.Server.Tests/TestLicensing.cs @@ -1,14 +1,11 @@ -using System; -using System.IO; -using System.Linq; using System.Net; using System.Text; -using System.Threading; -using System.Threading.Tasks; using Imazen.Common.Licensing; using Imazen.Common.Tests.Licensing; +using Imazen.Routing.Layers; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Xunit; using Xunit.Abstractions; @@ -17,8 +14,8 @@ namespace Imageflow.Server.Tests { class RequestUrlProvider { - public Uri Url { get; set; } = null; - public Uri Get() => Url; + public Uri? Url { get; set; } + public Uri? Get() => Url; } public class TestLicensing @@ -48,9 +45,13 @@ string GetInfo(ILicenseConfig c, LicenseManagerSingleton mgr) return sb.ToString(); } - internal Task StartAsyncWithOptions(ImageflowMiddlewareOptions options) + internal Task StartAsyncWithOptions(Licensing l, ImageflowMiddlewareOptions options) { var hostBuilder = new HostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(l); + }) .ConfigureWebHost(webHost => { // Add TestServer @@ -77,9 +78,8 @@ public async void TestNoLicense() var licensing = new Licensing(mgr); - using var host = await StartAsyncWithOptions(new ImageflowMiddlewareOptions() + using var host = await StartAsyncWithOptions(licensing,new ImageflowMiddlewareOptions() { - Licensing = licensing, MyOpenSourceProjectUrl = null, EnforcementMethod = EnforceLicenseWith.Http402Error } @@ -126,9 +126,8 @@ public async void TestAGPL() var licensing = new Licensing(mgr); - using var host = await StartAsyncWithOptions(new ImageflowMiddlewareOptions() + using var host = await StartAsyncWithOptions(licensing,new ImageflowMiddlewareOptions() { - Licensing = licensing, MyOpenSourceProjectUrl = "https://github.com/username/project", EnforcementMethod = EnforceLicenseWith.RedDotWatermark }.MapPath("/", Path.Combine(contentRoot.PhysicalPath, "images"))); @@ -168,9 +167,8 @@ public async void TestDomainsLicense() var url = new RequestUrlProvider(); var licensing = new Licensing(mgr, url.Get); - using var host = await StartAsyncWithOptions(new ImageflowMiddlewareOptions() + using var host = await StartAsyncWithOptions(licensing,new ImageflowMiddlewareOptions() { - Licensing = licensing, MyOpenSourceProjectUrl = null } .SetLicenseKey(EnforceLicenseWith.Http402Error, @@ -238,9 +236,8 @@ public async void TestSiteLicense() var url = new RequestUrlProvider(); var licensing = new Licensing(mgr, url.Get); - using var host = await StartAsyncWithOptions(new ImageflowMiddlewareOptions() + using var host = await StartAsyncWithOptions(licensing,new ImageflowMiddlewareOptions() { - Licensing = licensing, MyOpenSourceProjectUrl = null } .SetLicenseKey(EnforceLicenseWith.Http402Error, @@ -314,9 +311,8 @@ public async void TestRevocations(string licenseSetName) var licensing = new Licensing(mgr, url.Get); - using var host = await StartAsyncWithOptions(new ImageflowMiddlewareOptions() + using var host = await StartAsyncWithOptions(licensing, new ImageflowMiddlewareOptions() { - Licensing = licensing, MyOpenSourceProjectUrl = null } .SetLicenseKey(EnforceLicenseWith.Http402Error, diff --git a/tests/Imageflow.Server.Tests/packages.lock.json b/tests/Imageflow.Server.Tests/packages.lock.json index 618734b5..7773eff7 100644 --- a/tests/Imageflow.Server.Tests/packages.lock.json +++ b/tests/Imageflow.Server.Tests/packages.lock.json @@ -4,17 +4,26 @@ "net6.0": { "coverlet.collector": { "type": "Direct", - "requested": "[3.1.2, )", - "resolved": "3.1.2", - "contentHash": "wuLDIDKD5XMt0A7lE31JPenT7QQwZPFkP5rRpdJeblyXZ9MGLI8rYjvm5fvAKln+2/X+4IxxQDxBtwdrqKNLZw==" + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "ZXhHT2YwP9lajrwSKbLlFqsmCCvFJMoRSK9t7sImfnCyd0OB3MhgxdoMcVqxbq1iyxD6mD2fiackWmBb7ayiXQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "4.4.0" + } }, "Microsoft.AspNetCore.TestHost": { "type": "Direct", - "requested": "[3.1.5, )", - "resolved": "3.1.5", - "contentHash": "o08GfbTytPirpwlNe/mIaGv7yyM+Qf0Ig0dM4Dxmzee4rPRXQl3QlhFGVW8WbtVamUXlskbTloi7lmUVwAihPw==", + "requested": "[6.0.27, )", + "resolved": "6.0.27", + "contentHash": "KhpZ4ygPB7y/XxT+I6sk8y0Xgekc8EfdE9QH2Hvk1VDk5BK6d8tN/Ck7IshGXS34JeNdAdWd0N/beoijq5dgcw==", "dependencies": { - "System.IO.Pipelines": "4.7.1" + "System.IO.Pipelines": "6.0.3" } }, "Microsoft.Extensions.FileProviders.Embedded": { @@ -36,43 +45,70 @@ "Microsoft.TestPlatform.TestHost": "17.4.1" } }, + "Moq": { + "type": "Direct", + "requested": "[4.20.70, )", + "resolved": "4.20.70", + "contentHash": "4rNnAwdpXJBuxqrOCzCyICXHSImOTRktCgCWXWykuF1qwoIsVvEnR7PjbMk/eLOxWvhmj5Kwt+kDV3RGUYcNwg==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, "xunit": { "type": "Direct", - "requested": "[2.4.1, )", - "resolved": "2.4.1", - "contentHash": "XNR3Yz9QTtec16O0aKcO6+baVNpXmOnPUxDkCY97J+8krUYxPvXT1szYYEUdKk4sB8GOI2YbAjRIOm8ZnXRfzQ==", + "requested": "[2.6.6, )", + "resolved": "2.6.6", + "contentHash": "MAbOOMtZIKyn2lrAmMlvhX0BhDOX/smyrTB+8WTXnSKkrmTGBS2fm8g1PZtHBPj91Dc5DJA7fY+/81TJ/yUFZw==", + "dependencies": { + "xunit.analyzers": "1.10.0", + "xunit.assert": "2.6.6", + "xunit.core": "[2.6.6]" + } + }, + "Xunit.Extensions.Logging": { + "type": "Direct", + "requested": "[1.1.0, )", + "resolved": "1.1.0", + "contentHash": "xSTZEWnzpanm9gMYREIKWx7VnnaQVcXTL9DLL/+7qYneP+tNK7BnR6lpWB3xFOJhIE1vO1zoSXWyl2deggadew==", "dependencies": { - "xunit.analyzers": "0.10.0", - "xunit.assert": "[2.4.1]", - "xunit.core": "[2.4.1]" + "Microsoft.Extensions.Configuration.Abstractions": "3.1.10", + "Microsoft.Extensions.Logging": "3.1.10", + "Microsoft.Extensions.Logging.Abstractions": "3.1.10", + "xunit.abstractions": "2.0.3" } }, "xunit.runner.visualstudio": { "type": "Direct", - "requested": "[2.4.5, )", - "resolved": "2.4.5", - "contentHash": "OwHamvBdUKgqsXfBzWiCW/O98BTx81UKzx2bieIOQI7CZFE5NEQZGi8PBQGIKawDW96xeRffiNf20SjfC0x9hw==" + "requested": "[2.5.6, )", + "resolved": "2.5.6", + "contentHash": "CW6uhMXNaQQNMSG1IWhHkBT+V5eqHqn7MP0zfNMhU9wS/sgKX7FGL3rzoaUgt26wkY3bpf7pDVw3IjXhwfiP4w==" }, "AWSSDK.Core": { "type": "Transitive", - "resolved": "3.7.103.11", - "contentHash": "twTMAAOA4ztiVtfrq2ZiRUmSnt2CW3DZwGvlbcftM6Dbsc2VGWE/hWv1Rl6GfKpEbrJxkW+FmG2o27XRTg8lqA==" + "resolved": "3.7.302.12", + "contentHash": "OaFHc2FD3vYldtdaH0Sqob3NoTXFBBMKxmq/VV3of5l+hHZmcaAjpJhh1Et0BxggeHqimVOTaoQxEO0PXQHeuw==" }, "AWSSDK.S3": { "type": "Transitive", - "resolved": "3.7.101.49", - "contentHash": "GN0pFLWqDe53UDbMEtZ4RkU64+mtJS4OsADemJ0TTaUlLhH5xJCkj5nhTa7eFG3tiIKbtdeMDUPhX47F3+UimQ==", + "resolved": "3.7.305.28", + "contentHash": "kjnmtEIddQPmS8XxwGwu1Gdc0vO+3bbohPSH59s8OdXnZlSMtsldJM+jwS5LTz4DUusFwMk3E8h0DY0TlwkVzg==", "dependencies": { - "AWSSDK.Core": "[3.7.103.11, 4.0.0)" + "AWSSDK.Core": "[3.7.302.12, 4.0.0)" } }, "Azure.Core": { "type": "Transitive", - "resolved": "1.25.0", - "contentHash": "X8Dd4sAggS84KScWIjEbFAdt2U1KDolQopTPoHVubG2y3CM54f9l6asVrP5Uy384NWXjsspPYaJgz5xHc+KvTA==", + "resolved": "1.37.0", + "contentHash": "ZSWV/ftBM/c/+Eo2hlzeEWntjqNG+8TWXX/DAKjBSWIf7nEvFILQoJL7JZx5HjypDvdNUMj5J2ji8ZpFFSghSg==", "dependencies": { "Microsoft.Bcl.AsyncInterfaces": "1.1.1", - "System.Diagnostics.DiagnosticSource": "4.6.0", + "System.Diagnostics.DiagnosticSource": "6.0.1", "System.Memory.Data": "1.0.2", "System.Numerics.Vectors": "4.5.0", "System.Text.Encodings.Web": "4.7.2", @@ -80,51 +116,61 @@ "System.Threading.Tasks.Extensions": "4.5.4" } }, + "Azure.Identity": { + "type": "Transitive", + "resolved": "1.10.4", + "contentHash": "hSvisZy9sld0Gik1X94od3+rRXCx+AKgi+iLH6fFdlnRZRePn7RtrqUGSsORiH2h8H2sc4NLTrnuUte1WL+QuQ==", + "dependencies": { + "Azure.Core": "1.36.0", + "Microsoft.Identity.Client": "4.56.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.56.0", + "System.Memory": "4.5.4", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, "Azure.Storage.Blobs": { "type": "Transitive", - "resolved": "12.14.1", - "contentHash": "DvRBWUDMB2LjdRbsBNtz/LiVIYk56hqzSooxx4uq4rCdLj2M+7Vvoa1r+W35Dz6ZXL6p+SNcgEae3oZ+CkPfow==", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", "dependencies": { - "Azure.Storage.Common": "12.13.0", + "Azure.Storage.Common": "12.18.1", "System.Text.Json": "4.7.2" } }, "Azure.Storage.Common": { "type": "Transitive", - "resolved": "12.13.0", - "contentHash": "jDv8xJWeZY2Er9zA6QO25BiGolxg87rItt9CwAp7L/V9EPJeaz8oJydaNL9Wj0+3ncceoMgdiyEv66OF8YUwWQ==", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", "dependencies": { - "Azure.Core": "1.25.0", + "Azure.Core": "1.36.0", "System.IO.Hashing": "6.0.0" } }, "Castle.Core": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "b5rRL5zeaau1y/5hIbI+6mGw3cwun16YjkHZnV9RRT5UyUIFsgLmNXJ0YnIN9p8Hw7K7AbG1q1UclQVU3DinAQ==", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", "dependencies": { - "NETStandard.Library": "1.6.1", - "System.Collections.Specialized": "4.3.0", - "System.ComponentModel": "4.3.0", - "System.ComponentModel.TypeConverter": "4.3.0", - "System.Diagnostics.TraceSource": "4.3.0", - "System.Dynamic.Runtime": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Xml.XmlDocument": "4.3.0" + "System.Diagnostics.EventLog": "6.0.0" } }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", "dependencies": { "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", - "Imageflow.Net": "0.10.2" + "Imageflow.Net": "0.12.0" } }, "Imageflow.NativeRuntime.osx-x86_64": { @@ -149,35 +195,59 @@ }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", "dependencies": { - "Microsoft.CSharp": "4.7.0", - "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", - "Newtonsoft.Json": "[13.0.3, 14.0.0)" + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" } }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==" + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.4.1", "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" }, - "Microsoft.CSharp": { + "Microsoft.Extensions.Azure": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + "resolved": "1.7.2", + "contentHash": "EJJQKICExhTERoqY/m/A2dkfit7TqOJt2Mnhreg06D2BxioGnoOYV72s5a9NfIN8K4fABl04wRkdJ4+jgSPheQ==", + "dependencies": { + "Azure.Core": "1.37.0", + "Azure.Identity": "1.10.4", + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "resolved": "3.1.10", + "contentHash": "UEfngyXt8XYhmekUza9JsWlA37pNOtZAjcK5EEKQrHo2LDKJmZVmcyAUFlkzCcf97OSr+w/MiDLifDDNQk9agw==", "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" + "Microsoft.Extensions.Primitives": "3.1.10" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" } }, "Microsoft.Extensions.DependencyInjection": { @@ -253,20 +323,38 @@ "resolved": "5.0.0", "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" }, - "Microsoft.IO.RecyclableMemoryStream": { + "Microsoft.Identity.Client": { "type": "Transitive", - "resolved": "1.2.2", - "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + "resolved": "4.56.0", + "contentHash": "rr4zbidvHy9r4NvOAs5hdd964Ao2A0pAeFBJKR95u1CJAVzbd1p6tPTXUZ+5ld0cfThiVSGvz6UHwY6JjraTpA==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.22.0" + } }, - "Microsoft.NETCore.Platforms": { + "Microsoft.Identity.Client.Extensions.Msal": { "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + "resolved": "4.56.0", + "contentHash": "H12YAzEGK55vZ+QpxUzozhW8ZZtgPDuWvgA0JbdIR9UhMUplj29JhIgE2imuH8W2Nw9D8JKygR1uxRFtpSNcrg==", + "dependencies": { + "Microsoft.Identity.Client": "4.56.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } }, - "Microsoft.NETCore.Targets": { + "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + "resolved": "6.22.0", + "contentHash": "iI+9V+2ciCrbheeLjpmjcqCnhy+r6yCoEcid3nkoFWerHgjVuT6CPM4HODUTtUPe1uwks4wcnAujJ8u+IKogHQ==" + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", @@ -286,1290 +374,450 @@ "Newtonsoft.Json": "13.0.1" } }, - "Microsoft.Win32.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "Moq": { - "type": "Transitive", - "resolved": "4.15.2", - "contentHash": "wegOsLAWslb4Qm/ER1hl7pd9M5fiolsnSVEZuNZgVl8iVGm/PRHXrLY/2YCnFmNHnMiX3X/+em404mGZi2ofQw==", - "dependencies": { - "Castle.Core": "4.4.0", - "System.Threading.Tasks.Extensions": "4.5.4" - } - }, - "NETStandard.Library": { - "type": "Transitive", - "resolved": "1.6.1", - "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.Win32.Primitives": "4.3.0", - "System.AppContext": "4.3.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Console": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.Compression.ZipFile": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.Net.Http": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Net.Sockets": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Timer": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0", - "System.Xml.XDocument": "4.3.0" - } - }, "Newtonsoft.Json": { "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" }, "NuGet.Frameworks": { "type": "Transitive", "resolved": "5.11.0", "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q==" }, - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" - }, - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" - }, - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "System.Collections.Immutable": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" - }, - "runtime.native.System": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "runtime.native.System.IO.Compression": { + "System.Configuration.ConfigurationManager": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", + "resolved": "4.4.0", + "contentHash": "gWwQv/Ug1qWJmHCmN17nAbxJYmQBM/E94QxKLksvUiiKB1Ld3Sc/eK1lgmbSjDFxkQhVuayI/cGFZhpBSodLrg==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" + "System.Security.Cryptography.ProtectedData": "4.4.0" } }, - "runtime.native.System.Net.Http": { + "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "runtime.native.System.Security.Cryptography.Apple": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.Interactive.Async": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", "dependencies": { - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" + "System.Linq.Async": "6.0.1" } }, - "runtime.native.System.Security.Cryptography.OpenSsl": { + "System.IO.FileSystem.AccessControl": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", "dependencies": { - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" } }, - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "System.IO.Hashing": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" + "resolved": "6.0.0", + "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" }, - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "System.IO.Pipelines": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { + "System.Linq.Async": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "System.Memory": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==" }, - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "System.Memory.Data": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" + "resolved": "1.0.2", + "contentHash": "JGkzeqgBsiZwKJZ1IxPNsDFZDhUvuEdX8L8BDC8N3KOj+6zMcNU28CNN59TpZE/VJYy9cP+5M+sbxtWJx3/xtw==", + "dependencies": { + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.6.0" + } }, - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "System.Numerics.Vectors": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" }, - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "System.Reflection.Metadata": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" }, - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" }, - "System.AppContext": { + "System.Security.AccessControl": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", "dependencies": { - "System.Runtime": "4.3.0" + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" } }, - "System.Buffers": { + "System.Security.Cryptography.ProtectedData": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==" }, - "System.Collections": { + "System.Security.Principal.Windows": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" }, - "System.Collections.Concurrent": { + "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.Collections.NonGeneric": { + "System.Text.Json": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "prtjIEMhGUnQq6RnPEYLpFt8AtLbp9yq2zxOSrY7KJJZrw25Fi97IzBqY7iqssbM61Ek5b8f3MG/sG1N2sN5KA==", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" } }, - "System.Collections.Specialized": { + "System.Threading.Tasks.Extensions": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Epx8PoVZR0iuOnJJDzp7pWvdfMMOAvpUo95pC4ScH2mJuXkKA2Y4aR3cG9qt2klHgSons1WFh4kcGW7cSXvrxg==", - "dependencies": { - "System.Collections.NonGeneric": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" }, - "System.ComponentModel": { + "xunit.abstractions": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VyGn1jGRZVfxnh8EdvDCi71v3bMXrsu8aYJOwoV7SNDLVhiEqwP86pPMyRGsDsxhXAm2b3o9OIqeETfN5qfezw==", - "dependencies": { - "System.Runtime": "4.3.0" - } + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" }, - "System.ComponentModel.Primitives": { + "xunit.analyzers": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "j8GUkCpM8V4d4vhLIIoBLGey2Z5bCkMVNjEZseyAlm4n5arcsJOeI3zkUP+zvZgzsbLTYh4lYeP/ZD/gdIAPrw==", - "dependencies": { - "System.ComponentModel": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0" - } + "resolved": "1.10.0", + "contentHash": "Lw8CiDy5NaAWcO6keqD7iZHYUTIuCOcoFrUHw5Sv84ITZ9gFeDybdkVdH0Y2maSlP9fUjtENyiykT44zwFQIHA==" }, - "System.ComponentModel.TypeConverter": { + "xunit.assert": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "16pQ6P+EdhcXzPiEK4kbA953Fu0MNG2ovxTZU81/qsCd1zPRsKc3uif5NgvllCY598k6bI0KUyKW8fanlfaDQg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Collections.NonGeneric": "4.3.0", - "System.Collections.Specialized": "4.3.0", - "System.ComponentModel": "4.3.0", - "System.ComponentModel.Primitives": "4.3.0", - "System.Globalization": "4.3.0", - "System.Linq": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } + "resolved": "2.6.6", + "contentHash": "74Cm9lAZOk5TKCz2MvCBCByKsS23yryOKDIMxH3XRDHXmfGM02jKZWzRA7g4mGB41GnBnv/pcWP3vUYkrCtEcg==" }, - "System.Console": { + "xunit.core": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", + "resolved": "2.6.6", + "contentHash": "tqi7RfaNBqM7t8zx6QHryuBPzmotsZXKGaWnopQG2Ez5UV7JoWuyoNdT6gLpDIcKdGYey6YTXJdSr9IXDMKwjg==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" + "xunit.extensibility.core": "[2.6.6]", + "xunit.extensibility.execution": "[2.6.6]" } }, - "System.Diagnostics.Debug": { + "xunit.extensibility.core": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", + "resolved": "2.6.6", + "contentHash": "ty6VKByzbx4Toj4/VGJLEnlmOawqZiMv0in/tLju+ftA+lbWuAWDERM+E52Jfhj4ZYHrAYVa14KHK5T+dq0XxA==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" + "xunit.abstractions": "2.0.3" } }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "4.6.0", - "contentHash": "mbBgoR0rRfl2uimsZ2avZY8g7Xnh1Mza0rJZLPcxqiMWlkGukjmRkuMJ/er+AhQuiRIh80CR/Hpeztr80seV5g==" - }, - "System.Diagnostics.Tools": { + "xunit.extensibility.execution": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", + "resolved": "2.6.6", + "contentHash": "UDjIVGj2TepVKN3n32/qXIdb3U6STwTb9L6YEwoQO2A8OxiJS5QAVv2l1aT6tDwwv/9WBmm8Khh/LyHALipcng==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" + "xunit.extensibility.core": "[2.6.6]" } }, - "System.Diagnostics.TraceSource": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VnYp1NxGx8Ww731y2LJ1vpfb/DKVNKEZ8Jsh5SgQTZREL/YpWRArgh9pI8CDLmgHspZmLL697CaLvH85qQpRiw==", + "imageflow.server": { + "type": "Project", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" + "Imageflow.AllPlatforms": "[0.12.0, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.IO.RecyclableMemoryStream": "[3.0.0, )" } }, - "System.Diagnostics.Tracing": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", + "imageflow.server.hybridcache": { + "type": "Project", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.HybridCache": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, - "System.Dynamic.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "SNVi1E/vfWUAs/WYKhE9+qlS6KqK0YVhnlT0HQtr8pMIA8YX3lwy3uPMownDwdYISBdmAF/2holEIldVp85Wag==", + "imageflow.server.storage.azureblob": { + "type": "Project", "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" + "Azure.Storage.Blobs": "[12.19.1, )", + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Azure": "[1.7.2, )" } }, - "System.Globalization": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", + "imageflow.server.storage.remotereader": { + "type": "Project", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Http": "[5.0.0, )" } }, - "System.Globalization.Calendars": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", + "imageflow.server.storage.s3": { + "type": "Project", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Runtime": "4.3.0" + "AWSSDK.S3": "[3.7.305.28, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )" } }, - "System.Globalization.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", + "imazen.abstractions": { + "type": "Project", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0" + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" } }, - "System.IO": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", + "imazen.common": { + "type": "Project", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, - "System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", + "imazen.hybridcache": { + "type": "Project", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Buffers": "4.3.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.IO.Compression": "4.3.0" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" } }, - "System.IO.Compression.ZipFile": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", + "imazen.routing": { + "type": "Project", "dependencies": { - "System.Buffers": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0" + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" } }, - "System.IO.FileSystem": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", + "imazenshared.tests": { + "type": "Project", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" + "FluentAssertions": "[6.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.AspNetCore.TestHost": "[6.0.27, )", + "Microsoft.Extensions.FileProviders.Embedded": "[2.2.0, )", + "Microsoft.NET.Test.Sdk": "[17.4.1, )", + "Moq": "[4.20.70, )", + "xunit": "[2.6.6, )" } + } + }, + "net8.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" }, - "System.IO.FileSystem.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", + "FluentAssertions": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "ZXhHT2YwP9lajrwSKbLlFqsmCCvFJMoRSK9t7sImfnCyd0OB3MhgxdoMcVqxbq1iyxD6mD2fiackWmBb7ayiXQ==", "dependencies": { - "System.Runtime": "4.3.0" + "System.Configuration.ConfigurationManager": "4.4.0" } }, - "System.IO.Hashing": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "Rfm2jYCaUeGysFEZjDe7j1R4x6Z6BzumS/vUT5a1AA/AWJuGX71PoGB0RmpyX3VmrGqVnAwtfMn39OHR8Y/5+g==" - }, - "System.IO.Pipelines": { - "type": "Transitive", - "resolved": "4.7.1", - "contentHash": "TyMTasnXQt7U4k13RQ7tA3CrfWEkvWjR635SFfnKVwwMOgClLE5mdHSkG2D13BLIdNwbPTGVQnhQB8Rk7f53Rg==" - }, - "System.Linq": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", + "Microsoft.AspNetCore.TestHost": { + "type": "Direct", + "requested": "[6.0.27, )", + "resolved": "6.0.27", + "contentHash": "KhpZ4ygPB7y/XxT+I6sk8y0Xgekc8EfdE9QH2Hvk1VDk5BK6d8tN/Ck7IshGXS34JeNdAdWd0N/beoijq5dgcw==", "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" + "System.IO.Pipelines": "6.0.3" } }, - "System.Linq.Expressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "6e22jnVntG9JLLowjY40UBPLXkKTRlDpFHmo2evN8lwZIpO89ZRGz6JRdqhnVYCaavq5KeFU2W5VKPA5y5farA==", "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Linq": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Emit.Lightweight": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0" } }, - "System.Memory.Data": { - "type": "Transitive", - "resolved": "1.0.2", - "contentHash": "JGkzeqgBsiZwKJZ1IxPNsDFZDhUvuEdX8L8BDC8N3KOj+6zMcNU28CNN59TpZE/VJYy9cP+5M+sbxtWJx3/xtw==", + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.4.1, )", + "resolved": "17.4.1", + "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", "dependencies": { - "System.Text.Encodings.Web": "4.7.2", - "System.Text.Json": "4.6.0" + "Microsoft.CodeCoverage": "17.4.1", + "Microsoft.TestPlatform.TestHost": "17.4.1" } }, - "System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", + "Moq": { + "type": "Direct", + "requested": "[4.20.70, )", + "resolved": "4.20.70", + "contentHash": "4rNnAwdpXJBuxqrOCzCyICXHSImOTRktCgCWXWykuF1qwoIsVvEnR7PjbMk/eLOxWvhmj5Kwt+kDV3RGUYcNwg==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.DiagnosticSource": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + "Castle.Core": "5.1.1" } }, - "System.Net.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.6.6, )", + "resolved": "2.6.6", + "contentHash": "MAbOOMtZIKyn2lrAmMlvhX0BhDOX/smyrTB+8WTXnSKkrmTGBS2fm8g1PZtHBPj91Dc5DJA7fY+/81TJ/yUFZw==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" + "xunit.analyzers": "1.10.0", + "xunit.assert": "2.6.6", + "xunit.core": "[2.6.6]" } }, - "System.Net.Sockets": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", + "Xunit.Extensions.Logging": { + "type": "Direct", + "requested": "[1.1.0, )", + "resolved": "1.1.0", + "contentHash": "xSTZEWnzpanm9gMYREIKWx7VnnaQVcXTL9DLL/+7qYneP+tNK7BnR6lpWB3xFOJhIE1vO1zoSXWyl2deggadew==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" + "Microsoft.Extensions.Configuration.Abstractions": "3.1.10", + "Microsoft.Extensions.Logging": "3.1.10", + "Microsoft.Extensions.Logging.Abstractions": "3.1.10", + "xunit.abstractions": "2.0.3" } }, - "System.Numerics.Vectors": { + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.5.6, )", + "resolved": "2.5.6", + "contentHash": "CW6uhMXNaQQNMSG1IWhHkBT+V5eqHqn7MP0zfNMhU9wS/sgKX7FGL3rzoaUgt26wkY3bpf7pDVw3IjXhwfiP4w==" + }, + "AWSSDK.Core": { "type": "Transitive", - "resolved": "4.5.0", - "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + "resolved": "3.7.302.12", + "contentHash": "OaFHc2FD3vYldtdaH0Sqob3NoTXFBBMKxmq/VV3of5l+hHZmcaAjpJhh1Et0BxggeHqimVOTaoQxEO0PXQHeuw==" }, - "System.ObjectModel": { + "AWSSDK.S3": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", + "resolved": "3.7.305.28", + "contentHash": "kjnmtEIddQPmS8XxwGwu1Gdc0vO+3bbohPSH59s8OdXnZlSMtsldJM+jwS5LTz4DUusFwMk3E8h0DY0TlwkVzg==", "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" + "AWSSDK.Core": "[3.7.302.12, 4.0.0)" } }, - "System.Reflection": { + "Azure.Core": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", + "resolved": "1.37.0", + "contentHash": "ZSWV/ftBM/c/+Eo2hlzeEWntjqNG+8TWXX/DAKjBSWIf7nEvFILQoJL7JZx5HjypDvdNUMj5J2ji8ZpFFSghSg==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" + "Microsoft.Bcl.AsyncInterfaces": "1.1.1", + "System.Diagnostics.DiagnosticSource": "6.0.1", + "System.Memory.Data": "1.0.2", + "System.Numerics.Vectors": "4.5.0", + "System.Text.Encodings.Web": "4.7.2", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" } }, - "System.Reflection.Emit": { + "Azure.Identity": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", + "resolved": "1.10.4", + "contentHash": "hSvisZy9sld0Gik1X94od3+rRXCx+AKgi+iLH6fFdlnRZRePn7RtrqUGSsORiH2h8H2sc4NLTrnuUte1WL+QuQ==", "dependencies": { - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" + "Azure.Core": "1.36.0", + "Microsoft.Identity.Client": "4.56.0", + "Microsoft.Identity.Client.Extensions.Msal": "4.56.0", + "System.Memory": "4.5.4", + "System.Security.Cryptography.ProtectedData": "4.7.0", + "System.Text.Json": "4.7.2", + "System.Threading.Tasks.Extensions": "4.5.4" } }, - "System.Reflection.Emit.ILGeneration": { + "Azure.Storage.Blobs": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", + "resolved": "12.19.1", + "contentHash": "x43hWFJ4sPQ23TD4piCwT+KlQpZT8pNDAzqj6yUCqh+WJ2qcQa17e1gh6ZOeT2QNFQTTDSuR56fm2bIV7i11/w==", "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" + "Azure.Storage.Common": "12.18.1", + "System.Text.Json": "4.7.2" } }, - "System.Reflection.Emit.Lightweight": { + "Azure.Storage.Common": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", + "resolved": "12.18.1", + "contentHash": "ohCslqP9yDKIn+DVjBEOBuieB1QwsUCz+BwHYNaJ3lcIsTSiI4Evnq81HcKe8CqM8qvdModbipVQKpnxpbdWqA==", "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" + "Azure.Core": "1.36.0", + "System.IO.Hashing": "6.0.0" } }, - "System.Reflection.Extensions": { + "Castle.Core": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" + "System.Diagnostics.EventLog": "6.0.0" } }, - "System.Reflection.Metadata": { + "CommunityToolkit.HighPerformance": { "type": "Transitive", - "resolved": "1.6.0", - "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" }, - "System.Reflection.Primitives": { + "Imageflow.AllPlatforms": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", + "resolved": "0.12.0", + "contentHash": "4jtDy2BEGMHKkCbH0SZ3sQacgeDgfg00HXP88Re8aDpiUy4GHMvY8mgY60iQKb6BEBP1dNTeS7UQmrwkYhdfhA==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" + "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", + "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", + "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", + "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", + "Imageflow.Net": "0.12.0" } }, - "System.Reflection.TypeExtensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Resources.ResourceManager": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "System.Runtime.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.Handles": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.InteropServices": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Runtime.InteropServices.RuntimeInformation": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } - }, - "System.Runtime.Numerics": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", - "dependencies": { - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.Apple": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Cng": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Security.Cryptography.Csp": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Linq": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", - "dependencies": { - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Cng": "4.3.0", - "System.Security.Cryptography.Csp": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Text.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Text.Encoding.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Text.Encodings.Web": { - "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "iTUgB/WtrZ1sWZs84F2hwyQhiRH6QNjQv2DkwrH+WP6RoFga2Q1m3f9/Q7FG8cck8AdHitQkmkXSY8qylcDmuA==" - }, - "System.Text.Json": { - "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" - }, - "System.Text.RegularExpressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Threading": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", - "dependencies": { - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Threading.Tasks": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Threading.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.5.4", - "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" - }, - "System.Threading.Timer": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Xml.ReaderWriter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Tasks.Extensions": "4.3.0" - } - }, - "System.Xml.XDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - }, - "System.Xml.XmlDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "lJ8AxvkX7GQxpC6GFCeBj8ThYVyQczx2+f/cWHJU8tjS7YfI6Cv6bon70jVEgs2CiFbmmM8b9j1oZVx0dSI2Ww==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - }, - "xunit.abstractions": { - "type": "Transitive", - "resolved": "2.0.3", - "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" - }, - "xunit.analyzers": { - "type": "Transitive", - "resolved": "0.10.0", - "contentHash": "4/IDFCJfIeg6bix9apmUtIMwvOsiwqdEexeO/R2D4GReIGPLIRODTpId/l4LRSrAJk9lEO3Zx1H0Zx6uohJDNg==" - }, - "xunit.assert": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "O/Oe0BS5RmSsM+LQOb041TzuPo5MdH2Rov+qXGS37X+KFG1Hxz7kopYklM5+1Y+tRGeXrOx5+Xne1RuqLFQoyQ==", - "dependencies": { - "NETStandard.Library": "1.6.1" - } - }, - "xunit.core": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "Zsj5OMU6JasNGERXZy8s72+pcheG6Q15atS5XpZXqAtULuyQiQ6XNnUsp1gyfC6WgqScqMvySiEHmHcOG6Eg0Q==", - "dependencies": { - "xunit.extensibility.core": "[2.4.1]", - "xunit.extensibility.execution": "[2.4.1]" - } - }, - "xunit.extensibility.core": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "yKZKm/8QNZnBnGZFD9SewkllHBiK0DThybQD/G4PiAmQjKtEZyHi6ET70QPU9KtSMJGRYS6Syk7EyR2EVDU4Kg==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "xunit.abstractions": "2.0.3" - } - }, - "xunit.extensibility.execution": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "7e/1jqBpcb7frLkB6XDrHCGXAbKN4Rtdb88epYxCSRQuZDRW8UtTfdTEVpdTl8s4T56e07hOBVd4G0OdCxIY2A==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "xunit.extensibility.core": "[2.4.1]" - } - }, - "imageflow.server": { - "type": "Project", - "dependencies": { - "Imageflow.AllPlatforms": "[0.10.2, )", - "Imazen.Common": "[0.1.0--notset, )" - } - }, - "imageflow.server.diskcache": { - "type": "Project", - "dependencies": { - "Imazen.DiskCache": "[0.1.0--notset, )" - } - }, - "imageflow.server.storage.azureblob": { - "type": "Project", - "dependencies": { - "Azure.Storage.Blobs": "[12.14.1, )", - "Imazen.Common": "[0.1.0--notset, )" - } - }, - "imageflow.server.storage.remotereader": { - "type": "Project", - "dependencies": { - "Imazen.Common": "[0.1.0--notset, )", - "Microsoft.Extensions.Http": "[5.0.0, )" - } - }, - "imageflow.server.storage.s3": { - "type": "Project", - "dependencies": { - "AWSSDK.S3": "[3.7.101.49, )", - "Imazen.Common": "[0.1.0--notset, )" - } - }, - "imazen.common": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" - } - }, - "imazen.common.tests": { - "type": "Project", - "dependencies": { - "Imazen.Common": "[0.1.0--notset, )", - "Microsoft.NET.Test.Sdk": "[17.4.1, )", - "Moq": "[4.15.2, )", - "xunit": "[2.4.1, )" - } - }, - "imazen.diskcache": { - "type": "Project", - "dependencies": { - "Imazen.Common": "[0.1.0--notset, )", - "Microsoft.Extensions.Logging.Abstractions": "[3.1.3, )" - } - } - }, - "net7.0": { - "coverlet.collector": { - "type": "Direct", - "requested": "[3.1.2, )", - "resolved": "3.1.2", - "contentHash": "wuLDIDKD5XMt0A7lE31JPenT7QQwZPFkP5rRpdJeblyXZ9MGLI8rYjvm5fvAKln+2/X+4IxxQDxBtwdrqKNLZw==" - }, - "Microsoft.AspNetCore.TestHost": { - "type": "Direct", - "requested": "[3.1.5, )", - "resolved": "3.1.5", - "contentHash": "o08GfbTytPirpwlNe/mIaGv7yyM+Qf0Ig0dM4Dxmzee4rPRXQl3QlhFGVW8WbtVamUXlskbTloi7lmUVwAihPw==", - "dependencies": { - "System.IO.Pipelines": "4.7.1" - } - }, - "Microsoft.Extensions.FileProviders.Embedded": { - "type": "Direct", - "requested": "[2.2.0, )", - "resolved": "2.2.0", - "contentHash": "6e22jnVntG9JLLowjY40UBPLXkKTRlDpFHmo2evN8lwZIpO89ZRGz6JRdqhnVYCaavq5KeFU2W5VKPA5y5farA==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0" - } - }, - "Microsoft.NET.Test.Sdk": { - "type": "Direct", - "requested": "[17.4.1, )", - "resolved": "17.4.1", - "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", - "dependencies": { - "Microsoft.CodeCoverage": "17.4.1", - "Microsoft.TestPlatform.TestHost": "17.4.1" - } - }, - "xunit": { - "type": "Direct", - "requested": "[2.4.1, )", - "resolved": "2.4.1", - "contentHash": "XNR3Yz9QTtec16O0aKcO6+baVNpXmOnPUxDkCY97J+8krUYxPvXT1szYYEUdKk4sB8GOI2YbAjRIOm8ZnXRfzQ==", - "dependencies": { - "xunit.analyzers": "0.10.0", - "xunit.assert": "[2.4.1]", - "xunit.core": "[2.4.1]" - } - }, - "xunit.runner.visualstudio": { - "type": "Direct", - "requested": "[2.4.5, )", - "resolved": "2.4.5", - "contentHash": "OwHamvBdUKgqsXfBzWiCW/O98BTx81UKzx2bieIOQI7CZFE5NEQZGi8PBQGIKawDW96xeRffiNf20SjfC0x9hw==" - }, - "AWSSDK.Core": { - "type": "Transitive", - "resolved": "3.7.103.11", - "contentHash": "twTMAAOA4ztiVtfrq2ZiRUmSnt2CW3DZwGvlbcftM6Dbsc2VGWE/hWv1Rl6GfKpEbrJxkW+FmG2o27XRTg8lqA==" - }, - "AWSSDK.S3": { - "type": "Transitive", - "resolved": "3.7.101.49", - "contentHash": "GN0pFLWqDe53UDbMEtZ4RkU64+mtJS4OsADemJ0TTaUlLhH5xJCkj5nhTa7eFG3tiIKbtdeMDUPhX47F3+UimQ==", - "dependencies": { - "AWSSDK.Core": "[3.7.103.11, 4.0.0)" - } - }, - "Azure.Core": { - "type": "Transitive", - "resolved": "1.25.0", - "contentHash": "X8Dd4sAggS84KScWIjEbFAdt2U1KDolQopTPoHVubG2y3CM54f9l6asVrP5Uy384NWXjsspPYaJgz5xHc+KvTA==", - "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "1.1.1", - "System.Diagnostics.DiagnosticSource": "4.6.0", - "System.Memory.Data": "1.0.2", - "System.Numerics.Vectors": "4.5.0", - "System.Text.Encodings.Web": "4.7.2", - "System.Text.Json": "4.7.2", - "System.Threading.Tasks.Extensions": "4.5.4" - } - }, - "Azure.Storage.Blobs": { - "type": "Transitive", - "resolved": "12.14.1", - "contentHash": "DvRBWUDMB2LjdRbsBNtz/LiVIYk56hqzSooxx4uq4rCdLj2M+7Vvoa1r+W35Dz6ZXL6p+SNcgEae3oZ+CkPfow==", - "dependencies": { - "Azure.Storage.Common": "12.13.0", - "System.Text.Json": "4.7.2" - } - }, - "Azure.Storage.Common": { - "type": "Transitive", - "resolved": "12.13.0", - "contentHash": "jDv8xJWeZY2Er9zA6QO25BiGolxg87rItt9CwAp7L/V9EPJeaz8oJydaNL9Wj0+3ncceoMgdiyEv66OF8YUwWQ==", - "dependencies": { - "Azure.Core": "1.25.0", - "System.IO.Hashing": "6.0.0" - } - }, - "Castle.Core": { - "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "b5rRL5zeaau1y/5hIbI+6mGw3cwun16YjkHZnV9RRT5UyUIFsgLmNXJ0YnIN9p8Hw7K7AbG1q1UclQVU3DinAQ==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "System.Collections.Specialized": "4.3.0", - "System.ComponentModel": "4.3.0", - "System.ComponentModel.TypeConverter": "4.3.0", - "System.Diagnostics.TraceSource": "4.3.0", - "System.Dynamic.Runtime": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Xml.XmlDocument": "4.3.0" - } - }, - "Imageflow.AllPlatforms": { - "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "xBE1ob69E4z4Qg3v18RJe81J9rXXWvxpUyYiOZLcZZuOnbIF35t+ToPBQIYFDNgc39NFisf1iru3NnytOGM6tg==", - "dependencies": { - "Imageflow.NativeRuntime.osx-x86_64": "2.0.0-preview8", - "Imageflow.NativeRuntime.ubuntu-x86_64": "2.0.0-preview8", - "Imageflow.NativeRuntime.win-x86": "2.0.0-preview8", - "Imageflow.NativeRuntime.win-x86_64": "2.0.0-preview8", - "Imageflow.Net": "0.10.2" - } - }, - "Imageflow.NativeRuntime.osx-x86_64": { + "Imageflow.NativeRuntime.osx-x86_64": { "type": "Transitive", "resolved": "2.0.0-preview8", "contentHash": "3wEglrMqVzlnAVBTdK6qcRySdo/4ajBP5ASRuK3yHfBqPp3ld4ke6guxuSZbgDTObIxai7KTLlsIvQZhusymUA==" @@ -1591,35 +839,59 @@ }, "Imageflow.Net": { "type": "Transitive", - "resolved": "0.10.2", - "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", "dependencies": { - "Microsoft.CSharp": "4.7.0", - "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", - "Newtonsoft.Json": "[13.0.3, 14.0.0)" + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" } }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "yuvf07qFWFqtK3P/MRkEKLhn5r2UbSpVueRziSqj0yJQIKFwG1pq9mOayK3zE5qZCTs0CbrwL9M6R8VwqyGy2w==" + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.4.1", "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" }, - "Microsoft.CSharp": { + "Microsoft.Extensions.Azure": { "type": "Transitive", - "resolved": "4.7.0", - "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + "resolved": "1.7.2", + "contentHash": "EJJQKICExhTERoqY/m/A2dkfit7TqOJt2Mnhreg06D2BxioGnoOYV72s5a9NfIN8K4fABl04wRkdJ4+jgSPheQ==", + "dependencies": { + "Azure.Core": "1.37.0", + "Azure.Identity": "1.10.4", + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0", + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "resolved": "3.1.10", + "contentHash": "UEfngyXt8XYhmekUza9JsWlA37pNOtZAjcK5EEKQrHo2LDKJmZVmcyAUFlkzCcf97OSr+w/MiDLifDDNQk9agw==", "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" + "Microsoft.Extensions.Primitives": "3.1.10" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" } }, "Microsoft.Extensions.DependencyInjection": { @@ -1695,20 +967,38 @@ "resolved": "5.0.0", "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" }, - "Microsoft.IO.RecyclableMemoryStream": { + "Microsoft.Identity.Client": { + "type": "Transitive", + "resolved": "4.56.0", + "contentHash": "rr4zbidvHy9r4NvOAs5hdd964Ao2A0pAeFBJKR95u1CJAVzbd1p6tPTXUZ+5ld0cfThiVSGvz6UHwY6JjraTpA==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.22.0" + } + }, + "Microsoft.Identity.Client.Extensions.Msal": { "type": "Transitive", - "resolved": "1.2.2", - "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + "resolved": "4.56.0", + "contentHash": "H12YAzEGK55vZ+QpxUzozhW8ZZtgPDuWvgA0JbdIR9UhMUplj29JhIgE2imuH8W2Nw9D8JKygR1uxRFtpSNcrg==", + "dependencies": { + "Microsoft.Identity.Client": "4.56.0", + "System.IO.FileSystem.AccessControl": "5.0.0", + "System.Security.Cryptography.ProtectedData": "4.5.0" + } }, - "Microsoft.NETCore.Platforms": { + "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + "resolved": "6.22.0", + "contentHash": "iI+9V+2ciCrbheeLjpmjcqCnhy+r6yCoEcid3nkoFWerHgjVuT6CPM4HODUTtUPe1uwks4wcnAujJ8u+IKogHQ==" }, - "Microsoft.NETCore.Targets": { + "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + "resolved": "3.0.0", + "contentHash": "irv0HuqoH8Ig5i2fO+8dmDNdFdsrO+DoQcedwIlb810qpZHBNQHZLW7C/AHBQDgLLpw2T96vmMAy/aE4Yj55Sg==" + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==" }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", @@ -1728,496 +1018,60 @@ "Newtonsoft.Json": "13.0.1" } }, - "Microsoft.Win32.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "Moq": { - "type": "Transitive", - "resolved": "4.15.2", - "contentHash": "wegOsLAWslb4Qm/ER1hl7pd9M5fiolsnSVEZuNZgVl8iVGm/PRHXrLY/2YCnFmNHnMiX3X/+em404mGZi2ofQw==", - "dependencies": { - "Castle.Core": "4.4.0", - "System.Threading.Tasks.Extensions": "4.5.4" - } - }, - "NETStandard.Library": { - "type": "Transitive", - "resolved": "1.6.1", - "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.Win32.Primitives": "4.3.0", - "System.AppContext": "4.3.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Console": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.Compression.ZipFile": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.Net.Http": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Net.Sockets": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Timer": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0", - "System.Xml.XDocument": "4.3.0" - } - }, "Newtonsoft.Json": { "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" }, "NuGet.Frameworks": { "type": "Transitive", "resolved": "5.11.0", "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q==" }, - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" - }, - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" - }, - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" - }, - "runtime.native.System": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", - "dependencies": { - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" - } - }, - "runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", - "dependencies": { - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" - }, - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" - }, - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" - }, - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" - }, - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" - }, - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" - }, - "System.AppContext": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Buffers": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Collections": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Collections.Concurrent": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Collections.NonGeneric": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "prtjIEMhGUnQq6RnPEYLpFt8AtLbp9yq2zxOSrY7KJJZrw25Fi97IzBqY7iqssbM61Ek5b8f3MG/sG1N2sN5KA==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Collections.Specialized": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Epx8PoVZR0iuOnJJDzp7pWvdfMMOAvpUo95pC4ScH2mJuXkKA2Y4aR3cG9qt2klHgSons1WFh4kcGW7cSXvrxg==", - "dependencies": { - "System.Collections.NonGeneric": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.ComponentModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VyGn1jGRZVfxnh8EdvDCi71v3bMXrsu8aYJOwoV7SNDLVhiEqwP86pPMyRGsDsxhXAm2b3o9OIqeETfN5qfezw==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.ComponentModel.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "j8GUkCpM8V4d4vhLIIoBLGey2Z5bCkMVNjEZseyAlm4n5arcsJOeI3zkUP+zvZgzsbLTYh4lYeP/ZD/gdIAPrw==", - "dependencies": { - "System.ComponentModel": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.ComponentModel.TypeConverter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "16pQ6P+EdhcXzPiEK4kbA953Fu0MNG2ovxTZU81/qsCd1zPRsKc3uif5NgvllCY598k6bI0KUyKW8fanlfaDQg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Collections.NonGeneric": "4.3.0", - "System.Collections.Specialized": "4.3.0", - "System.ComponentModel": "4.3.0", - "System.ComponentModel.Primitives": "4.3.0", - "System.Globalization": "4.3.0", - "System.Linq": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Console": { + "System.Collections.Immutable": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.Diagnostics.Debug": { + "System.Configuration.ConfigurationManager": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", + "resolved": "4.4.0", + "contentHash": "gWwQv/Ug1qWJmHCmN17nAbxJYmQBM/E94QxKLksvUiiKB1Ld3Sc/eK1lgmbSjDFxkQhVuayI/cGFZhpBSodLrg==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" + "System.Security.Cryptography.ProtectedData": "4.4.0" } }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "4.6.0", - "contentHash": "mbBgoR0rRfl2uimsZ2avZY8g7Xnh1Mza0rJZLPcxqiMWlkGukjmRkuMJ/er+AhQuiRIh80CR/Hpeztr80seV5g==" - }, - "System.Diagnostics.Tools": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.TraceSource": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VnYp1NxGx8Ww731y2LJ1vpfb/DKVNKEZ8Jsh5SgQTZREL/YpWRArgh9pI8CDLmgHspZmLL697CaLvH85qQpRiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } - }, - "System.Diagnostics.Tracing": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Dynamic.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "SNVi1E/vfWUAs/WYKhE9+qlS6KqK0YVhnlT0HQtr8pMIA8YX3lwy3uPMownDwdYISBdmAF/2holEIldVp85Wag==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Globalization": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Calendars": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0" - } - }, - "System.IO": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", + "resolved": "6.0.1", + "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Buffers": "4.3.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.IO.Compression": "4.3.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.IO.Compression.ZipFile": { + "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", - "dependencies": { - "System.Buffers": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0" - } + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" }, - "System.IO.FileSystem": { + "System.Interactive.Async": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" + "System.Linq.Async": "6.0.1" } }, - "System.IO.FileSystem.Primitives": { + "System.IO.FileSystem.AccessControl": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", + "resolved": "5.0.0", + "contentHash": "SxHB3nuNrpptVk+vZ/F+7OHEpoHUIKKMl02bUmYHQr1r+glbZQxs7pRtsf4ENO29TVm2TH3AEeep2fJcy92oYw==", "dependencies": { - "System.Runtime": "4.3.0" + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" } }, "System.IO.Hashing": { @@ -2227,44 +1081,21 @@ }, "System.IO.Pipelines": { "type": "Transitive", - "resolved": "4.7.1", - "contentHash": "TyMTasnXQt7U4k13RQ7tA3CrfWEkvWjR635SFfnKVwwMOgClLE5mdHSkG2D13BLIdNwbPTGVQnhQB8Rk7f53Rg==" + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" }, - "System.Linq": { + "System.Linq.Async": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" } }, - "System.Linq.Expressions": { + "System.Memory": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Linq": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Emit.Lightweight": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } + "resolved": "4.5.4", + "contentHash": "1MbJTHS1lZ4bS4FmsJjnuGJOu88ZzTT2rLvrhW7Ygic+pC0NWA+3hgAen0HRdsocuQXCkUTdFn9yHJJhsijDXw==" }, "System.Memory.Data": { "type": "Transitive", @@ -2275,439 +1106,55 @@ "System.Text.Json": "4.6.0" } }, - "System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.DiagnosticSource": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Net.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Net.Sockets": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, "System.Numerics.Vectors": { "type": "Transitive", "resolved": "4.5.0", "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" }, - "System.ObjectModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Reflection": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", - "dependencies": { - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.ILGeneration": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.Lightweight": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, "System.Reflection.Metadata": { "type": "Transitive", "resolved": "1.6.0", "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" }, - "System.Reflection.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.TypeExtensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Resources.ResourceManager": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "System.Runtime.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.Handles": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.InteropServices": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Runtime.InteropServices.RuntimeInformation": { + "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" }, - "System.Runtime.Numerics": { + "System.Security.AccessControl": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", "dependencies": { - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" + "Microsoft.NETCore.Platforms": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" } }, - "System.Security.Cryptography.Algorithms": { + "System.Security.Cryptography.ProtectedData": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.Apple": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } + "resolved": "4.7.0", + "contentHash": "ehYW0m9ptxpGWvE4zgqongBVWpSDU/JCFD4K7krxkQwSz/sFQjEXCUqpvencjy6DYDbn7Ig09R8GFffu8TtneQ==" }, - "System.Security.Cryptography.Cng": { + "System.Security.Principal.Windows": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Security.Cryptography.Csp": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Linq": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", - "dependencies": { - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Cng": "4.3.0", - "System.Security.Cryptography.Csp": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Text.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Text.Encoding.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "iTUgB/WtrZ1sWZs84F2hwyQhiRH6QNjQv2DkwrH+WP6RoFga2Q1m3f9/Q7FG8cck8AdHitQkmkXSY8qylcDmuA==" - }, - "System.Text.Json": { - "type": "Transitive", - "resolved": "4.7.2", - "contentHash": "TcMd95wcrubm9nHvJEQs70rC0H/8omiSGGpU4FQ/ZA1URIqD4pjmFJh2Mfv1yH1eHgJDWTi2hMDXwTET+zOOyg==" - }, - "System.Text.RegularExpressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Threading": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", "dependencies": { - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, - "System.Threading.Tasks": { + "System.Text.Json": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" } }, "System.Threading.Tasks.Extensions": { @@ -2715,74 +1162,6 @@ "resolved": "4.5.4", "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" }, - "System.Threading.Timer": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Xml.ReaderWriter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Tasks.Extensions": "4.3.0" - } - }, - "System.Xml.XDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - }, - "System.Xml.XmlDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "lJ8AxvkX7GQxpC6GFCeBj8ThYVyQczx2+f/cWHJU8tjS7YfI6Cv6bon70jVEgs2CiFbmmM8b9j1oZVx0dSI2Ww==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -2790,62 +1169,62 @@ }, "xunit.analyzers": { "type": "Transitive", - "resolved": "0.10.0", - "contentHash": "4/IDFCJfIeg6bix9apmUtIMwvOsiwqdEexeO/R2D4GReIGPLIRODTpId/l4LRSrAJk9lEO3Zx1H0Zx6uohJDNg==" + "resolved": "1.10.0", + "contentHash": "Lw8CiDy5NaAWcO6keqD7iZHYUTIuCOcoFrUHw5Sv84ITZ9gFeDybdkVdH0Y2maSlP9fUjtENyiykT44zwFQIHA==" }, "xunit.assert": { "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "O/Oe0BS5RmSsM+LQOb041TzuPo5MdH2Rov+qXGS37X+KFG1Hxz7kopYklM5+1Y+tRGeXrOx5+Xne1RuqLFQoyQ==", - "dependencies": { - "NETStandard.Library": "1.6.1" - } + "resolved": "2.6.6", + "contentHash": "74Cm9lAZOk5TKCz2MvCBCByKsS23yryOKDIMxH3XRDHXmfGM02jKZWzRA7g4mGB41GnBnv/pcWP3vUYkrCtEcg==" }, "xunit.core": { "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "Zsj5OMU6JasNGERXZy8s72+pcheG6Q15atS5XpZXqAtULuyQiQ6XNnUsp1gyfC6WgqScqMvySiEHmHcOG6Eg0Q==", + "resolved": "2.6.6", + "contentHash": "tqi7RfaNBqM7t8zx6QHryuBPzmotsZXKGaWnopQG2Ez5UV7JoWuyoNdT6gLpDIcKdGYey6YTXJdSr9IXDMKwjg==", "dependencies": { - "xunit.extensibility.core": "[2.4.1]", - "xunit.extensibility.execution": "[2.4.1]" + "xunit.extensibility.core": "[2.6.6]", + "xunit.extensibility.execution": "[2.6.6]" } }, "xunit.extensibility.core": { "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "yKZKm/8QNZnBnGZFD9SewkllHBiK0DThybQD/G4PiAmQjKtEZyHi6ET70QPU9KtSMJGRYS6Syk7EyR2EVDU4Kg==", + "resolved": "2.6.6", + "contentHash": "ty6VKByzbx4Toj4/VGJLEnlmOawqZiMv0in/tLju+ftA+lbWuAWDERM+E52Jfhj4ZYHrAYVa14KHK5T+dq0XxA==", "dependencies": { - "NETStandard.Library": "1.6.1", "xunit.abstractions": "2.0.3" } }, "xunit.extensibility.execution": { "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "7e/1jqBpcb7frLkB6XDrHCGXAbKN4Rtdb88epYxCSRQuZDRW8UtTfdTEVpdTl8s4T56e07hOBVd4G0OdCxIY2A==", + "resolved": "2.6.6", + "contentHash": "UDjIVGj2TepVKN3n32/qXIdb3U6STwTb9L6YEwoQO2A8OxiJS5QAVv2l1aT6tDwwv/9WBmm8Khh/LyHALipcng==", "dependencies": { - "NETStandard.Library": "1.6.1", - "xunit.extensibility.core": "[2.4.1]" + "xunit.extensibility.core": "[2.6.6]" } }, "imageflow.server": { "type": "Project", "dependencies": { - "Imageflow.AllPlatforms": "[0.10.2, )", - "Imazen.Common": "[0.1.0--notset, )" + "Imageflow.AllPlatforms": "[0.12.0, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.IO.RecyclableMemoryStream": "[3.0.0, )" } }, - "imageflow.server.diskcache": { + "imageflow.server.hybridcache": { "type": "Project", "dependencies": { - "Imazen.DiskCache": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.HybridCache": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imageflow.server.storage.azureblob": { "type": "Project", "dependencies": { - "Azure.Storage.Blobs": "[12.14.1, )", - "Imazen.Common": "[0.1.0--notset, )" + "Azure.Storage.Blobs": "[12.19.1, )", + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Azure": "[1.7.2, )" } }, "imageflow.server.storage.remotereader": { @@ -2858,30 +1237,55 @@ "imageflow.server.storage.s3": { "type": "Project", "dependencies": { - "AWSSDK.S3": "[3.7.101.49, )", - "Imazen.Common": "[0.1.0--notset, )" + "AWSSDK.S3": "[3.7.305.28, )", + "Imazen.Common": "[0.1.0--notset, )", + "Imazen.Routing": "[0.1.0--notset, )" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, - "imazen.common.tests": { + "imazen.hybridcache": { "type": "Project", "dependencies": { "Imazen.Common": "[0.1.0--notset, )", - "Microsoft.NET.Test.Sdk": "[17.4.1, )", - "Moq": "[4.15.2, )", - "xunit": "[2.4.1, )" + "System.Interactive.Async": "[6.0.1, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" } }, - "imazen.diskcache": { + "imazenshared.tests": { "type": "Project", "dependencies": { + "FluentAssertions": "[6.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", "Imazen.Common": "[0.1.0--notset, )", - "Microsoft.Extensions.Logging.Abstractions": "[3.1.3, )" + "Imazen.Routing": "[0.1.0--notset, )", + "Microsoft.AspNetCore.TestHost": "[6.0.27, )", + "Microsoft.Extensions.FileProviders.Embedded": "[2.2.0, )", + "Microsoft.NET.Test.Sdk": "[17.4.1, )", + "Moq": "[4.20.70, )", + "xunit": "[2.6.6, )" } } } diff --git a/tests/Imazen.Common.Tests/Imazen.Common.Tests.csproj b/tests/Imazen.Common.Tests/Imazen.Common.Tests.csproj deleted file mode 100644 index 54a09e24..00000000 --- a/tests/Imazen.Common.Tests/Imazen.Common.Tests.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net6.0;net7.0 - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/tests/Imazen.Common.Tests/VisibleToTests.cs b/tests/Imazen.Common.Tests/VisibleToTests.cs deleted file mode 100644 index 6976397c..00000000 --- a/tests/Imazen.Common.Tests/VisibleToTests.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("Imageflow.Server.Tests")] \ No newline at end of file diff --git a/tests/Imazen.Common.Tests/packages.lock.json b/tests/Imazen.Common.Tests/packages.lock.json deleted file mode 100644 index 1f3fc3ca..00000000 --- a/tests/Imazen.Common.Tests/packages.lock.json +++ /dev/null @@ -1,2447 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net6.0": { - "coverlet.collector": { - "type": "Direct", - "requested": "[3.1.2, )", - "resolved": "3.1.2", - "contentHash": "wuLDIDKD5XMt0A7lE31JPenT7QQwZPFkP5rRpdJeblyXZ9MGLI8rYjvm5fvAKln+2/X+4IxxQDxBtwdrqKNLZw==" - }, - "Microsoft.NET.Test.Sdk": { - "type": "Direct", - "requested": "[17.4.1, )", - "resolved": "17.4.1", - "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", - "dependencies": { - "Microsoft.CodeCoverage": "17.4.1", - "Microsoft.TestPlatform.TestHost": "17.4.1" - } - }, - "Moq": { - "type": "Direct", - "requested": "[4.15.2, )", - "resolved": "4.15.2", - "contentHash": "wegOsLAWslb4Qm/ER1hl7pd9M5fiolsnSVEZuNZgVl8iVGm/PRHXrLY/2YCnFmNHnMiX3X/+em404mGZi2ofQw==", - "dependencies": { - "Castle.Core": "4.4.0", - "System.Threading.Tasks.Extensions": "4.5.4" - } - }, - "xunit": { - "type": "Direct", - "requested": "[2.4.1, )", - "resolved": "2.4.1", - "contentHash": "XNR3Yz9QTtec16O0aKcO6+baVNpXmOnPUxDkCY97J+8krUYxPvXT1szYYEUdKk4sB8GOI2YbAjRIOm8ZnXRfzQ==", - "dependencies": { - "xunit.analyzers": "0.10.0", - "xunit.assert": "[2.4.1]", - "xunit.core": "[2.4.1]" - } - }, - "xunit.runner.visualstudio": { - "type": "Direct", - "requested": "[2.4.5, )", - "resolved": "2.4.5", - "contentHash": "OwHamvBdUKgqsXfBzWiCW/O98BTx81UKzx2bieIOQI7CZFE5NEQZGi8PBQGIKawDW96xeRffiNf20SjfC0x9hw==" - }, - "Castle.Core": { - "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "b5rRL5zeaau1y/5hIbI+6mGw3cwun16YjkHZnV9RRT5UyUIFsgLmNXJ0YnIN9p8Hw7K7AbG1q1UclQVU3DinAQ==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "System.Collections.Specialized": "4.3.0", - "System.ComponentModel": "4.3.0", - "System.ComponentModel.TypeConverter": "4.3.0", - "System.Diagnostics.TraceSource": "4.3.0", - "System.Dynamic.Runtime": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Xml.XmlDocument": "4.3.0" - } - }, - "Microsoft.CodeCoverage": { - "type": "Transitive", - "resolved": "17.4.1", - "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", - "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", - "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" - } - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", - "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", - "Microsoft.Extensions.Logging.Abstractions": "2.2.0" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", - "dependencies": { - "System.Memory": "4.5.1", - "System.Runtime.CompilerServices.Unsafe": "4.5.1" - } - }, - "Microsoft.NETCore.Platforms": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" - }, - "Microsoft.NETCore.Targets": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" - }, - "Microsoft.TestPlatform.ObjectModel": { - "type": "Transitive", - "resolved": "17.4.1", - "contentHash": "v2CwoejusooZa/DZYt7UXo+CJOvwAmqg6ZyFJeIBu+DCRDqpEtf7WYhZ/AWii0EKzANPPLU9+m148aipYQkTuA==", - "dependencies": { - "NuGet.Frameworks": "5.11.0", - "System.Reflection.Metadata": "1.6.0" - } - }, - "Microsoft.TestPlatform.TestHost": { - "type": "Transitive", - "resolved": "17.4.1", - "contentHash": "K7QXM4P4qrDKdPs/VSEKXR08QEru7daAK8vlIbhwENM3peXJwb9QgrAbtbYyyfVnX+F1m+1hntTH6aRX+h/f8g==", - "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "17.4.1", - "Newtonsoft.Json": "13.0.1" - } - }, - "Microsoft.Win32.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "NETStandard.Library": { - "type": "Transitive", - "resolved": "1.6.1", - "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.Win32.Primitives": "4.3.0", - "System.AppContext": "4.3.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Console": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.Compression.ZipFile": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.Net.Http": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Net.Sockets": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Timer": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0", - "System.Xml.XDocument": "4.3.0" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.1", - "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" - }, - "NuGet.Frameworks": { - "type": "Transitive", - "resolved": "5.11.0", - "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q==" - }, - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" - }, - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" - }, - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" - }, - "runtime.native.System": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", - "dependencies": { - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" - } - }, - "runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", - "dependencies": { - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" - }, - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" - }, - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" - }, - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" - }, - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" - }, - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" - }, - "System.AppContext": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Buffers": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Collections": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Collections.Concurrent": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Collections.NonGeneric": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "prtjIEMhGUnQq6RnPEYLpFt8AtLbp9yq2zxOSrY7KJJZrw25Fi97IzBqY7iqssbM61Ek5b8f3MG/sG1N2sN5KA==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Collections.Specialized": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Epx8PoVZR0iuOnJJDzp7pWvdfMMOAvpUo95pC4ScH2mJuXkKA2Y4aR3cG9qt2klHgSons1WFh4kcGW7cSXvrxg==", - "dependencies": { - "System.Collections.NonGeneric": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.ComponentModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VyGn1jGRZVfxnh8EdvDCi71v3bMXrsu8aYJOwoV7SNDLVhiEqwP86pPMyRGsDsxhXAm2b3o9OIqeETfN5qfezw==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.ComponentModel.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "j8GUkCpM8V4d4vhLIIoBLGey2Z5bCkMVNjEZseyAlm4n5arcsJOeI3zkUP+zvZgzsbLTYh4lYeP/ZD/gdIAPrw==", - "dependencies": { - "System.ComponentModel": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.ComponentModel.TypeConverter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "16pQ6P+EdhcXzPiEK4kbA953Fu0MNG2ovxTZU81/qsCd1zPRsKc3uif5NgvllCY598k6bI0KUyKW8fanlfaDQg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Collections.NonGeneric": "4.3.0", - "System.Collections.Specialized": "4.3.0", - "System.ComponentModel": "4.3.0", - "System.ComponentModel.Primitives": "4.3.0", - "System.Globalization": "4.3.0", - "System.Linq": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Console": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Diagnostics.Debug": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "tD6kosZnTAGdrEa0tZSuFyunMbt/5KYDnHdndJYGqZoNy00XVXyACd5d6KnE1YgYv3ne2CjtAfNXo/fwEhnKUA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Diagnostics.Tools": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.TraceSource": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VnYp1NxGx8Ww731y2LJ1vpfb/DKVNKEZ8Jsh5SgQTZREL/YpWRArgh9pI8CDLmgHspZmLL697CaLvH85qQpRiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } - }, - "System.Diagnostics.Tracing": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Dynamic.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "SNVi1E/vfWUAs/WYKhE9+qlS6KqK0YVhnlT0HQtr8pMIA8YX3lwy3uPMownDwdYISBdmAF/2holEIldVp85Wag==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Globalization": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Calendars": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0" - } - }, - "System.IO": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Buffers": "4.3.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.IO.Compression": "4.3.0" - } - }, - "System.IO.Compression.ZipFile": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", - "dependencies": { - "System.Buffers": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.IO.FileSystem": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.FileSystem.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Linq": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Linq.Expressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Linq": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Emit.Lightweight": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Memory": { - "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" - }, - "System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.DiagnosticSource": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Net.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Net.Sockets": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.ObjectModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Reflection": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", - "dependencies": { - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.ILGeneration": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.Lightweight": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "1.6.0", - "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" - }, - "System.Reflection.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.TypeExtensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Resources.ResourceManager": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "System.Runtime.CompilerServices.Unsafe": { - "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" - }, - "System.Runtime.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.Handles": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.InteropServices": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Runtime.InteropServices.RuntimeInformation": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } - }, - "System.Runtime.Numerics": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", - "dependencies": { - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.Apple": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Cng": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Security.Cryptography.Csp": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Linq": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", - "dependencies": { - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Cng": "4.3.0", - "System.Security.Cryptography.Csp": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Text.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Text.Encoding.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Text.RegularExpressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Threading": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", - "dependencies": { - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Threading.Tasks": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Threading.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.5.4", - "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" - }, - "System.Threading.Timer": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Xml.ReaderWriter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Tasks.Extensions": "4.3.0" - } - }, - "System.Xml.XDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - }, - "System.Xml.XmlDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "lJ8AxvkX7GQxpC6GFCeBj8ThYVyQczx2+f/cWHJU8tjS7YfI6Cv6bon70jVEgs2CiFbmmM8b9j1oZVx0dSI2Ww==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - }, - "xunit.abstractions": { - "type": "Transitive", - "resolved": "2.0.3", - "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" - }, - "xunit.analyzers": { - "type": "Transitive", - "resolved": "0.10.0", - "contentHash": "4/IDFCJfIeg6bix9apmUtIMwvOsiwqdEexeO/R2D4GReIGPLIRODTpId/l4LRSrAJk9lEO3Zx1H0Zx6uohJDNg==" - }, - "xunit.assert": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "O/Oe0BS5RmSsM+LQOb041TzuPo5MdH2Rov+qXGS37X+KFG1Hxz7kopYklM5+1Y+tRGeXrOx5+Xne1RuqLFQoyQ==", - "dependencies": { - "NETStandard.Library": "1.6.1" - } - }, - "xunit.core": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "Zsj5OMU6JasNGERXZy8s72+pcheG6Q15atS5XpZXqAtULuyQiQ6XNnUsp1gyfC6WgqScqMvySiEHmHcOG6Eg0Q==", - "dependencies": { - "xunit.extensibility.core": "[2.4.1]", - "xunit.extensibility.execution": "[2.4.1]" - } - }, - "xunit.extensibility.core": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "yKZKm/8QNZnBnGZFD9SewkllHBiK0DThybQD/G4PiAmQjKtEZyHi6ET70QPU9KtSMJGRYS6Syk7EyR2EVDU4Kg==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "xunit.abstractions": "2.0.3" - } - }, - "xunit.extensibility.execution": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "7e/1jqBpcb7frLkB6XDrHCGXAbKN4Rtdb88epYxCSRQuZDRW8UtTfdTEVpdTl8s4T56e07hOBVd4G0OdCxIY2A==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "xunit.extensibility.core": "[2.4.1]" - } - }, - "imazen.common": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" - } - } - }, - "net7.0": { - "coverlet.collector": { - "type": "Direct", - "requested": "[3.1.2, )", - "resolved": "3.1.2", - "contentHash": "wuLDIDKD5XMt0A7lE31JPenT7QQwZPFkP5rRpdJeblyXZ9MGLI8rYjvm5fvAKln+2/X+4IxxQDxBtwdrqKNLZw==" - }, - "Microsoft.NET.Test.Sdk": { - "type": "Direct", - "requested": "[17.4.1, )", - "resolved": "17.4.1", - "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", - "dependencies": { - "Microsoft.CodeCoverage": "17.4.1", - "Microsoft.TestPlatform.TestHost": "17.4.1" - } - }, - "Moq": { - "type": "Direct", - "requested": "[4.15.2, )", - "resolved": "4.15.2", - "contentHash": "wegOsLAWslb4Qm/ER1hl7pd9M5fiolsnSVEZuNZgVl8iVGm/PRHXrLY/2YCnFmNHnMiX3X/+em404mGZi2ofQw==", - "dependencies": { - "Castle.Core": "4.4.0", - "System.Threading.Tasks.Extensions": "4.5.4" - } - }, - "xunit": { - "type": "Direct", - "requested": "[2.4.1, )", - "resolved": "2.4.1", - "contentHash": "XNR3Yz9QTtec16O0aKcO6+baVNpXmOnPUxDkCY97J+8krUYxPvXT1szYYEUdKk4sB8GOI2YbAjRIOm8ZnXRfzQ==", - "dependencies": { - "xunit.analyzers": "0.10.0", - "xunit.assert": "[2.4.1]", - "xunit.core": "[2.4.1]" - } - }, - "xunit.runner.visualstudio": { - "type": "Direct", - "requested": "[2.4.5, )", - "resolved": "2.4.5", - "contentHash": "OwHamvBdUKgqsXfBzWiCW/O98BTx81UKzx2bieIOQI7CZFE5NEQZGi8PBQGIKawDW96xeRffiNf20SjfC0x9hw==" - }, - "Castle.Core": { - "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "b5rRL5zeaau1y/5hIbI+6mGw3cwun16YjkHZnV9RRT5UyUIFsgLmNXJ0YnIN9p8Hw7K7AbG1q1UclQVU3DinAQ==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "System.Collections.Specialized": "4.3.0", - "System.ComponentModel": "4.3.0", - "System.ComponentModel.TypeConverter": "4.3.0", - "System.Diagnostics.TraceSource": "4.3.0", - "System.Dynamic.Runtime": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Xml.XmlDocument": "4.3.0" - } - }, - "Microsoft.CodeCoverage": { - "type": "Transitive", - "resolved": "17.4.1", - "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", - "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", - "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" - } - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", - "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", - "Microsoft.Extensions.Logging.Abstractions": "2.2.0" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", - "dependencies": { - "System.Memory": "4.5.1", - "System.Runtime.CompilerServices.Unsafe": "4.5.1" - } - }, - "Microsoft.NETCore.Platforms": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" - }, - "Microsoft.NETCore.Targets": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" - }, - "Microsoft.TestPlatform.ObjectModel": { - "type": "Transitive", - "resolved": "17.4.1", - "contentHash": "v2CwoejusooZa/DZYt7UXo+CJOvwAmqg6ZyFJeIBu+DCRDqpEtf7WYhZ/AWii0EKzANPPLU9+m148aipYQkTuA==", - "dependencies": { - "NuGet.Frameworks": "5.11.0", - "System.Reflection.Metadata": "1.6.0" - } - }, - "Microsoft.TestPlatform.TestHost": { - "type": "Transitive", - "resolved": "17.4.1", - "contentHash": "K7QXM4P4qrDKdPs/VSEKXR08QEru7daAK8vlIbhwENM3peXJwb9QgrAbtbYyyfVnX+F1m+1hntTH6aRX+h/f8g==", - "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "17.4.1", - "Newtonsoft.Json": "13.0.1" - } - }, - "Microsoft.Win32.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "NETStandard.Library": { - "type": "Transitive", - "resolved": "1.6.1", - "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.Win32.Primitives": "4.3.0", - "System.AppContext": "4.3.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Console": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.Compression.ZipFile": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.Net.Http": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Net.Sockets": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Timer": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0", - "System.Xml.XDocument": "4.3.0" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.1", - "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" - }, - "NuGet.Frameworks": { - "type": "Transitive", - "resolved": "5.11.0", - "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q==" - }, - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" - }, - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" - }, - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" - }, - "runtime.native.System": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", - "dependencies": { - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" - } - }, - "runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", - "dependencies": { - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" - }, - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" - }, - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" - }, - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" - }, - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" - }, - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" - }, - "System.AppContext": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Buffers": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Collections": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Collections.Concurrent": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Collections.NonGeneric": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "prtjIEMhGUnQq6RnPEYLpFt8AtLbp9yq2zxOSrY7KJJZrw25Fi97IzBqY7iqssbM61Ek5b8f3MG/sG1N2sN5KA==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Collections.Specialized": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Epx8PoVZR0iuOnJJDzp7pWvdfMMOAvpUo95pC4ScH2mJuXkKA2Y4aR3cG9qt2klHgSons1WFh4kcGW7cSXvrxg==", - "dependencies": { - "System.Collections.NonGeneric": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.ComponentModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VyGn1jGRZVfxnh8EdvDCi71v3bMXrsu8aYJOwoV7SNDLVhiEqwP86pPMyRGsDsxhXAm2b3o9OIqeETfN5qfezw==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.ComponentModel.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "j8GUkCpM8V4d4vhLIIoBLGey2Z5bCkMVNjEZseyAlm4n5arcsJOeI3zkUP+zvZgzsbLTYh4lYeP/ZD/gdIAPrw==", - "dependencies": { - "System.ComponentModel": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.ComponentModel.TypeConverter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "16pQ6P+EdhcXzPiEK4kbA953Fu0MNG2ovxTZU81/qsCd1zPRsKc3uif5NgvllCY598k6bI0KUyKW8fanlfaDQg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Collections.NonGeneric": "4.3.0", - "System.Collections.Specialized": "4.3.0", - "System.ComponentModel": "4.3.0", - "System.ComponentModel.Primitives": "4.3.0", - "System.Globalization": "4.3.0", - "System.Linq": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Console": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Diagnostics.Debug": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "tD6kosZnTAGdrEa0tZSuFyunMbt/5KYDnHdndJYGqZoNy00XVXyACd5d6KnE1YgYv3ne2CjtAfNXo/fwEhnKUA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Diagnostics.Tools": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.TraceSource": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VnYp1NxGx8Ww731y2LJ1vpfb/DKVNKEZ8Jsh5SgQTZREL/YpWRArgh9pI8CDLmgHspZmLL697CaLvH85qQpRiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } - }, - "System.Diagnostics.Tracing": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Dynamic.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "SNVi1E/vfWUAs/WYKhE9+qlS6KqK0YVhnlT0HQtr8pMIA8YX3lwy3uPMownDwdYISBdmAF/2holEIldVp85Wag==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Globalization": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Calendars": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0" - } - }, - "System.IO": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Buffers": "4.3.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.IO.Compression": "4.3.0" - } - }, - "System.IO.Compression.ZipFile": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", - "dependencies": { - "System.Buffers": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.IO.FileSystem": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.FileSystem.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Linq": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Linq.Expressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Linq": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Emit.Lightweight": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Memory": { - "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" - }, - "System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.DiagnosticSource": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Net.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Net.Sockets": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.ObjectModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Reflection": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", - "dependencies": { - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.ILGeneration": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.Lightweight": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "1.6.0", - "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" - }, - "System.Reflection.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.TypeExtensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Resources.ResourceManager": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "System.Runtime.CompilerServices.Unsafe": { - "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" - }, - "System.Runtime.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.Handles": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.InteropServices": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Runtime.InteropServices.RuntimeInformation": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } - }, - "System.Runtime.Numerics": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", - "dependencies": { - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.Apple": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Cng": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Security.Cryptography.Csp": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Linq": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", - "dependencies": { - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Cng": "4.3.0", - "System.Security.Cryptography.Csp": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Text.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Text.Encoding.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Text.RegularExpressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Threading": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", - "dependencies": { - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Threading.Tasks": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Threading.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.5.4", - "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==" - }, - "System.Threading.Timer": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Xml.ReaderWriter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Tasks.Extensions": "4.3.0" - } - }, - "System.Xml.XDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - }, - "System.Xml.XmlDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "lJ8AxvkX7GQxpC6GFCeBj8ThYVyQczx2+f/cWHJU8tjS7YfI6Cv6bon70jVEgs2CiFbmmM8b9j1oZVx0dSI2Ww==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - }, - "xunit.abstractions": { - "type": "Transitive", - "resolved": "2.0.3", - "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" - }, - "xunit.analyzers": { - "type": "Transitive", - "resolved": "0.10.0", - "contentHash": "4/IDFCJfIeg6bix9apmUtIMwvOsiwqdEexeO/R2D4GReIGPLIRODTpId/l4LRSrAJk9lEO3Zx1H0Zx6uohJDNg==" - }, - "xunit.assert": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "O/Oe0BS5RmSsM+LQOb041TzuPo5MdH2Rov+qXGS37X+KFG1Hxz7kopYklM5+1Y+tRGeXrOx5+Xne1RuqLFQoyQ==", - "dependencies": { - "NETStandard.Library": "1.6.1" - } - }, - "xunit.core": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "Zsj5OMU6JasNGERXZy8s72+pcheG6Q15atS5XpZXqAtULuyQiQ6XNnUsp1gyfC6WgqScqMvySiEHmHcOG6Eg0Q==", - "dependencies": { - "xunit.extensibility.core": "[2.4.1]", - "xunit.extensibility.execution": "[2.4.1]" - } - }, - "xunit.extensibility.core": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "yKZKm/8QNZnBnGZFD9SewkllHBiK0DThybQD/G4PiAmQjKtEZyHi6ET70QPU9KtSMJGRYS6Syk7EyR2EVDU4Kg==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "xunit.abstractions": "2.0.3" - } - }, - "xunit.extensibility.execution": { - "type": "Transitive", - "resolved": "2.4.1", - "contentHash": "7e/1jqBpcb7frLkB6XDrHCGXAbKN4Rtdb88epYxCSRQuZDRW8UtTfdTEVpdTl8s4T56e07hOBVd4G0OdCxIY2A==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "xunit.extensibility.core": "[2.4.1]" - } - }, - "imazen.common": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" - } - } - } - } -} \ No newline at end of file diff --git a/tests/Imazen.HybridCache.Benchmark/Imazen.HybridCache.Benchmark.csproj b/tests/Imazen.HybridCache.Benchmark/Imazen.HybridCache.Benchmark.csproj index b993ac67..1a95de9c 100644 --- a/tests/Imazen.HybridCache.Benchmark/Imazen.HybridCache.Benchmark.csproj +++ b/tests/Imazen.HybridCache.Benchmark/Imazen.HybridCache.Benchmark.csproj @@ -2,8 +2,8 @@ Exe - net6.0 + diff --git a/tests/Imazen.HybridCache.Benchmark/Program.cs b/tests/Imazen.HybridCache.Benchmark/Program.cs index d1908aee..28cf676c 100644 --- a/tests/Imazen.HybridCache.Benchmark/Program.cs +++ b/tests/Imazen.HybridCache.Benchmark/Program.cs @@ -46,7 +46,7 @@ private static async Task TestAsyncMediumLimitedCacheWavesMetaStore(Cancellation { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions(null) { Subfolders = 2048, AsyncCacheOptions = new AsyncCacheOptions() @@ -91,7 +91,7 @@ private static async Task TestSyncVeryLimitedCacheWavesMetaStore(CancellationTok { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions("HybridCache",null) { Subfolders = 1, AsyncCacheOptions = new AsyncCacheOptions() @@ -133,7 +133,7 @@ private static async Task TestMassiveFileQuantityMetaStore(CancellationToken can { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions(null) { AsyncCacheOptions = new AsyncCacheOptions() { @@ -169,7 +169,7 @@ private static async Task TestMassiveFileQuantity(CancellationToken cancellation { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions(null) { AsyncCacheOptions = new AsyncCacheOptions() { @@ -202,7 +202,7 @@ private static async Task TestSyncVeryLimitedCacheWaves(CancellationToken cancel { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions(null) { AsyncCacheOptions = new AsyncCacheOptions() { @@ -236,7 +236,7 @@ private static async Task TestRandomAsyncCache(CancellationToken cancellationTok { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions(null) { AsyncCacheOptions = new AsyncCacheOptions() { @@ -270,7 +270,7 @@ private static async Task TestRandomAsyncVeryLimitedCache(CancellationToken canc { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions(null) { AsyncCacheOptions = new AsyncCacheOptions() { @@ -303,7 +303,7 @@ private static async Task TestRandomSynchronousVeryLimitedCache(CancellationToke { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions("HybridCache", null) { AsyncCacheOptions = new AsyncCacheOptions() { @@ -335,7 +335,7 @@ private static async Task TestRandomSynchronousLimitedCache(CancellationToken ca { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions(null) { AsyncCacheOptions = new AsyncCacheOptions() { @@ -365,7 +365,7 @@ private static async Task TestRandomSynchronousNoEviction(bool getContentType, C { var options = new TestParams() { - CacheOptions = new HybridCacheOptions(null) + CacheOptions = new HybridCacheAdvancedOptions(null) { AsyncCacheOptions = new AsyncCacheOptions() { @@ -404,7 +404,7 @@ private class TestParams internal bool RetrieveContentType { get; set; } internal bool DisplayLog { get; set; } - internal HybridCacheOptions CacheOptions { get; set; } = new HybridCacheOptions(null); + internal HybridCacheAdvancedOptions CacheOptions { get; set; } = new HybridCacheAdvancedOptions(null); internal bool UseMetaStore { get; set; } public int Seed { get; set; } @@ -427,7 +427,6 @@ private static async Task TestRandom(TestParams options, CancellationToken cance try { options.CacheOptions.PhysicalCacheDir = path; - options.MetaStoreOptions.DatabaseDir = path; for (var reboot = 0; reboot < options.RebootCount; reboot++) { @@ -437,8 +436,8 @@ private static async Task TestRandom(TestParams options, CancellationToken cance var logger = loggerFactory.CreateLogger(); - ICacheDatabase database = new MetaStore.MetaStore(options.MetaStoreOptions, options.CacheOptions, logger); - HybridCache cache = new HybridCache(database, options.CacheOptions, logger); + ICacheDatabase database = new MetaStore.MetaStore(options.MetaStoreOptions, options.CacheOptions, logger); + HybridCache cache = new HybridCache(options.CacheOptions,database, logger); try { Console.Write("Starting cache..."); diff --git a/tests/Imazen.HybridCache.Benchmark/packages.lock.json b/tests/Imazen.HybridCache.Benchmark/packages.lock.json index 247e553c..d2e1ab08 100644 --- a/tests/Imazen.HybridCache.Benchmark/packages.lock.json +++ b/tests/Imazen.HybridCache.Benchmark/packages.lock.json @@ -1,6 +1,229 @@ { "version": 1, "dependencies": { + ".NETStandard,Version=v2.0": { + "MELT": { + "type": "Direct", + "requested": "[0.8.0, )", + "resolved": "0.8.0", + "contentHash": "G6I+Sth0iOMHl8VAhf3F7uuk+mONrbQC3xsuC63MSf8xJHGhaLAPSpYDWmV34ifBjwLh10KrYtBVCe/Lu71oTw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "2.1.0", + "Microsoft.Extensions.Logging": "2.1.0" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Direct", + "requested": "[2.2.1, )", + "resolved": "2.2.1", + "contentHash": "T5ahjOqWFMTSb9wFHKFNAcGXm35BxbUbwARtAPLSSPPFehcLz5mwDsKO1RR9R2aZ2Lk1BNQC7Ja63onOBE6rpA==", + "dependencies": { + "System.Memory": "4.5.5" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "gqQviLfuA31PheEGi+XJoZc1bc9H9RsPa9Gq9XuDct7XGWSR9eVXjK5Sg7CSUPhTFHSuxUFY12wcTYLZ4zM1hg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "kuZbZMMHb7ibzhLdn9/R1+PAAFKntlF10tOw4loB8VuQkHvSrBE6IzW1rhBLsEdmLXOgi2zFbwcXFrxzSM6ybA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "VOM1pPMi9+7/4Vc9aPLU8btHOBQy1+AvpqxLxFI2OVtqGv+1klPaV59g9R6aSt2U7ijfB3TjvAO4Tc/cn9/hxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Primitives": "2.1.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.hybridcache": { + "type": "Project", + "dependencies": { + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + } + }, "net6.0": { "MELT": { "type": "Direct", @@ -18,6 +241,17 @@ "resolved": "2.2.1", "contentHash": "T5ahjOqWFMTSb9wFHKFNAcGXm35BxbUbwARtAPLSSPPFehcLz5mwDsKO1RR9R2aZ2Lk1BNQC7Ja63onOBE6rpA==" }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "2.1.0", @@ -108,26 +342,233 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.1" } }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, "System.Memory": { "type": "Transitive", "resolved": "4.5.1", "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" }, "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.hybridcache": { + "type": "Project", + "dependencies": { + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + } + }, + "net8.0": { + "MELT": { + "type": "Direct", + "requested": "[0.8.0, )", + "resolved": "0.8.0", + "contentHash": "G6I+Sth0iOMHl8VAhf3F7uuk+mONrbQC3xsuC63MSf8xJHGhaLAPSpYDWmV34ifBjwLh10KrYtBVCe/Lu71oTw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "2.1.0", + "Microsoft.Extensions.Logging": "2.1.0" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Direct", + "requested": "[2.2.1, )", + "resolved": "2.2.1", + "contentHash": "T5ahjOqWFMTSb9wFHKFNAcGXm35BxbUbwARtAPLSSPPFehcLz5mwDsKO1RR9R2aZ2Lk1BNQC7Ja63onOBE6rpA==" + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "SS8ce1GYQTkZoOq5bskqQ+m7xiXQjnKRiGfVNZkkX2SX0HpXNRsKnSUaywRRuCje3v2KT9xeacsM3J9/G2exsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "Fls0O54Ielz1DiVYpcmiUpeizN1iKGGI5yAWAoShfmUvMcQ8jAGOK1a+DaflHA5hN9IOKvmSos0yewDYAIY0ZA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "gqQviLfuA31PheEGi+XJoZc1bc9H9RsPa9Gq9XuDct7XGWSR9eVXjK5Sg7CSUPhTFHSuxUFY12wcTYLZ4zM1hg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "kuZbZMMHb7ibzhLdn9/R1+PAAFKntlF10tOw4loB8VuQkHvSrBE6IzW1rhBLsEdmLXOgi2zFbwcXFrxzSM6ybA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "2.1.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Logging.Abstractions": "2.1.0", + "Microsoft.Extensions.Options": "2.1.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "VOM1pPMi9+7/4Vc9aPLU8btHOBQy1+AvpqxLxFI2OVtqGv+1klPaV59g9R6aSt2U7ijfB3TjvAO4Tc/cn9/hxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0", + "Microsoft.Extensions.Primitives": "2.1.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "System.Memory": { "type": "Transitive", "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imazen.hybridcache": { "type": "Project", "dependencies": { - "Imazen.Common": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" } } } diff --git a/tests/Imazen.HybridCache.Tests/AsyncCacheTests.cs b/tests/Imazen.HybridCache.Tests/AsyncCacheTests.cs index 6ffc0fb4..67885f27 100644 --- a/tests/Imazen.HybridCache.Tests/AsyncCacheTests.cs +++ b/tests/Imazen.HybridCache.Tests/AsyncCacheTests.cs @@ -1,9 +1,14 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; +using Imazen.Abstractions.Blobs; +using Imazen.Abstractions.Resulting; using Imazen.Common.Concurrency; using Imazen.Common.Extensibility.StreamCache; +using Imazen.Common.Extensibility.Support; +using Imazen.HybridCache.MetaStore; using Xunit; namespace Imazen.HybridCache.Tests @@ -12,6 +17,11 @@ public class AsyncCacheTests { internal class NullCacheManager : ICacheCleanupManager { + public long EstimateFileSizeOnDisk(long byteCount) + { + return byteCount; + } + public void NotifyUsed(CacheEntry cacheEntry){} public Task GetContentType(CacheEntry cacheEntry, CancellationToken cancellationToken) => null; public Task TryReserveSpace(CacheEntry cacheEntry, string contentType, int byteCount, bool allowEviction, @@ -25,7 +35,38 @@ public Task MarkFileCreated(CacheEntry cacheEntry, string contentType, long reco public Task GetRecordReference(CacheEntry cacheEntry, CancellationToken cancellationToken) { - return null; + return Task.FromResult(null); + } + + public Task TryReserveSpace(CacheEntry cacheEntry, CacheDatabaseRecord newRecord, bool allowEviction, + AsyncLockProvider writeLocks, CancellationToken cancellationToken) + { + return Task.FromResult(new ReserveSpaceResult(){Success = true}); + } + + public int GetAccessCountKey(CacheEntry cacheEntry) + { + return 0; + } + + public Task MarkFileCreated(CacheEntry cacheEntry, DateTime createdDate, Func createIfMissing) + { + return Task.FromResult(true); + } + + public Task>> CacheSearchByTag(string tag, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task>>> CachePurgeByTag(string tag, AsyncLockProvider writeLocks, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public Task CacheDelete(string relativePath, AsyncLockProvider writeLocks, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); } } diff --git a/tests/Imazen.HybridCache.Tests/HybridCacheTests.cs b/tests/Imazen.HybridCache.Tests/HybridCacheTests.cs index efa409f2..aa1c781e 100644 --- a/tests/Imazen.HybridCache.Tests/HybridCacheTests.cs +++ b/tests/Imazen.HybridCache.Tests/HybridCacheTests.cs @@ -16,15 +16,17 @@ public async void SmokeTest() var cancellationToken = CancellationToken.None; var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}"); Directory.CreateDirectory(path); - var cacheOptions = new HybridCacheOptions(path) + var cacheOptions = new HybridCacheAdvancedOptions("HybridCache",path) { - AsyncCacheOptions = new AsyncCacheOptions() + AsyncCacheOptions = new AsyncCacheOptions { - MaxQueuedBytes = 0 + MaxQueuedBytes = 0, + UniqueName = "HybridCache" } }; var database = new MetaStore.MetaStore(new MetaStoreOptions(path), cacheOptions, null); - HybridCache cache = new HybridCache(database,cacheOptions, null); + HybridCache hybridCache = new HybridCache(database,cacheOptions, null); + var cache = new LegacyStreamCacheAdapter(hybridCache); try { await cache.StartAsync(cancellationToken); @@ -46,14 +48,14 @@ Task DataProvider(CancellationToken token) Assert.Equal(contentType, result2.ContentType); Assert.NotNull(result2.Data); - Assert.NotNull(((AsyncCache.AsyncCacheResult)result2).CreatedAt); + Assert.NotNull(((AsyncCache.AsyncCacheResultOld)result2).CreatedAt); await result2.Data.DisposeAsync(); await cache.AsyncCache.AwaitEnqueuedTasks(); var result3 = await cache.GetOrCreateBytes(key, DataProvider, cancellationToken, true); Assert.Equal("DiskHit", result3.Status); Assert.Equal(contentType, result3.ContentType); - Assert.NotNull(((AsyncCache.AsyncCacheResult)result3).CreatedAt); + Assert.NotNull(((AsyncCache.AsyncCacheResultOld)result3).CreatedAt); Assert.NotNull(result3.Data); await result3.Data.DisposeAsync(); var key2 = new byte[] {2, 1, 2, 3}; diff --git a/tests/Imazen.HybridCache.Tests/Imazen.HybridCache.Tests.csproj b/tests/Imazen.HybridCache.Tests/Imazen.HybridCache.Tests.csproj index 0dd92585..6ff62ad0 100644 --- a/tests/Imazen.HybridCache.Tests/Imazen.HybridCache.Tests.csproj +++ b/tests/Imazen.HybridCache.Tests/Imazen.HybridCache.Tests.csproj @@ -1,8 +1,7 @@ - - net6.0;net7.0 - + + @@ -11,7 +10,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Imazen.HybridCache.Tests/packages.lock.json b/tests/Imazen.HybridCache.Tests/packages.lock.json index ea053545..4fe37a89 100644 --- a/tests/Imazen.HybridCache.Tests/packages.lock.json +++ b/tests/Imazen.HybridCache.Tests/packages.lock.json @@ -1,12 +1,254 @@ { "version": 1, "dependencies": { + ".NETStandard,Version=v2.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.4.1, )", + "resolved": "17.4.1", + "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", + "dependencies": { + "Microsoft.CodeCoverage": "17.4.1" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "XNR3Yz9QTtec16O0aKcO6+baVNpXmOnPUxDkCY97J+8krUYxPvXT1szYYEUdKk4sB8GOI2YbAjRIOm8ZnXRfzQ==", + "dependencies": { + "xunit.analyzers": "0.10.0", + "xunit.assert": "[2.4.1]", + "xunit.core": "[2.4.1]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.4.3, )", + "resolved": "2.4.3", + "contentHash": "kZZSmOmKA8OBlAJaquPXnJJLM9RwQ27H7BMVqfMLUcTi9xHinWGJiWksa3D4NEtz0wZ/nxd2mogObvBgJKCRhQ==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.4.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.4", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "0.10.0", + "contentHash": "4/IDFCJfIeg6bix9apmUtIMwvOsiwqdEexeO/R2D4GReIGPLIRODTpId/l4LRSrAJk9lEO3Zx1H0Zx6uohJDNg==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "O/Oe0BS5RmSsM+LQOb041TzuPo5MdH2Rov+qXGS37X+KFG1Hxz7kopYklM5+1Y+tRGeXrOx5+Xne1RuqLFQoyQ==", + "dependencies": { + "NETStandard.Library": "1.6.1" + } + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "Zsj5OMU6JasNGERXZy8s72+pcheG6Q15atS5XpZXqAtULuyQiQ6XNnUsp1gyfC6WgqScqMvySiEHmHcOG6Eg0Q==", + "dependencies": { + "xunit.extensibility.core": "[2.4.1]", + "xunit.extensibility.execution": "[2.4.1]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "yKZKm/8QNZnBnGZFD9SewkllHBiK0DThybQD/G4PiAmQjKtEZyHi6ET70QPU9KtSMJGRYS6Syk7EyR2EVDU4Kg==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "7e/1jqBpcb7frLkB6XDrHCGXAbKN4Rtdb88epYxCSRQuZDRW8UtTfdTEVpdTl8s4T56e07hOBVd4G0OdCxIY2A==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "xunit.extensibility.core": "[2.4.1]" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[6.*, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Buffers": "[4.*, )", + "System.Memory": "[4.*, )", + "System.Text.Encodings.Web": "[6.*, )", + "System.Threading.Tasks.Extensions": "[4.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.hybridcache": { + "type": "Project", + "dependencies": { + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" + } + } + }, "net6.0": { "coverlet.collector": { "type": "Direct", - "requested": "[3.1.2, )", - "resolved": "3.1.2", - "contentHash": "wuLDIDKD5XMt0A7lE31JPenT7QQwZPFkP5rRpdJeblyXZ9MGLI8rYjvm5fvAKln+2/X+4IxxQDxBtwdrqKNLZw==" + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" }, "Microsoft.NET.Test.Sdk": { "type": "Direct", @@ -18,6 +260,12 @@ "Microsoft.TestPlatform.TestHost": "17.4.1" } }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, "xunit": { "type": "Direct", "requested": "[2.4.1, )", @@ -35,6 +283,11 @@ "resolved": "2.4.3", "contentHash": "kZZSmOmKA8OBlAJaquPXnJJLM9RwQ27H7BMVqfMLUcTi9xHinWGJiWksa3D4NEtz0wZ/nxd2mogObvBgJKCRhQ==" }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.4.1", @@ -427,6 +680,14 @@ "System.Runtime.InteropServices": "4.3.0" } }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, "System.IO": { "type": "Transitive", "resolved": "4.3.0", @@ -512,6 +773,14 @@ "System.Runtime.Extensions": "4.3.0" } }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, "System.Linq.Expressions": { "type": "Transitive", "resolved": "4.3.0", @@ -713,8 +982,8 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" }, "System.Runtime.Extensions": { "type": "Transitive", @@ -939,6 +1208,14 @@ "System.Text.Encoding": "4.3.0" } }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, "System.Text.RegularExpressions": { "type": "Transitive", "resolved": "4.3.0", @@ -1072,25 +1349,34 @@ "xunit.extensibility.core": "[2.4.1]" } }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imazen.hybridcache": { "type": "Project", "dependencies": { - "Imazen.Common": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" } } }, - "net7.0": { + "net8.0": { "coverlet.collector": { "type": "Direct", - "requested": "[3.1.2, )", - "resolved": "3.1.2", - "contentHash": "wuLDIDKD5XMt0A7lE31JPenT7QQwZPFkP5rRpdJeblyXZ9MGLI8rYjvm5fvAKln+2/X+4IxxQDxBtwdrqKNLZw==" + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" }, "Microsoft.NET.Test.Sdk": { "type": "Direct", @@ -1102,6 +1388,12 @@ "Microsoft.TestPlatform.TestHost": "17.4.1" } }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, "xunit": { "type": "Direct", "requested": "[2.4.1, )", @@ -1119,6 +1411,11 @@ "resolved": "2.4.3", "contentHash": "kZZSmOmKA8OBlAJaquPXnJJLM9RwQ27H7BMVqfMLUcTi9xHinWGJiWksa3D4NEtz0wZ/nxd2mogObvBgJKCRhQ==" }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.4.1", @@ -1511,6 +1808,14 @@ "System.Runtime.InteropServices": "4.3.0" } }, + "System.Interactive.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "f8H1O4ZWDQo344y5NQU76G4SIjWMuKDVXL9OM1dg6K5YZnLkc8iCdQDybBvMcC6ufk61jzXGVAX6UCDu0qDSjA==", + "dependencies": { + "System.Linq.Async": "6.0.1" + } + }, "System.IO": { "type": "Transitive", "resolved": "4.3.0", @@ -1596,6 +1901,14 @@ "System.Runtime.Extensions": "4.3.0" } }, + "System.Linq.Async": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "0YhHcaroWpQ9UCot3Pizah7ryAzQhNvobLMSxeDIGmnXfkQn8u5owvpOH0K6EVB+z9L7u6Cc4W17Br/+jyttEQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, "System.Linq.Expressions": { "type": "Transitive", "resolved": "4.3.0", @@ -1797,8 +2110,8 @@ }, "System.Runtime.CompilerServices.Unsafe": { "type": "Transitive", - "resolved": "4.5.1", - "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" }, "System.Runtime.Extensions": { "type": "Transitive", @@ -2023,6 +2336,14 @@ "System.Text.Encoding": "4.3.0" } }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, "System.Text.RegularExpressions": { "type": "Transitive", "resolved": "4.3.0", @@ -2156,16 +2477,25 @@ "xunit.extensibility.core": "[2.4.1]" } }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, "imazen.common": { "type": "Project", "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" } }, "imazen.hybridcache": { "type": "Project", "dependencies": { - "Imazen.Common": "[0.1.0--notset, )" + "Imazen.Common": "[0.1.0--notset, )", + "System.Interactive.Async": "[6.0.1, )" } } } diff --git a/tests/Imazen.Routing.Tests/Class1.cs b/tests/Imazen.Routing.Tests/Class1.cs new file mode 100644 index 00000000..e96a2584 --- /dev/null +++ b/tests/Imazen.Routing.Tests/Class1.cs @@ -0,0 +1,5 @@ +namespace Imazen.Routing.Tests; + +public class Class1 +{ +} \ No newline at end of file diff --git a/tests/Imazen.Routing.Tests/Imazen.Routing.Tests.csproj b/tests/Imazen.Routing.Tests/Imazen.Routing.Tests.csproj new file mode 100644 index 00000000..9d2a84aa --- /dev/null +++ b/tests/Imazen.Routing.Tests/Imazen.Routing.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/tests/Imazen.Routing.Tests/packages.lock.json b/tests/Imazen.Routing.Tests/packages.lock.json new file mode 100644 index 00000000..ade925ed --- /dev/null +++ b/tests/Imazen.Routing.Tests/packages.lock.json @@ -0,0 +1,925 @@ +{ + "version": 1, + "dependencies": { + ".NETFramework,Version=v4.8": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "ZXhHT2YwP9lajrwSKbLlFqsmCCvFJMoRSK9t7sImfnCyd0OB3MhgxdoMcVqxbq1iyxD6mD2fiackWmBb7ayiXQ==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.0" + } + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "6e22jnVntG9JLLowjY40UBPLXkKTRlDpFHmo2evN8lwZIpO89ZRGz6JRdqhnVYCaavq5KeFU2W5VKPA5y5farA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.4.1, )", + "resolved": "17.4.1", + "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", + "dependencies": { + "Microsoft.CodeCoverage": "17.4.1" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.70, )", + "resolved": "4.20.70", + "contentHash": "4rNnAwdpXJBuxqrOCzCyICXHSImOTRktCgCWXWykuF1qwoIsVvEnR7PjbMk/eLOxWvhmj5Kwt+kDV3RGUYcNwg==", + "dependencies": { + "Castle.Core": "5.1.1", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.6.6, )", + "resolved": "2.6.6", + "contentHash": "MAbOOMtZIKyn2lrAmMlvhX0BhDOX/smyrTB+8WTXnSKkrmTGBS2fm8g1PZtHBPj91Dc5DJA7fY+/81TJ/yUFZw==", + "dependencies": { + "xunit.analyzers": "1.10.0", + "xunit.assert": "2.6.6", + "xunit.core": "[2.6.6]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.5.6, )", + "resolved": "2.5.6", + "contentHash": "CW6uhMXNaQQNMSG1IWhHkBT+V5eqHqn7MP0zfNMhU9wS/sgKX7FGL3rzoaUgt26wkY3bpf7pDVw3IjXhwfiP4w==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.8.0" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==" + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==", + "dependencies": { + "Microsoft.Bcl.HashCode": "1.1.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Imageflow.Net": { + "type": "Transitive", + "resolved": "0.10.2", + "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "dependencies": { + "Microsoft.CSharp": "4.7.0", + "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", + "Newtonsoft.Json": "[13.0.3, 14.0.0)" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "W8DPQjkMScOMTtJbPwmPyj9c3zYSFGawDW3jwlBOOsnY+EzZFLgNQ/UMkK35JmkNOVPdCyPr2Tw7Vv9N+KA3ZQ==", + "dependencies": { + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "MalY0Y/uM/LjXtHfX/26l2VtN4LDNZ2OE3aumNOHDLsT4fNYy2hiHXI4CXCqKpNUNm7iJ2brrc4J89UdaL56FA==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" + }, + "Microsoft.CSharp": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "1.2.2", + "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.8.0", + "contentHash": "AYy6vlpGMfz5kOFq99L93RGbqftW/8eQTqjT9iGXW6s9MRP3UdtY8idJ8rJcjeSja8A18IhIro5YnH3uv1nz4g==", + "dependencies": { + "NuGet.Frameworks": "6.5.0", + "System.Reflection.Metadata": "1.6.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "6.5.0", + "contentHash": "QWINE2x3MbTODsWT1Gh71GaGb5icBz4chS8VYvTgsBnsi8esgN6wtHhydd7fvToWECYGq7T4cgBBDiKD/363fg==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Threading.Tasks.Extensions": "4.5.4" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Numerics.Vectors": "4.5.0", + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==", + "dependencies": { + "System.Collections.Immutable": "1.5.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Buffers": "4.5.1", + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.5.4", + "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.5.3" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.10.0", + "contentHash": "Lw8CiDy5NaAWcO6keqD7iZHYUTIuCOcoFrUHw5Sv84ITZ9gFeDybdkVdH0Y2maSlP9fUjtENyiykT44zwFQIHA==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "74Cm9lAZOk5TKCz2MvCBCByKsS23yryOKDIMxH3XRDHXmfGM02jKZWzRA7g4mGB41GnBnv/pcWP3vUYkrCtEcg==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "tqi7RfaNBqM7t8zx6QHryuBPzmotsZXKGaWnopQG2Ez5UV7JoWuyoNdT6gLpDIcKdGYey6YTXJdSr9IXDMKwjg==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]", + "xunit.extensibility.execution": "[2.6.6]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "ty6VKByzbx4Toj4/VGJLEnlmOawqZiMv0in/tLju+ftA+lbWuAWDERM+E52Jfhj4ZYHrAYVa14KHK5T+dq0XxA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "UDjIVGj2TepVKN3n32/qXIdb3U6STwTb9L6YEwoQO2A8OxiJS5QAVv2l1aT6tDwwv/9WBmm8Khh/LyHALipcng==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "[5.0.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )", + "System.Buffers": "[4.5.1, )", + "System.Memory": "[4.5.5, )", + "System.Text.Encodings.Web": "[8.0.0, )", + "System.Threading.Tasks.Extensions": "[4.5.4, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.2.2, )", + "Imageflow.Net": "[0.10.2, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Primitives": "[8.0.0, )", + "System.Collections.Immutable": "[8.0.0, )", + "System.IO.Pipelines": "[8.0.0, )", + "System.Threading.Tasks.Extensions": "[4.5.4, )" + } + } + }, + "net6.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "ZXhHT2YwP9lajrwSKbLlFqsmCCvFJMoRSK9t7sImfnCyd0OB3MhgxdoMcVqxbq1iyxD6mD2fiackWmBb7ayiXQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "4.4.0" + } + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "o08GfbTytPirpwlNe/mIaGv7yyM+Qf0Ig0dM4Dxmzee4rPRXQl3QlhFGVW8WbtVamUXlskbTloi7lmUVwAihPw==", + "dependencies": { + "System.IO.Pipelines": "4.7.1" + } + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "6e22jnVntG9JLLowjY40UBPLXkKTRlDpFHmo2evN8lwZIpO89ZRGz6JRdqhnVYCaavq5KeFU2W5VKPA5y5farA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.4.1, )", + "resolved": "17.4.1", + "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", + "dependencies": { + "Microsoft.CodeCoverage": "17.4.1", + "Microsoft.TestPlatform.TestHost": "17.4.1" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.70, )", + "resolved": "4.20.70", + "contentHash": "4rNnAwdpXJBuxqrOCzCyICXHSImOTRktCgCWXWykuF1qwoIsVvEnR7PjbMk/eLOxWvhmj5Kwt+kDV3RGUYcNwg==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.6.6, )", + "resolved": "2.6.6", + "contentHash": "MAbOOMtZIKyn2lrAmMlvhX0BhDOX/smyrTB+8WTXnSKkrmTGBS2fm8g1PZtHBPj91Dc5DJA7fY+/81TJ/yUFZw==", + "dependencies": { + "xunit.analyzers": "1.10.0", + "xunit.assert": "2.6.6", + "xunit.core": "[2.6.6]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.5.6, )", + "resolved": "2.5.6", + "contentHash": "CW6uhMXNaQQNMSG1IWhHkBT+V5eqHqn7MP0zfNMhU9wS/sgKX7FGL3rzoaUgt26wkY3bpf7pDVw3IjXhwfiP4w==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "Imageflow.Net": { + "type": "Transitive", + "resolved": "0.10.2", + "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "dependencies": { + "Microsoft.CSharp": "4.7.0", + "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", + "Newtonsoft.Json": "[13.0.3, 14.0.0)" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" + }, + "Microsoft.CSharp": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "1.2.2", + "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "v2CwoejusooZa/DZYt7UXo+CJOvwAmqg6ZyFJeIBu+DCRDqpEtf7WYhZ/AWii0EKzANPPLU9+m148aipYQkTuA==", + "dependencies": { + "NuGet.Frameworks": "5.11.0", + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "K7QXM4P4qrDKdPs/VSEKXR08QEru7daAK8vlIbhwENM3peXJwb9QgrAbtbYyyfVnX+F1m+1hntTH6aRX+h/f8g==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.4.1", + "Newtonsoft.Json": "13.0.1" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "5.11.0", + "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gWwQv/Ug1qWJmHCmN17nAbxJYmQBM/E94QxKLksvUiiKB1Ld3Sc/eK1lgmbSjDFxkQhVuayI/cGFZhpBSodLrg==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "4.4.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "cJV7ScGW7EhatRsjehfvvYVBvtiSMKgN8bOVI0bQhnF5bU7vnHVIsH49Kva7i7GWaWYvmEzkYVk1TC+gZYBEog==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.10.0", + "contentHash": "Lw8CiDy5NaAWcO6keqD7iZHYUTIuCOcoFrUHw5Sv84ITZ9gFeDybdkVdH0Y2maSlP9fUjtENyiykT44zwFQIHA==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "74Cm9lAZOk5TKCz2MvCBCByKsS23yryOKDIMxH3XRDHXmfGM02jKZWzRA7g4mGB41GnBnv/pcWP3vUYkrCtEcg==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "tqi7RfaNBqM7t8zx6QHryuBPzmotsZXKGaWnopQG2Ez5UV7JoWuyoNdT6gLpDIcKdGYey6YTXJdSr9IXDMKwjg==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]", + "xunit.extensibility.execution": "[2.6.6]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "ty6VKByzbx4Toj4/VGJLEnlmOawqZiMv0in/tLju+ftA+lbWuAWDERM+E52Jfhj4ZYHrAYVa14KHK5T+dq0XxA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "UDjIVGj2TepVKN3n32/qXIdb3U6STwTb9L6YEwoQO2A8OxiJS5QAVv2l1aT6tDwwv/9WBmm8Khh/LyHALipcng==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )", + "System.Text.Encodings.Web": "[8.0.0, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.2.2, )", + "Imageflow.Net": "[0.10.2, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Primitives": "[8.0.0, )", + "System.Collections.Immutable": "[8.0.0, )", + "System.IO.Pipelines": "[8.0.0, )" + } + } + }, + "net8.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "ZXhHT2YwP9lajrwSKbLlFqsmCCvFJMoRSK9t7sImfnCyd0OB3MhgxdoMcVqxbq1iyxD6mD2fiackWmBb7ayiXQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "4.4.0" + } + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "o08GfbTytPirpwlNe/mIaGv7yyM+Qf0Ig0dM4Dxmzee4rPRXQl3QlhFGVW8WbtVamUXlskbTloi7lmUVwAihPw==", + "dependencies": { + "System.IO.Pipelines": "4.7.1" + } + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "6e22jnVntG9JLLowjY40UBPLXkKTRlDpFHmo2evN8lwZIpO89ZRGz6JRdqhnVYCaavq5KeFU2W5VKPA5y5farA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.4.1, )", + "resolved": "17.4.1", + "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", + "dependencies": { + "Microsoft.CodeCoverage": "17.4.1", + "Microsoft.TestPlatform.TestHost": "17.4.1" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.70, )", + "resolved": "4.20.70", + "contentHash": "4rNnAwdpXJBuxqrOCzCyICXHSImOTRktCgCWXWykuF1qwoIsVvEnR7PjbMk/eLOxWvhmj5Kwt+kDV3RGUYcNwg==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.6.6, )", + "resolved": "2.6.6", + "contentHash": "MAbOOMtZIKyn2lrAmMlvhX0BhDOX/smyrTB+8WTXnSKkrmTGBS2fm8g1PZtHBPj91Dc5DJA7fY+/81TJ/yUFZw==", + "dependencies": { + "xunit.analyzers": "1.10.0", + "xunit.assert": "2.6.6", + "xunit.core": "[2.6.6]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.5.6, )", + "resolved": "2.5.6", + "contentHash": "CW6uhMXNaQQNMSG1IWhHkBT+V5eqHqn7MP0zfNMhU9wS/sgKX7FGL3rzoaUgt26wkY3bpf7pDVw3IjXhwfiP4w==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "Imageflow.Net": { + "type": "Transitive", + "resolved": "0.10.2", + "contentHash": "4KtF92PRHCU8Gm3wYEGJd+Uk0YBqXUY2CFe2P3N3P8UiG6vSIQRDKUV9LjgstEsis95/9OE4ItfJTpMessJIRQ==", + "dependencies": { + "Microsoft.CSharp": "4.7.0", + "Microsoft.IO.RecyclableMemoryStream": "[1.2.2, 3.0.0)", + "Newtonsoft.Json": "[13.0.3, 14.0.0)" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" + }, + "Microsoft.CSharp": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "1.2.2", + "contentHash": "LA4RBTStohA0hAAs6oKchmIC5M5Mjd5MwfB7vbbl+312N5kXj8abTGOgwZy6ASJYLCiqiiK5kHS0hDGEgfkB8g==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "v2CwoejusooZa/DZYt7UXo+CJOvwAmqg6ZyFJeIBu+DCRDqpEtf7WYhZ/AWii0EKzANPPLU9+m148aipYQkTuA==", + "dependencies": { + "NuGet.Frameworks": "5.11.0", + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "K7QXM4P4qrDKdPs/VSEKXR08QEru7daAK8vlIbhwENM3peXJwb9QgrAbtbYyyfVnX+F1m+1hntTH6aRX+h/f8g==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.4.1", + "Newtonsoft.Json": "13.0.1" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "5.11.0", + "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gWwQv/Ug1qWJmHCmN17nAbxJYmQBM/E94QxKLksvUiiKB1Ld3Sc/eK1lgmbSjDFxkQhVuayI/cGFZhpBSodLrg==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "4.4.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "cJV7ScGW7EhatRsjehfvvYVBvtiSMKgN8bOVI0bQhnF5bU7vnHVIsH49Kva7i7GWaWYvmEzkYVk1TC+gZYBEog==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.10.0", + "contentHash": "Lw8CiDy5NaAWcO6keqD7iZHYUTIuCOcoFrUHw5Sv84ITZ9gFeDybdkVdH0Y2maSlP9fUjtENyiykT44zwFQIHA==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "74Cm9lAZOk5TKCz2MvCBCByKsS23yryOKDIMxH3XRDHXmfGM02jKZWzRA7g4mGB41GnBnv/pcWP3vUYkrCtEcg==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "tqi7RfaNBqM7t8zx6QHryuBPzmotsZXKGaWnopQG2Ez5UV7JoWuyoNdT6gLpDIcKdGYey6YTXJdSr9IXDMKwjg==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]", + "xunit.extensibility.execution": "[2.6.6]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "ty6VKByzbx4Toj4/VGJLEnlmOawqZiMv0in/tLju+ftA+lbWuAWDERM+E52Jfhj4ZYHrAYVa14KHK5T+dq0XxA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "UDjIVGj2TepVKN3n32/qXIdb3U6STwTb9L6YEwoQO2A8OxiJS5QAVv2l1aT6tDwwv/9WBmm8Khh/LyHALipcng==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )", + "System.Text.Encodings.Web": "[8.0.0, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.2.2, )", + "Imageflow.Net": "[0.10.2, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "Microsoft.Extensions.Primitives": "[8.0.0, )", + "System.Collections.Immutable": "[8.0.0, )", + "System.IO.Pipelines": "[8.0.0, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/ImazenShared.Tests/Abstractions/Concurrency/ConcurrencyHelperTests.cs b/tests/ImazenShared.Tests/Abstractions/Concurrency/ConcurrencyHelperTests.cs new file mode 100644 index 00000000..b1979c17 --- /dev/null +++ b/tests/ImazenShared.Tests/Abstractions/Concurrency/ConcurrencyHelperTests.cs @@ -0,0 +1,83 @@ +namespace Imazen.Routing.Tests.Helpers; + +using Xunit; +using System.Threading.Tasks; +using System.Collections.Generic; +using Imazen.Routing.Helpers; +using System; + +public class ConcurrencyHelpersTests +{ + [Fact] + public async Task WhenAnyMatchesOrDefault_AllTasksFailToMatch_ReturnsNull() + { + var tasks = new List> + { + Task.FromResult(1), + Task.FromResult(2), + Task.FromResult(3) + }; + + var result = await ConcurrencyHelpers.WhenAnyMatchesOrDefault(tasks, i => i > 3); + + Assert.Equal(default(int), result); + } + + [Fact] + public async Task WhenAnyMatchesOrDefault_AnyTaskMatches_ReturnsThatTaskResult() + { + var tasks = new List> + { + Task.FromResult(1), + Task.FromResult(2), + Task.FromResult(3) + }; + + var result = await ConcurrencyHelpers.WhenAnyMatchesOrDefault(tasks, i => i == 2); + + Assert.Equal(2, result); + } + [Fact] + public async void WhenAnyMatchesOrDefault_IgnoresFailedTasks() + { + var tasks = new List> + { + Task.FromException(new Exception("Test exception")), + Task.FromResult(2), + Task.FromException(new Exception("Test exception")) + }; + + var result = await ConcurrencyHelpers.WhenAnyMatchesOrDefault(tasks, i => i == 2); + + Assert.Equal(2, result); + } + + + + [Fact] + public async Task WhenAnyMatchesOrDefault_OuterTaskIsCancelled_ThrowsTaskCanceledException() + { + var cts = new CancellationTokenSource(); + var tasks = new List> + { + Task.Run(int () => { + // Throw the waiter cancellation token. + cts.Cancel(); cts.Token.ThrowIfCancellationRequested(); + return 0; + }, CancellationToken.None) + }; + + await Assert.ThrowsAnyAsync(() => ConcurrencyHelpers.WhenAnyMatchesOrDefault(tasks, i => true, cts.Token)); + } + + [Fact] + public async Task WhenAnyMatchesOrDefault_TaskIsFaulted_ReturnsDefault() + { + var tasks = new List> + { + Task.FromException(new Exception("Test exception")) + }; + + Assert.Equal(default, await ConcurrencyHelpers.WhenAnyMatchesOrDefault(tasks, i => true)); + } +} \ No newline at end of file diff --git a/tests/ImazenShared.Tests/Abstractions/Concurrency/NonOverlappingAsyncRunnerTests.cs b/tests/ImazenShared.Tests/Abstractions/Concurrency/NonOverlappingAsyncRunnerTests.cs new file mode 100644 index 00000000..4cb3060b --- /dev/null +++ b/tests/ImazenShared.Tests/Abstractions/Concurrency/NonOverlappingAsyncRunnerTests.cs @@ -0,0 +1,296 @@ +namespace Imazen.Routing.Caching.Health.Tests; + +using Xunit; +using System; +using System.Threading; +using System.Threading.Tasks; +using Imazen.Routing.Caching.Health; + +public class NonOverlappingAsyncRunnerTests +{ + private class ArbitraryException : Exception { } + private async ValueTask TestTask30(CancellationToken ct) + { + await Task.Delay(30, ct); + return 1; + } + private async ValueTask TestTask80(CancellationToken ct) + { + await Task.Delay(80, ct); + return 1; + } + + [Fact] + public async Task RunNonOverlappingAsync_ShouldStartNewTask_WhenNoTaskIsRunning() + { + var runner = new NonOverlappingAsyncRunner(TestTask30); + var result = await runner.RunNonOverlappingAsync(); + Assert.Equal(1, result); + } + + [Fact] + public async Task RunNonOverlappingAsync_DoesNotStartNewTask_WhenTaskRunning() + { + int executionCount = 0; + + async ValueTask TestOverlappingTask(CancellationToken ct) + { + await Task.Delay(50, ct); + Interlocked.Increment(ref executionCount); + return 1; + } + + var runner = new NonOverlappingAsyncRunner(TestOverlappingTask); + var task1 = runner.RunNonOverlappingAsync(); + var task2 = runner.RunNonOverlappingAsync(); + var result1 = await task1; + var result2 = await task2; + Assert.Equal(1, result1); + Assert.Equal(1, result2); + Assert.Equal(1, executionCount); // Assert that the task was only executed once + } + + // Test fire and forget + [Fact] + public async Task RunNonOverlappingAsync_ShouldNotStartNewTask_WhenFireAndForgetIsUsed() + { + int executionCount = 0; + + async ValueTask TestOverlappingTask(CancellationToken ct) + { + await Task.Delay(50, ct); + Interlocked.Increment(ref executionCount); + return 1; + } + + var runner = new NonOverlappingAsyncRunner(TestOverlappingTask); + runner.FireAndForget(); + + var task1 = runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(500)); + var task2 = runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(500)); + var result1 = await task1; + var result2 = await task2; + Assert.Equal(1, result1); + Assert.Equal(1, result2); + Assert.Equal(1, executionCount); // Assert that the task was only executed once + } + + [Fact] + public async Task RunNonOverlappingAsync_CancelsTask_WhenProxyTimeoutReached() + { + var runner = new NonOverlappingAsyncRunner(TestTask80); + await Assert.ThrowsAsync(async () => await runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(5))); + await runner.StopAsync(); + } + + [Fact] + public async Task RunNonOverlappingAsync_CancelsTask_WhenProxyCancellationRequested() + { + var runner = new NonOverlappingAsyncRunner(TestTask30); + var cts = new CancellationTokenSource(); + var task = runner.RunNonOverlappingAsync(default, cts.Token); + cts.Cancel(); + await Assert.ThrowsAsync(async () => await task); + await runner.StopAsync(); + } + + [Fact] + public async Task RunNonOverlappingAsync_ThrowsExceptionForSynchronousTask_WhenTaskThrowsException() + { + var runner = new NonOverlappingAsyncRunner(_ => throw new ArbitraryException()); + await Assert.ThrowsAsync(async () => await runner.RunNonOverlappingAsync()); + await Assert.ThrowsAsync(async () => await runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(500))); + await Assert.ThrowsAsync(async () => await runner.RunNonOverlappingAsync(default, new CancellationTokenSource().Token)); + } + + [Fact] + public async Task RunNonOverlappingAsync_ReturnsThrownException_WhenSynchronousTaskThrowsException_AndProxyCancellationRequested() + { + var runner = new NonOverlappingAsyncRunner(_ => throw new ArbitraryException()); + var cts = new CancellationTokenSource(); + await Assert.ThrowsAsync(async () => + { + var task = runner.RunNonOverlappingAsync(default, cts.Token); + cts.Cancel(); + await task; + }); + } + private static async ValueTask TestTaskWith10MsDelayedException(CancellationToken ct) + { + await Task.Delay(10, ct); + throw new ArbitraryException(); + } + + [Fact] + public async Task RunNonOverlappingAsync_ThrowsTaskCanceledException_WhenTaskThrowsDelayedException_AndProxyCancellationRequestedEarlier() + { + var runner = new NonOverlappingAsyncRunner(TestTaskWith10MsDelayedException); + var cts = new CancellationTokenSource(); + var task = runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(500), cts.Token); + cts.Cancel(); // Cancel before task completes + var _ = await Assert.ThrowsAsync(async () => await task); + await runner.StopAsync(); + } + + [Fact] + public async Task RunNonOverlappingAsync_Exception_WhenTaskThrowsException_AndTimeoutAndCancellationUsedLater() + { + var runner = new NonOverlappingAsyncRunner(TestTaskWith10MsDelayedException); + var cts = new CancellationTokenSource(); + var task = runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(1000), cts.Token); + await Task.Delay(500); + cts.Cancel(); + await Assert.ThrowsAsync(async () => await task); + } + + // Test repeated calls of Run like 5 times, including reusing after cancellation and exception + [Fact] + public async Task RunNonOverlappingAsync_MultipleTimesAfterCancelAndExceptions() + { + int executionCount = 0; + int throwNextCount = 0; + + var runner = new NonOverlappingAsyncRunner(async ct => + { + await Task.Delay(50, ct); + if (ct.IsCancellationRequested) throw new TaskCanceledException(); + Interlocked.Increment(ref executionCount); + if (throwNextCount > 0) + { + throwNextCount--; + throw new ArbitraryException(); + } + return 1; + }); + var cts = new CancellationTokenSource(); + // try with task exception first + throwNextCount = 1; + var task = runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(1000), cts.Token); + await Assert.ThrowsAsync(async () => await task); + Assert.Equal(1, executionCount); + // Now try again, timeout being first (never guaranteed, the scheduler may be busy) + task = runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(5), cts.Token); + await Assert.ThrowsAsync(async () => await task); + Assert.Equal(1, executionCount); + // Now try again with cancellation being first + task = runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(1000), cts.Token); + cts.Cancel(); + await Assert.ThrowsAsync(async () => await task); + Assert.Equal(1, executionCount); + // Now try again with exception being first + throwNextCount = 1; + task = runner.RunNonOverlappingAsync(TimeSpan.FromMilliseconds(1000), default); + await Assert.ThrowsAsync(async () => await task); + Assert.Equal(2, executionCount); + // Now try again spamming, no exceptions + for (int i = 0; i < 5; i++) + { + task = runner.RunNonOverlappingAsync(default, default); + await task; + Assert.Equal(3 + i, executionCount); + } + executionCount = 0; + // Now try fire and forget 10x, expecting only one execution + for (int i = 0; i < 10; i++) + { + runner.FireAndForget(); + } + await runner.RunNonOverlappingAsync(default, default); + Assert.Equal(1, executionCount); + + runner.Dispose(); + } + + // Now test dispose + [Fact] + public async Task Dispose_StopsTask_WhenTaskIsRunning() + { + int completionCount = 0; + int startedCount = 0; + int cancelledCount = 0; + async ValueTask TestOverlappingTask(CancellationToken ct) + { + try + { + Interlocked.Increment(ref startedCount); + await Task.Delay(50, ct); + Interlocked.Increment(ref completionCount); + return 1; + }catch(TaskCanceledException) + { + Interlocked.Increment(ref cancelledCount); + return 0; + } + } + + var runner = new NonOverlappingAsyncRunner(TestOverlappingTask, true); + Assert.Equal(1, await runner.RunNonOverlappingAsync()); + Assert.Equal(1, completionCount); // Assert that the task was only executed once + // fire and forget - may not have started + runner.FireAndForget(); + Assert.Equal(2, startedCount); + + runner.Dispose(); + + Assert.True(cancelledCount == 1 || completionCount == 2); + } + + // test dispose with the last task being cancelled + [Fact] + public async Task Dispose_StopsTask_WhenTaskIsRunning_AndTaskIsCancelled() + { + + var runner = new NonOverlappingAsyncRunner(async ct => + { + await Task.Delay(1, ct); + throw new TaskCanceledException(); + }); + try + { + await runner.RunNonOverlappingAsync(); + } + catch (TaskCanceledException) + { + // ignored + } + + await runner.StopAsync(); + } + + // We want to do a lot of parallel testing, parallel calls to RunNonOverlappingAsync, and FireAndForget + // And we want to StopAsync + // and verify that all tasks are stopped + [Fact] + public async Task Dispose_StopsAllTasks_WhenMultipleTasksAreRunning() + { + int completionCount = 0; + int startedCount = 0; + int cancelledCount = 0; + async ValueTask TestOverlappingTask(CancellationToken ct) + { + try + { + Interlocked.Increment(ref startedCount); + await Task.Delay(50, ct); + Interlocked.Increment(ref completionCount); + return 1; + }catch(TaskCanceledException) + { + Interlocked.Increment(ref cancelledCount); + throw; + } + } + + var runner = new NonOverlappingAsyncRunner(TestOverlappingTask); + var tasks = new Task[100]; + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = runner.RunNonOverlappingAsync().AsTask(); + } + await runner.StopAsync(); + // ensure all proxy tasks are cancelled + await Assert.ThrowsAsync(async () => await Task.WhenAll(tasks)); + } + + +} \ No newline at end of file diff --git a/tests/Imazen.Common.Tests/AsyncLockProviderTests.cs b/tests/ImazenShared.Tests/AsyncLockProviderTests.cs similarity index 96% rename from tests/Imazen.Common.Tests/AsyncLockProviderTests.cs rename to tests/ImazenShared.Tests/AsyncLockProviderTests.cs index 0205d39b..e1c44447 100644 --- a/tests/Imazen.Common.Tests/AsyncLockProviderTests.cs +++ b/tests/ImazenShared.Tests/AsyncLockProviderTests.cs @@ -1,9 +1,4 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Imazen.Common.Concurrency; -using Moq; using Xunit; namespace Imazen.Common.Tests diff --git a/tests/Imazen.Common.Tests/ConcurrentBitArrayTests.cs b/tests/ImazenShared.Tests/ConcurrentBitArrayTests.cs similarity index 99% rename from tests/Imazen.Common.Tests/ConcurrentBitArrayTests.cs rename to tests/ImazenShared.Tests/ConcurrentBitArrayTests.cs index 97159579..678c5970 100644 --- a/tests/Imazen.Common.Tests/ConcurrentBitArrayTests.cs +++ b/tests/ImazenShared.Tests/ConcurrentBitArrayTests.cs @@ -1,4 +1,3 @@ -using System; using Xunit; using Imazen.Common.Instrumentation.Support; diff --git a/tests/Imazen.HybridCache.Tests/HashBasedPathBuilderTests.cs b/tests/ImazenShared.Tests/HashBasedPathBuilderTests.cs similarity index 98% rename from tests/Imazen.HybridCache.Tests/HashBasedPathBuilderTests.cs rename to tests/ImazenShared.Tests/HashBasedPathBuilderTests.cs index 4b988082..a2adc42c 100644 --- a/tests/Imazen.HybridCache.Tests/HashBasedPathBuilderTests.cs +++ b/tests/ImazenShared.Tests/HashBasedPathBuilderTests.cs @@ -1,3 +1,4 @@ +using Imazen.Common.Extensibility.Support; using Xunit; namespace Imazen.HybridCache.Tests diff --git a/tests/ImazenShared.Tests/ImazenShared.Tests.csproj b/tests/ImazenShared.Tests/ImazenShared.Tests.csproj new file mode 100644 index 00000000..e3e8cfaf --- /dev/null +++ b/tests/ImazenShared.Tests/ImazenShared.Tests.csproj @@ -0,0 +1,24 @@ + + + + Imazen.Tests + + net8.0;net48;net6.0 + + net8.0;net6.0 + + + + + + + + + + + + + + + + diff --git a/tests/Imazen.Common.Tests/Licensing/DataStructureTests.cs b/tests/ImazenShared.Tests/Licensing/DataStructureTests.cs similarity index 98% rename from tests/Imazen.Common.Tests/Licensing/DataStructureTests.cs rename to tests/ImazenShared.Tests/Licensing/DataStructureTests.cs index c50bfd6e..54ba7998 100644 --- a/tests/Imazen.Common.Tests/Licensing/DataStructureTests.cs +++ b/tests/ImazenShared.Tests/Licensing/DataStructureTests.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; +using System.Diagnostics; using Xunit; -using System.Threading; -using System.Threading.Tasks; using Imazen.Common.Instrumentation.Support; using Imazen.Common.Instrumentation.Support.Clamping; using Imazen.Common.Instrumentation.Support.PercentileSinks; diff --git a/tests/Imazen.Common.Tests/Licensing/LicenseManagerTests.cs b/tests/ImazenShared.Tests/Licensing/LicenseManagerTests.cs similarity index 98% rename from tests/Imazen.Common.Tests/Licensing/LicenseManagerTests.cs rename to tests/ImazenShared.Tests/Licensing/LicenseManagerTests.cs index 21eba290..1b9c4ed4 100644 --- a/tests/Imazen.Common.Tests/Licensing/LicenseManagerTests.cs +++ b/tests/ImazenShared.Tests/Licensing/LicenseManagerTests.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; +using System.Net; using System.Reflection; using Imazen.Common.ExtensionMethods; using Imazen.Common.Licensing; @@ -263,7 +259,7 @@ public void Test_Remote_License_Success() Mock.Verify(httpHandler); Assert.StartsWith( - "https://s3-us-west-2.amazonaws.com/licenses.imazen.net/v1/licenses/latest/", + "https://s3.us-west-2.amazonaws.com/licenses.imazen.net/v1/licenses/latest/", invokedUri.ToString()); diff --git a/tests/Imazen.Common.Tests/Licensing/LicenseStrings.cs b/tests/ImazenShared.Tests/Licensing/LicenseStrings.cs similarity index 99% rename from tests/Imazen.Common.Tests/Licensing/LicenseStrings.cs rename to tests/ImazenShared.Tests/Licensing/LicenseStrings.cs index ced47606..a44e0a05 100644 --- a/tests/Imazen.Common.Tests/Licensing/LicenseStrings.cs +++ b/tests/ImazenShared.Tests/Licensing/LicenseStrings.cs @@ -1,15 +1,12 @@ -using System.Collections.Generic; -using System.Linq; - -namespace Imazen.Common.Tests.Licensing +namespace Imazen.Common.Tests.Licensing { static class LicenseStrings { public class LicenseSet { - public string Name { get; set; } - public string Placeholder { get; set; } - public string Remote { get; set; } + public required string Name { get; set; } + public required string Placeholder { get; set; } + public required string Remote { get; set; } } diff --git a/tests/Imazen.Common.Tests/Licensing/LicenseVerifierTests.cs b/tests/ImazenShared.Tests/Licensing/LicenseVerifierTests.cs similarity index 100% rename from tests/Imazen.Common.Tests/Licensing/LicenseVerifierTests.cs rename to tests/ImazenShared.Tests/Licensing/LicenseVerifierTests.cs diff --git a/tests/Imazen.Common.Tests/Licensing/StringCacheMem.cs b/tests/ImazenShared.Tests/Licensing/StringCacheMem.cs similarity index 98% rename from tests/Imazen.Common.Tests/Licensing/StringCacheMem.cs rename to tests/ImazenShared.Tests/Licensing/StringCacheMem.cs index c314c4e6..25dc33ca 100644 --- a/tests/Imazen.Common.Tests/Licensing/StringCacheMem.cs +++ b/tests/ImazenShared.Tests/Licensing/StringCacheMem.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Concurrent; using Imazen.Common.Persistence; diff --git a/tests/Imazen.Common.Tests/Licensing/TestHelpers.cs b/tests/ImazenShared.Tests/Licensing/TestHelpers.cs similarity index 79% rename from tests/Imazen.Common.Tests/Licensing/TestHelpers.cs rename to tests/ImazenShared.Tests/Licensing/TestHelpers.cs index d912b23c..e8b29459 100644 --- a/tests/Imazen.Common.Tests/Licensing/TestHelpers.cs +++ b/tests/ImazenShared.Tests/Licensing/TestHelpers.cs @@ -1,13 +1,7 @@ -using System; -using System.Diagnostics; -using System.Linq; +using System.Diagnostics; using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; using Moq; using Moq.Protected; -using System.Collections.Generic; using Imazen.Common.Issues; using Imazen.Common.Licensing; using Imazen.Common.Persistence; @@ -16,16 +10,10 @@ namespace Imazen.Common.Tests.Licensing { - class FakeClock : ILicenseClock + class FakeClock(string date, string buildDate) : ILicenseClock { - DateTimeOffset now; - readonly DateTimeOffset built; - - public FakeClock(string date, string buildDate) - { - now = DateTimeOffset.Parse(date); - built = DateTimeOffset.Parse(buildDate); - } + private DateTimeOffset now = DateTimeOffset.Parse(date); + private readonly DateTimeOffset built = DateTimeOffset.Parse(buildDate); public void AdvanceSeconds(long seconds) { now = now.AddSeconds(seconds); } public DateTimeOffset GetUtcNow() => now; @@ -38,18 +26,11 @@ public FakeClock(string date, string buildDate) /// /// Time advances normally, but starting from the given date instead of now /// - class OffsetClock : ILicenseClock + internal class OffsetClock(string date, string buildDate) : ILicenseClock { - TimeSpan offset; - readonly long ticksOffset; - readonly DateTimeOffset built; - - public OffsetClock(string date, string buildDate) - { - offset = DateTimeOffset.UtcNow - DateTimeOffset.Parse(date); - ticksOffset = Stopwatch.GetTimestamp() - 1; - built = DateTimeOffset.Parse(buildDate); - } + private TimeSpan offset = DateTimeOffset.UtcNow - DateTimeOffset.Parse(date); + private readonly long ticksOffset = Stopwatch.GetTimestamp() - 1; + private readonly DateTimeOffset built = DateTimeOffset.Parse(buildDate); public void AdvanceSeconds(int seconds) { offset += new TimeSpan(0,0, seconds); } public DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow - offset; @@ -59,9 +40,9 @@ public OffsetClock(string date, string buildDate) public DateTimeOffset? GetAssemblyWriteDate() => built; } - class StringCacheEmpty : IPersistentStringCache + internal class StringCacheEmpty : IPersistentStringCache { - public string Get(string key) => null; + public string? Get(string key) => null; public DateTime? GetWriteTimeUtc(string key) => null; @@ -73,7 +54,7 @@ class StringCacheEmpty : IPersistentStringCache class MockConfig : ILicenseConfig { - Computation cache; + Computation? cache; readonly string[] codes; readonly LicenseManagerSingleton mgr; @@ -92,7 +73,7 @@ Computation Result } } - public MockConfig(LicenseManagerSingleton mgr, ILicenseClock clock, string[] codes, IEnumerable> domainMappings) + public MockConfig(LicenseManagerSingleton mgr, ILicenseClock? clock, string[] codes, IEnumerable> domainMappings) { this.codes = codes; this.mgr = mgr; @@ -103,7 +84,7 @@ public MockConfig(LicenseManagerSingleton mgr, ILicenseClock clock, string[] cod // Ensure our cache is appropriately invalidated cache = null; - mgr.AddLicenseChangeHandler(this, (me, manager) => me.cache = null); + mgr.AddLicenseChangeHandler(this, (me, _) => me.cache = null); // And repopulated, so that errors show up. if (Result == null) { @@ -117,9 +98,9 @@ public IEnumerable> GetDomainMappings() return domainMappings; } - public IEnumerable> GetFeaturesUsed() + public IReadOnlyCollection> GetFeaturesUsed() { - return Enumerable.Repeat>(codes, 1); + return new[] { codes }; } private readonly List licenses = new List(); @@ -131,8 +112,8 @@ public IEnumerable GetLicenses() public LicenseAccess LicenseScope { get; } = LicenseAccess.Local; public LicenseErrorAction LicenseEnforcement { get; } = LicenseErrorAction.Http402; public string EnforcementMethodMessage { get; } = ""; - public event LicenseConfigEvent LicensingChange; - public event LicenseConfigEvent Heartbeat; + public event LicenseConfigEvent? LicensingChange; + public event LicenseConfigEvent? Heartbeat; public bool IsImageflow { get; } = false; public bool IsImageResizer { get; } = true; public string LicensePurchaseUrl { get; } = "https://imageresizing.net/licenses"; @@ -150,7 +131,7 @@ public string GetLicensesPage() return Result.ProvidePublicLicensesPage(); } - public IEnumerable GetIssues() => mgr.GetIssues().Concat(Result?.GetIssues() ?? Enumerable.Empty()); + public IEnumerable GetIssues() => mgr.GetIssues().Concat(Result.GetIssues()); public void FireHeartbeat() @@ -162,7 +143,7 @@ public void FireHeartbeat() static class MockHttpHelpers { public static Mock MockRemoteLicense(LicenseManagerSingleton mgr, HttpStatusCode code, string value, - Action callback) + Action? callback) { var handler = new Mock(); var method = handler.Protected() diff --git a/tests/ImazenShared.Tests/Routing/Health/BehvaiorMetricsTests.cs b/tests/ImazenShared.Tests/Routing/Health/BehvaiorMetricsTests.cs new file mode 100644 index 00000000..b7b158aa --- /dev/null +++ b/tests/ImazenShared.Tests/Routing/Health/BehvaiorMetricsTests.cs @@ -0,0 +1,53 @@ +using Imazen.Routing.Health; +using Xunit; + +namespace Imazen.Routing.Caching.Health.Tests +{ + public class BehaviorMetricsTests + { + [Fact] + public void ReportBehavior_Success_IncrementsSuccessCounters() + { + var metrics = new BehaviorMetrics(MetricBasis.ProblemReports, new BehaviorTask()); + metrics.ReportBehavior(true, new BehaviorTask(), TimeSpan.FromSeconds(1)); + + Assert.Equal(1, metrics.TotalSuccessReports); + Assert.Equal(1, metrics.ConsecutiveSuccessReports); + Assert.Equal(0, metrics.ConsecutiveFailureReports); + } + + [Fact] + public void ReportBehavior_Failure_IncrementsFailureCounters() + { + var metrics = new BehaviorMetrics(MetricBasis.ProblemReports, new BehaviorTask()); + metrics.ReportBehavior(false, new BehaviorTask(), TimeSpan.FromSeconds(1)); + + Assert.Equal(1, metrics.TotalFailureReports); + Assert.Equal(1, metrics.ConsecutiveFailureReports); + Assert.Equal(0, metrics.ConsecutiveSuccessReports); + } + + [Fact] + public void ReportBehavior_Success_UpdatesUptime() + { + var metrics = new BehaviorMetrics(MetricBasis.ProblemReports, new BehaviorTask()); + metrics.ReportBehavior(true, new BehaviorTask(), TimeSpan.FromSeconds(1)); + Thread.Sleep(15); + metrics.ReportBehavior(true, new BehaviorTask(), TimeSpan.FromSeconds(1)); + + Assert.True(metrics.Uptime > TimeSpan.Zero); + } + + [Fact] + public void ReportBehavior_Failure_UpdatesDowntime() + { + var metrics = new BehaviorMetrics(MetricBasis.ProblemReports, new BehaviorTask()); + metrics.ReportBehavior(false, new BehaviorTask(), TimeSpan.FromSeconds(1)); + // delay 15ms + Thread.Sleep(15); + metrics.ReportBehavior(false, new BehaviorTask(), TimeSpan.FromSeconds(1)); + + Assert.True(metrics.Downtime > TimeSpan.Zero); + } + } +} \ No newline at end of file diff --git a/tests/ImazenShared.Tests/Routing/Helpers/HexHelpersTests.cs b/tests/ImazenShared.Tests/Routing/Helpers/HexHelpersTests.cs new file mode 100644 index 00000000..4a81167e --- /dev/null +++ b/tests/ImazenShared.Tests/Routing/Helpers/HexHelpersTests.cs @@ -0,0 +1,36 @@ +namespace Imazen.Routing.Tests.Helpers; + +using Xunit; +using Imazen.Routing.Helpers; +using System; + +public class HexHelpersTests +{ + [Fact] + public void TestToHexLowercase() + { + // Arrange + byte[] buffer = new byte[] { 0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F }; + + // Act + string result = buffer.ToHexLowercase(); + + // Assert + Assert.Equal("1a2b3c4d5e6f", result); + } + + [Fact] + public void TestToHexLowercaseWith() + { + // Arrange + ReadOnlySpan buffer = new byte[] { 0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F }; + ReadOnlySpan prefix = "prefix".ToCharArray(); + ReadOnlySpan suffix = "suffix".ToCharArray(); + + // Act + string result = buffer.ToHexLowercaseWith(prefix, suffix); + + // Assert + Assert.Equal("prefix1a2b3c4d5e6fsuffix", result); + } +} \ No newline at end of file diff --git a/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs b/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs new file mode 100644 index 00000000..91b6150c --- /dev/null +++ b/tests/ImazenShared.Tests/Routing/Matching/MatchExpressionTests.cs @@ -0,0 +1,65 @@ +using Imazen.Routing.Matching; +using Xunit; +namespace Imazen.Common.Tests.Routing.Matching; + +public class MatchExpressionTests +{ + private static MatchingContext CaseSensitive = new MatchingContext + { + OrdinalIgnoreCase = false, + SupportedImageExtensions = [], + }; + private static MatchingContext CaseInsensitive = new MatchingContext + { + OrdinalIgnoreCase = true, + SupportedImageExtensions = [], + }; + [Theory] + [InlineData(true, true, "/hi")] + [InlineData(false, true, "/Hi")] + [InlineData(true, false, "/Hi")] + public void TestCaseSensitivity(bool isMatch, bool caseSensitive, string path) + { + var c = caseSensitive ? CaseSensitive : CaseInsensitive; + var expr = MatchExpression.Parse(c, "/hi"); + Assert.Equal(isMatch, expr.IsMatch(c, path)); + } + + [Theory] + [InlineData(true, "/{name}/{country}{:(/):?}", "/hi/usa", "/hi/usa/")] + [InlineData(true, "/{name}/{country}{:eq(/):optional}", "/hi/usa", "/hi/usa/")] + [InlineData(true, "/{name}/{country:len(3)}", "/hi/usa")] + [InlineData(false, "/{name}/{country:length(3)}", "/hi/usa2")] + [InlineData(false, "/{name}/{country:length(3)}", "/hi/usa/")] + [InlineData(true, "/images/{seo_string_ignored}/{sku:guid}/{image_id:integer-range(0,1111111)}{width:integer:prefix(_):optional}.{format:equals(jpg|png|gif)}" + , "/images/seo-string/12345678-1234-1234-1234-123456789012/12678_300.jpg", "/images/seo-string/12345678-1234-1234-1234-123456789012/12678.png")] + + public void TestAll(bool s, string expr, params string[] inputs) + { + var caseSensitive = expr.Contains("(i)"); + expr = expr.Replace("(i)", ""); + var c = caseSensitive ? CaseSensitive : CaseInsensitive; + var me = MatchExpression.Parse(c, expr); + foreach (var path in inputs) + { + var matched = me.TryMatchVerbose(c, path.AsMemory(), out var result, out var error); + if (matched && !s) + { + Assert.Fail($"False positive! Expression {expr} should not have matched {path}! False positive."); + } + if (!matched && s) + { + Assert.Fail($"Expression {expr} incorrectly failed to match {path} with error {error}"); + } + } + } + + // Test MatchExpression.Parse + [Fact] + public void TestParse() + { + var c = CaseSensitive; + var expr = MatchExpression.Parse(c, "/{name}/{country}{:equals(/):?}"); + Assert.Equal(5, expr.SegmentCount); + } +} \ No newline at end of file diff --git a/tests/ImazenShared.Tests/Routing/Serving/ImageServerTests.cs b/tests/ImazenShared.Tests/Routing/Serving/ImageServerTests.cs new file mode 100644 index 00000000..95e8e746 --- /dev/null +++ b/tests/ImazenShared.Tests/Routing/Serving/ImageServerTests.cs @@ -0,0 +1,99 @@ +using Imageflow.Server; +using Imazen.Abstractions.DependencyInjection; +using Imazen.Abstractions.Logging; +using Imazen.Routing.Promises.Pipelines.Watermarking; +using Imazen.Routing.Engine; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Layers; +using Imazen.Routing.Serving; +using Imazen.Tests.Routing.Serving; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Imazen.Routing.Tests.Serving; + +using TContext = object; +public class ImageServerTests +{ + private readonly ILicenseChecker licenseChecker; + private readonly IReLoggerFactory loggerFactory; + private readonly IReLogger logger; + private readonly IReLogStore logStore; + private readonly List logList; + private IImageServerContainer container; + + public ImageServerTests() + { + licenseChecker = MockLicenseChecker.AlwaysOK(); + logList = new List(); + logStore = new ReLogStore(new ReLogStoreOptions()); + loggerFactory = MockHelpers.MakeMemoryLoggerFactory(logList, logStore); + logger = loggerFactory.CreateReLogger("ImageServerTests"); + container = new ImageServerContainer(null); + container.Register(() => licenseChecker); + container.Register(() => loggerFactory); + container.Register(() => logStore); + container.Register(() => new LicenseOptions + { + + CandidateCacheFolders = new string[] + { + Path.GetTempPath() // Typical env.ContentRootPath as well + } + }); + + } + + internal ImageServer + CreateImageServer( + RoutingEngine routingEngine, + ImageServerOptions imageServerOptions + ) + { + return new ImageServer( + container, + container.GetRequiredService(), + routingEngine, + container.GetService() ?? new NullPerformanceTracker(), + logger + ); + } + + [Fact] + public void TestMightHandleRequest() + { + var b = new RoutingBuilder(); + b.AddGlobalEndpoint(Conditions.PathEquals("/hi"), + SmallHttpResponse.Text(200, "HI")); + + var imageServer = CreateImageServer( + b.Build(logger), + new ImageServerOptions()); + + + Assert.True(imageServer.MightHandleRequest("/hi", DictionaryQueryWrapper.Empty, new TContext())); + + } + + [Fact] + public async void TestTryHandleRequestAsync() + { + var b = new RoutingBuilder(); + b.AddGlobalEndpoint(Conditions.PathEquals("/hi"), + SmallHttpResponse.Text(200, "HI")); + + var imageServer = CreateImageServer( + b.Build(logger), + new ImageServerOptions() + ); + var request = MockRequest.GetLocalRequest("/hi").ToAdapter(); + var response = new MockResponseAdapter(); + var context = new TContext(); + var handled = await imageServer.TryHandleRequestAsync(request, response, context); + Assert.True(handled); + var r = await response.ToMockResponse(); + + Assert.Equal(200, r.StatusCode); + Assert.Equal("HI", r.DecodeBodyUtf8()); + } +} \ No newline at end of file diff --git a/tests/ImazenShared.Tests/Routing/Serving/MemoryLogger.cs b/tests/ImazenShared.Tests/Routing/Serving/MemoryLogger.cs new file mode 100644 index 00000000..a358895a --- /dev/null +++ b/tests/ImazenShared.Tests/Routing/Serving/MemoryLogger.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging; + +namespace Imazen.Routing.Tests.Serving; + + +public readonly record struct MemoryLogEntry +{ + public string Message { get; init; } + public string Category { get; init; } + public EventId EventId { get; init; } + public LogLevel Level { get; init; } + + // scope stack snapshot + public object[]? Scopes { get; init; } + + public override string ToString() + { + if (Scopes is { Length: > 0 }) + { + return $"{Level}: {Category}[{EventId.Id}] {Message} Scopes: {string.Join(" > ", Scopes)}"; + } + return $"{Level}: {Category}[{EventId.Id}] {Message}"; + } +} +public class MemoryLogger(string categoryName, Func? filter, List logs) + : ILogger +{ + private readonly object @lock = new(); + + private static readonly AsyncLocal> Scopes = new AsyncLocal>(); + + public IDisposable BeginScope(TState state) where TState : notnull + { + if (Scopes.Value == null) + Scopes.Value = new Stack(); + + Scopes.Value.Push(state); + return new DisposableScope(); + } + + + public bool IsEnabled(LogLevel logLevel) + { + return filter == null || filter(categoryName, logLevel); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + lock (@lock) + { + logs.Add(new MemoryLogEntry + { + Scopes = Scopes.Value?.ToArray(), + Message = formatter(state, exception), + Category = categoryName, + EventId = eventId, + Level = logLevel + }); + } + } + + private class DisposableScope : IDisposable + { + public void Dispose() + { + var scopes = Scopes.Value; + if (scopes != null && scopes.Count > 0) + { + scopes.Pop(); + } + } + } + +} + +public class MemoryLoggerFactory : ILoggerFactory +{ + private readonly Func? filter; + private readonly List logs; + + public MemoryLoggerFactory(LogLevel orHigher, List? backingList = null) + { + filter = (_, level) => level >= orHigher; + logs = backingList ?? new List(); + } + public MemoryLoggerFactory(Func? filter, List? backingList = null) + { + this.filter = filter; + logs = backingList ?? new List(); + } + + public void AddProvider(ILoggerProvider provider) + { + throw new NotSupportedException(); + } + + public ILogger CreateLogger(string categoryName) + { + return new MemoryLogger(categoryName, filter, logs); + } + + public void Dispose() + { + } + + public List GetLogs() => logs; + + public void Clear() => logs.Clear(); +} diff --git a/tests/ImazenShared.Tests/Routing/Serving/MockHelpers.cs b/tests/ImazenShared.Tests/Routing/Serving/MockHelpers.cs new file mode 100644 index 00000000..a1bdb8de --- /dev/null +++ b/tests/ImazenShared.Tests/Routing/Serving/MockHelpers.cs @@ -0,0 +1,24 @@ +using Imazen.Abstractions.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Imazen.Routing.Tests.Serving; + +public class MockHelpers +{ + + public static IReLoggerFactory MakeNullLoggerFactory(IReLogStore? logStore) + { + logStore ??= new ReLogStore(new ReLogStoreOptions()); + var nullLogger = new NullLoggerFactory(); + return new ReLoggerFactory(nullLogger, logStore); + } + + public static IReLoggerFactory MakeMemoryLoggerFactory(List logList, + IReLogStore? logStore = null) + { + logStore ??= new ReLogStore(new ReLogStoreOptions()); + var logger = new MemoryLoggerFactory(LogLevel.Trace, logList); + return new ReLoggerFactory(logger, logStore); + } +} \ No newline at end of file diff --git a/tests/ImazenShared.Tests/Routing/Serving/MockLicenseChecker.cs b/tests/ImazenShared.Tests/Routing/Serving/MockLicenseChecker.cs new file mode 100644 index 00000000..f56323d1 --- /dev/null +++ b/tests/ImazenShared.Tests/Routing/Serving/MockLicenseChecker.cs @@ -0,0 +1,34 @@ +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Layers; +using Imazen.Routing.Serving; + +namespace Imazen.Tests.Routing.Serving; + +public class MockLicenseChecker(Func NeedsEnforcement, string LicenseMessage):ILicenseChecker +{ + public bool RequestNeedsEnforcementAction(IHttpRequestStreamAdapter request) + { + return NeedsEnforcement(request); + } + + public string InvalidLicenseMessage => LicenseMessage; + public string GetLicensePageContents() + { + return ""; + } + + public void FireHeartbeat() + { + + } + + public void Initialize(LicenseOptions licenseOptions) + { + + } + + public static MockLicenseChecker AlwaysOK() + { + return new MockLicenseChecker(request => false, "OK"); + } +} \ No newline at end of file diff --git a/tests/ImazenShared.Tests/Routing/Serving/MockRequest.cs b/tests/ImazenShared.Tests/Routing/Serving/MockRequest.cs new file mode 100644 index 00000000..090e0801 --- /dev/null +++ b/tests/ImazenShared.Tests/Routing/Serving/MockRequest.cs @@ -0,0 +1,184 @@ +using System.IO.Pipelines; +using System.Net; +using Imazen.Abstractions.HttpStrings; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Serving; +using Microsoft.Extensions.Primitives; + +namespace Imazen.Routing.Tests.Serving; + +public record MockRequest +{ + public required UrlPathString Path { get; init; } + public UrlPathString PathBase { get; init; } + public required Uri Uri { get; init; } + public required UrlQueryString QueryString { get; init; } + public required UrlHostString Host { get; init; } + public IEnumerable>? Cookies { get; init; } + public required IDictionary Headers { get; init; } + public required IReadOnlyQueryWrapper Query { get; init; } + public string? ContentType { get; init; } + public long? ContentLength { get; init; } + public required string Protocol { get; init; } + public required string Scheme { get; init; } + public required string Method { get; init; } + public bool IsHttps => Scheme == "https"; + public Stream? BodyStream { get; init; } + public IPAddress? RemoteIpAddress { get; init; } + public IPAddress? LocalIpAddress { get; init; } + public bool SupportsPipelines => BodyPipeReader != null; + public PipeReader? BodyPipeReader { get; init; } + + + public MockRequest WithAbsoluteUri(Uri absoluteUri, string pathBase = "") + { + var path = absoluteUri.AbsolutePath; + if (!string.IsNullOrEmpty(pathBase)) + { + if (path.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)) + path = path[pathBase.Length..]; + else if (path.StartsWith("/" + pathBase, StringComparison.OrdinalIgnoreCase)) + path = path[(pathBase.Length + 1)..]; + } + var q = UrlQueryString.FromUriComponent(absoluteUri); + return this with + { + Path = path, + PathBase = pathBase, + Uri = absoluteUri, + QueryString = q, + Host = UrlHostString.FromUriComponent(absoluteUri), + Query = new DictionaryQueryWrapper(q.Parse()), + Scheme = absoluteUri.Scheme, + }; + } + + private static MockRequest? _localhostRootHttp; + public static MockRequest GetLocalhostRootHttp() + { + _localhostRootHttp ??= new MockRequest + { + Path = "/", + PathBase = "", + Uri = new Uri("http://localhost/"), + QueryString = UrlQueryString.FromUriComponent(""), + Host = UrlHostString.FromUriComponent("localhost"), + Query = new DictionaryQueryWrapper(new Dictionary()), + Scheme = "http", + Protocol = "HTTP/1.1", + Headers = new Dictionary(), + Method = "GET" + }; + return _localhostRootHttp; + } + + public MockRequest WithUrlString(string uriString, + string pathBase = "", + string defaultHost = "localhost", + string defaultScheme = "https") + { + + Uri absoluteUri; + // check if uriString is absolute and well formed + // if not, prepend http://localhost or https://localhost + if (Uri.TryCreate(uriString, UriKind.Absolute, out var uri1)) + { + absoluteUri = uri1; + } + else + { + if (uriString.StartsWith("//")) + { + uriString = $"{defaultScheme}:{uriString}"; + }else if (uriString.StartsWith("/")) + { + uriString = $"{defaultScheme}://{defaultHost}{uriString}"; + } + else + { + uriString = $"{defaultScheme}://{uriString}"; + } + + absoluteUri = new Uri(uriString); + } + return WithAbsoluteUri(absoluteUri, pathBase); + } + + public MockRequest WithHeader(string key, string value) + { + var headers = new Dictionary(Headers); + headers[key] = value; + return this with { Headers = headers }; + } + + public MockRequest WithEmptyBody() + { + return this with { BodyStream = new MemoryStream(), ContentLength = 0 }; + } + + public static MockRequest Get(string uriString, + string pathBase = "", string defaultHost = "localhost", string defaultScheme = "https", + IPAddress? remoteIpAddress = null, IPAddress? localIpAddress = null) + + { + return GetLocalhostRootHttp() + .WithUrlString(uriString, pathBase, defaultHost, defaultScheme) + .WithEmptyBody(); + } + + /// + /// Creates an HttpRequestStreamAdapterOptions object for a local request with optional pathBase. + /// + /// The URL string for the request. + /// The base path for the request URL (optional). + /// An instance of HttpRequestStreamAdapterOptions for the local request. + /// Specifies localhost as the host, https as the scheme, and GET as the method. + /// server and client IP addresses are set to loopback. + public static MockRequest GetLocalRequest(string uriString, + string pathBase = "") + + { + return GetLocalhostRootHttp() + .WithUrlString(uriString, pathBase) + .WithEmptyBody() + with { RemoteIpAddress = IPAddress.Loopback, LocalIpAddress = IPAddress.Loopback }; + } + public MockRequestAdapter ToAdapter() => new (this); +} + + +public class MockRequestAdapter(MockRequest options) : IHttpRequestStreamAdapter +{ + public string? TryGetServerVariable(string key) + { + return null; + } + + public UrlPathString GetPath() => options.Path; + public UrlPathString GetPathBase() => options.PathBase; + public Uri GetUri() => options.Uri; + public UrlQueryString GetQueryString() => options.QueryString; + public UrlHostString GetHost() => options.Host; + public IEnumerable>? GetCookiePairs() => options.Cookies; + public IDictionary GetHeaderPairs() => options.Headers; + public IReadOnlyQueryWrapper GetQuery() => options.Query; + public bool TryGetHeader(string key, out StringValues value) => options.Headers.TryGetValue(key, out value); + public bool TryGetQueryValues(string key, out StringValues value) => options.Query.TryGetValue(key, out value); + public T? GetHttpContextUnreliable() where T : class + { + return null; + } + + public string? ContentType => options.ContentType; + public long? ContentLength => options.ContentLength; + public string Protocol => options.Protocol; + public string Scheme => options.Scheme; + public string Method => options.Method; + public bool IsHttps => options.IsHttps; + public bool SupportsStream => options.BodyStream != null; + public Stream GetBodyStream() => options.BodyStream ?? throw new NotSupportedException(); + public IPAddress? GetRemoteIpAddress() => options.RemoteIpAddress; + public IPAddress? GetLocalIpAddress() => options.LocalIpAddress; + public bool SupportsPipelines => options.SupportsPipelines; + public PipeReader GetBodyPipeReader() => options.BodyPipeReader ?? throw new NotSupportedException(); +} \ No newline at end of file diff --git a/tests/ImazenShared.Tests/Routing/Serving/MockResponse.cs b/tests/ImazenShared.Tests/Routing/Serving/MockResponse.cs new file mode 100644 index 00000000..98f326e3 --- /dev/null +++ b/tests/ImazenShared.Tests/Routing/Serving/MockResponse.cs @@ -0,0 +1,117 @@ +using System.Buffers; +using System.IO.Pipelines; +using System.Text; +using Imazen.Routing.HttpAbstractions; +using Imazen.Routing.Serving; + +namespace Imazen.Routing.Tests.Serving; + +[Flags] +public enum MockResponseStreamType +{ + None, + Stream, + Pipe, + BufferWriter +} +/// +/// We record the final state of everything and return it as a MockResponse +/// +public class MockResponseAdapter(MockResponseStreamType enabledStreams = MockResponseStreamType.Stream | MockResponseStreamType.Pipe): IHttpResponseStreamAdapter{ + public int StatusCode { get; private set; } + public string? ContentType => Headers.TryGetValue(HttpHeaderNames.ContentType, out var v) ? v : null; + public long ContentLength { get; private set; } + public bool SupportsStream => enabledStreams.HasFlag(MockResponseStreamType.Stream); + public bool SupportsPipelines => enabledStreams.HasFlag(MockResponseStreamType.Pipe); + public bool SupportsBufferWriter => enabledStreams.HasFlag(MockResponseStreamType.BufferWriter); + protected Stream? BodyStream { get; private set; } + protected PipeWriter? BodyPipeWriter { get; private set; } + protected IBufferWriter? BodyBufferWriter { get; private set; } + public Dictionary Headers { get; } = new Dictionary(); + public void SetHeader(string name, string value) + { + Headers[name] = value; + } + + public void SetStatusCode(int statusCode) + { + StatusCode = statusCode; + } + + public void SetContentType(string contentType) + { + SetHeader(HttpHeaderNames.ContentType, contentType); + } + + public void SetContentLength(long contentLength) + { + ContentLength = contentLength; + } + + public Stream GetBodyWriteStream() + { + if (!SupportsStream) throw new NotSupportedException(); + return BodyStream = new MemoryStream(); + } + + public PipeWriter GetBodyPipeWriter() + { + if (!SupportsPipelines) throw new NotSupportedException(); + return BodyPipeWriter = PipeWriter.Create(BodyStream = new MemoryStream()); + } + + public IBufferWriter GetBodyBufferWriter() + { + if (!SupportsBufferWriter) throw new NotSupportedException(); + BodyBufferWriter = PipeWriter.Create(BodyStream = new MemoryStream()); + return BodyBufferWriter; + } + + public async Task GetBodyBytes() + { + if (BodyPipeWriter != null) + { + await BodyPipeWriter.FlushAsync(); + } + if (BodyStream == null) return Array.Empty(); + await BodyStream.FlushAsync(); + if (BodyStream is MemoryStream ms) + { + return ms.ToArray(); + } + + BodyStream.Position = 0; + var ms2 = new MemoryStream(); + await BodyStream.CopyToAsync(ms2); + return ms2.ToArray(); + } + + public async Task ToMockResponse() + { + return new MockResponse(this, await GetBodyBytes()); + } +} + + +public record MockResponse +{ + public MockResponse(MockResponseAdapter adapter, byte[] body) + { + StatusCode = adapter.StatusCode; + ContentType = adapter.ContentType; + ContentLength = adapter.ContentLength; + Headers = adapter.Headers; + Body = body; + } + public MockResponseStreamType EnabledStreams { get; init; } + public int StatusCode { get; init; } + public string? ContentType { get; init; } + public long ContentLength { get; init; } + public Dictionary Headers { get; init; } = new Dictionary(); + public byte[] Body { get; init; } + + public string DecodeBodyUtf8() + { + return Encoding.UTF8.GetString(Body); + } +} \ No newline at end of file diff --git a/src/Imageflow.Server/VisibleToTests.cs b/tests/ImazenShared.Tests/VisibleToTests.cs similarity index 100% rename from src/Imageflow.Server/VisibleToTests.cs rename to tests/ImazenShared.Tests/VisibleToTests.cs diff --git a/tests/ImazenShared.Tests/packages.lock.json b/tests/ImazenShared.Tests/packages.lock.json new file mode 100644 index 00000000..a2c083df --- /dev/null +++ b/tests/ImazenShared.Tests/packages.lock.json @@ -0,0 +1,627 @@ +{ + "version": 1, + "dependencies": { + "net6.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "ZXhHT2YwP9lajrwSKbLlFqsmCCvFJMoRSK9t7sImfnCyd0OB3MhgxdoMcVqxbq1iyxD6mD2fiackWmBb7ayiXQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "4.4.0" + } + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Direct", + "requested": "[6.0.27, )", + "resolved": "6.0.27", + "contentHash": "KhpZ4ygPB7y/XxT+I6sk8y0Xgekc8EfdE9QH2Hvk1VDk5BK6d8tN/Ck7IshGXS34JeNdAdWd0N/beoijq5dgcw==", + "dependencies": { + "System.IO.Pipelines": "6.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "6e22jnVntG9JLLowjY40UBPLXkKTRlDpFHmo2evN8lwZIpO89ZRGz6JRdqhnVYCaavq5KeFU2W5VKPA5y5farA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.4.1, )", + "resolved": "17.4.1", + "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", + "dependencies": { + "Microsoft.CodeCoverage": "17.4.1", + "Microsoft.TestPlatform.TestHost": "17.4.1" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.70, )", + "resolved": "4.20.70", + "contentHash": "4rNnAwdpXJBuxqrOCzCyICXHSImOTRktCgCWXWykuF1qwoIsVvEnR7PjbMk/eLOxWvhmj5Kwt+kDV3RGUYcNwg==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.6.6, )", + "resolved": "2.6.6", + "contentHash": "MAbOOMtZIKyn2lrAmMlvhX0BhDOX/smyrTB+8WTXnSKkrmTGBS2fm8g1PZtHBPj91Dc5DJA7fY+/81TJ/yUFZw==", + "dependencies": { + "xunit.analyzers": "1.10.0", + "xunit.assert": "2.6.6", + "xunit.core": "[2.6.6]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.5.6, )", + "resolved": "2.5.6", + "contentHash": "CW6uhMXNaQQNMSG1IWhHkBT+V5eqHqn7MP0zfNMhU9wS/sgKX7FGL3rzoaUgt26wkY3bpf7pDVw3IjXhwfiP4w==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "Imageflow.Net": { + "type": "Transitive", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "2.3.2", + "contentHash": "Oh1qXXFdJFcHozvb4H6XYLf2W0meZFuG0A+TfapFPj9z5fd4vxiARGEhAaLj/6XWQaMYIv4SH/9Q6H78Hw0E2Q==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "v2CwoejusooZa/DZYt7UXo+CJOvwAmqg6ZyFJeIBu+DCRDqpEtf7WYhZ/AWii0EKzANPPLU9+m148aipYQkTuA==", + "dependencies": { + "NuGet.Frameworks": "5.11.0", + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "K7QXM4P4qrDKdPs/VSEKXR08QEru7daAK8vlIbhwENM3peXJwb9QgrAbtbYyyfVnX+F1m+1hntTH6aRX+h/f8g==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.4.1", + "Newtonsoft.Json": "13.0.1" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "5.11.0", + "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gWwQv/Ug1qWJmHCmN17nAbxJYmQBM/E94QxKLksvUiiKB1Ld3Sc/eK1lgmbSjDFxkQhVuayI/cGFZhpBSodLrg==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "4.4.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "cJV7ScGW7EhatRsjehfvvYVBvtiSMKgN8bOVI0bQhnF5bU7vnHVIsH49Kva7i7GWaWYvmEzkYVk1TC+gZYBEog==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.10.0", + "contentHash": "Lw8CiDy5NaAWcO6keqD7iZHYUTIuCOcoFrUHw5Sv84ITZ9gFeDybdkVdH0Y2maSlP9fUjtENyiykT44zwFQIHA==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "74Cm9lAZOk5TKCz2MvCBCByKsS23yryOKDIMxH3XRDHXmfGM02jKZWzRA7g4mGB41GnBnv/pcWP3vUYkrCtEcg==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "tqi7RfaNBqM7t8zx6QHryuBPzmotsZXKGaWnopQG2Ez5UV7JoWuyoNdT6gLpDIcKdGYey6YTXJdSr9IXDMKwjg==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]", + "xunit.extensibility.execution": "[2.6.6]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "ty6VKByzbx4Toj4/VGJLEnlmOawqZiMv0in/tLju+ftA+lbWuAWDERM+E52Jfhj4ZYHrAYVa14KHK5T+dq0XxA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "UDjIVGj2TepVKN3n32/qXIdb3U6STwTb9L6YEwoQO2A8OxiJS5QAVv2l1aT6tDwwv/9WBmm8Khh/LyHALipcng==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" + } + } + }, + "net8.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "ZXhHT2YwP9lajrwSKbLlFqsmCCvFJMoRSK9t7sImfnCyd0OB3MhgxdoMcVqxbq1iyxD6mD2fiackWmBb7ayiXQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "4.4.0" + } + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Direct", + "requested": "[6.0.27, )", + "resolved": "6.0.27", + "contentHash": "KhpZ4ygPB7y/XxT+I6sk8y0Xgekc8EfdE9QH2Hvk1VDk5BK6d8tN/Ck7IshGXS34JeNdAdWd0N/beoijq5dgcw==", + "dependencies": { + "System.IO.Pipelines": "6.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Direct", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "6e22jnVntG9JLLowjY40UBPLXkKTRlDpFHmo2evN8lwZIpO89ZRGz6JRdqhnVYCaavq5KeFU2W5VKPA5y5farA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.4.1, )", + "resolved": "17.4.1", + "contentHash": "kJ5/v2ad+VEg1fL8UH18nD71Eu+Fq6dM4RKBVqlV2MLSEK/AW4LUkqlk7m7G+BrxEDJVwPjxHam17nldxV80Ow==", + "dependencies": { + "Microsoft.CodeCoverage": "17.4.1", + "Microsoft.TestPlatform.TestHost": "17.4.1" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.70, )", + "resolved": "4.20.70", + "contentHash": "4rNnAwdpXJBuxqrOCzCyICXHSImOTRktCgCWXWykuF1qwoIsVvEnR7PjbMk/eLOxWvhmj5Kwt+kDV3RGUYcNwg==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.14.1, )", + "resolved": "1.14.1", + "contentHash": "mOOmFYwad3MIOL14VCjj02LljyF1GNw1wP0YVlxtcPvqdxjGGMNdNJJxHptlry3MOd8b40Flm8RPOM8JOlN2sQ==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.6.6, )", + "resolved": "2.6.6", + "contentHash": "MAbOOMtZIKyn2lrAmMlvhX0BhDOX/smyrTB+8WTXnSKkrmTGBS2fm8g1PZtHBPj91Dc5DJA7fY+/81TJ/yUFZw==", + "dependencies": { + "xunit.analyzers": "1.10.0", + "xunit.assert": "2.6.6", + "xunit.core": "[2.6.6]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.5.6, )", + "resolved": "2.5.6", + "contentHash": "CW6uhMXNaQQNMSG1IWhHkBT+V5eqHqn7MP0zfNMhU9wS/sgKX7FGL3rzoaUgt26wkY3bpf7pDVw3IjXhwfiP4w==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "Imageflow.Net": { + "type": "Transitive", + "resolved": "0.12.0", + "contentHash": "ZQNfvIIP+0TEeHCThL0DH+YtQfcA+Cpwn3otrIi+Pf55dICKHoT0Ipl2bs+4B3HClyU/Cr/IMsCk0Mpob9gaFw==", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[2.3.2, 4.0.0)", + "System.Text.Json": "6.0.9" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "T21KxaiFawbrrjm0uXjxAStXaBm5P9H6Nnf8BUtBTvIpd8q57lrChVBCY2dnazmSu9/kuX4z5+kAOT78Dod7vA==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "f9hstgjVmr6rmrfGSpfsVOl2irKAgr1QjrSi3FgnS7kulxband50f2brRLwySAQTADPZeTdow0mpSMcoAdadCw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "+k4AEn68HOJat5gj1TWa6X28WlirNQO9sPIIeQbia+91n03esEtMSSoekSTpMjUzjqtJWQN3McVx0GvSPFHF/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.2.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.2.0", + "Microsoft.Extensions.FileProviders.Abstractions": "2.2.0", + "Microsoft.Extensions.Logging.Abstractions": "2.2.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "B2WqEox8o+4KUOpL7rZPyh6qYjik8tHi2tN8Z9jZkHzED8ElYgZa/h6K+xliB435SqUcWT290Fr2aa8BtZjn8A==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "azyQtqbm4fSaDzZHD/J+V6oWMFaf2tWP4WEGIYePLCMw3+b2RQdj9ybgbQyjCshcitQKQ4lEDOZjmSlTTrHxUg==", + "dependencies": { + "System.Memory": "4.5.1", + "System.Runtime.CompilerServices.Unsafe": "4.5.1" + } + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "2.3.2", + "contentHash": "Oh1qXXFdJFcHozvb4H6XYLf2W0meZFuG0A+TfapFPj9z5fd4vxiARGEhAaLj/6XWQaMYIv4SH/9Q6H78Hw0E2Q==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "v2CwoejusooZa/DZYt7UXo+CJOvwAmqg6ZyFJeIBu+DCRDqpEtf7WYhZ/AWii0EKzANPPLU9+m148aipYQkTuA==", + "dependencies": { + "NuGet.Frameworks": "5.11.0", + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.4.1", + "contentHash": "K7QXM4P4qrDKdPs/VSEKXR08QEru7daAK8vlIbhwENM3peXJwb9QgrAbtbYyyfVnX+F1m+1hntTH6aRX+h/f8g==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.4.1", + "Newtonsoft.Json": "13.0.1" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "5.11.0", + "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gWwQv/Ug1qWJmHCmN17nAbxJYmQBM/E94QxKLksvUiiKB1Ld3Sc/eK1lgmbSjDFxkQhVuayI/cGFZhpBSodLrg==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "4.4.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.1", + "contentHash": "sDJYJpGtTgx+23Ayu5euxG5mAXWdkDb4+b0rD0Cab0M1oQS9H0HXGPriKcqpXuiJDTV7fTp/d+fMDJmnr6sNvA==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "cJV7ScGW7EhatRsjehfvvYVBvtiSMKgN8bOVI0bQhnF5bU7vnHVIsH49Kva7i7GWaWYvmEzkYVk1TC+gZYBEog==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "Vg8eB5Tawm1IFqj4TVK1czJX89rhFxJo9ELqc/Eiq0eXy13RK00eubyU6TJE6y+GQXjyV5gSfiewDUZjQgSE0w==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.9", + "contentHash": "2j16oUgtIzl7Xtk7demG0i/v5aU/ZvULcAnJvPb63U3ZhXJ494UYcxuEj5Fs49i3XDrk5kU/8I+6l9zRCw3cJw==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encodings.Web": "6.0.0" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.10.0", + "contentHash": "Lw8CiDy5NaAWcO6keqD7iZHYUTIuCOcoFrUHw5Sv84ITZ9gFeDybdkVdH0Y2maSlP9fUjtENyiykT44zwFQIHA==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "74Cm9lAZOk5TKCz2MvCBCByKsS23yryOKDIMxH3XRDHXmfGM02jKZWzRA7g4mGB41GnBnv/pcWP3vUYkrCtEcg==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "tqi7RfaNBqM7t8zx6QHryuBPzmotsZXKGaWnopQG2Ez5UV7JoWuyoNdT6gLpDIcKdGYey6YTXJdSr9IXDMKwjg==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]", + "xunit.extensibility.execution": "[2.6.6]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "ty6VKByzbx4Toj4/VGJLEnlmOawqZiMv0in/tLju+ftA+lbWuAWDERM+E52Jfhj4ZYHrAYVa14KHK5T+dq0XxA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.6.6", + "contentHash": "UDjIVGj2TepVKN3n32/qXIdb3U6STwTb9L6YEwoQO2A8OxiJS5QAVv2l1aT6tDwwv/9WBmm8Khh/LyHALipcng==", + "dependencies": { + "xunit.extensibility.core": "[2.6.6]" + } + }, + "imazen.abstractions": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "[2.*, )", + "System.Text.Encodings.Web": "[6.*, )" + } + }, + "imazen.common": { + "type": "Project", + "dependencies": { + "Imazen.Abstractions": "[0.1.0--notset, )", + "Microsoft.Extensions.Hosting.Abstractions": "[2.2.0, )" + } + }, + "imazen.routing": { + "type": "Project", + "dependencies": { + "CommunityToolkit.HighPerformance": "[8.*, )", + "Imageflow.Net": "[0.12.0, )", + "Imazen.Abstractions": "[0.1.0--notset, )", + "Imazen.Common": "[0.1.0--notset, )", + "System.Collections.Immutable": "[6.*, )", + "System.IO.Pipelines": "[6.*, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/TestDefaults.targets b/tests/TestDefaults.targets new file mode 100644 index 00000000..d94d291f --- /dev/null +++ b/tests/TestDefaults.targets @@ -0,0 +1,29 @@ + + + enable + false + net8.0;net6.0 + latest + enable + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + \ No newline at end of file