diff --git a/.github/actions/cache-nuget/action.yml b/.github/actions/cache-nuget/action.yml new file mode 100644 index 0000000..01b2aea --- /dev/null +++ b/.github/actions/cache-nuget/action.yml @@ -0,0 +1,14 @@ +name: 'Cache NuGet Packages' +description: 'Sets up caching for NuGet packages to speed up builds' +author: 'Kenny Pflug' + +runs: + using: 'composite' + steps: + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + nuget-${{ runner.os }}- diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 83e3b95..0407b6f 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -17,12 +17,7 @@ jobs: with: global-json-file: ./global.json - name: Cache NuGet packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} - restore-keys: | - nuget-${{ runner.os }}- + uses: ./.github/actions/cache-nuget - name: Restore dependencies run: dotnet restore ./Light.TemporaryStreams.sln /p:ContinuousIntegrationBuild=true - name: Build diff --git a/.github/workflows/release-on-nuget.yml b/.github/workflows/release-on-nuget.yml new file mode 100644 index 0000000..3c34e02 --- /dev/null +++ b/.github/workflows/release-on-nuget.yml @@ -0,0 +1,33 @@ +name: Release on NuGet + +on: + release: + types: [ published ] + workflow_dispatch: + +jobs: + release-on-nuget: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: ./global.json + - name: Cache NuGet packages + uses: ./.github/actions/cache-nuget + - name: Prepare SNK file + env: + SNK: ${{ secrets.SNK }} + run: echo "$SNK" | base64 --decode > Light.TemporaryStreams.snk + - name: Create NuGet packages + # AssemblyOriginatorKeyFile must be a relative path from the csproj file that is being built, hence the ../../ + run: dotnet pack ./Light.TemporaryStreams.sln --configuration Release /p:SignAssembly=true /p:AssemblyOriginatorKeyFile=../../Light.TemporaryStreams.snk /p:ContinuousIntegrationBuild=true + - name: Delete SNK file + run: rm ./Light.TemporaryStreams.snk + - name: Push NuGet packages + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push "./src/**/*.nupkg" --api-key $NUGET_API_KEY --source https://api.nuget.org/v3/index.json diff --git a/.gitignore b/.gitignore index 739ff4d..25b1271 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ bin/ obj/ *.suo -*.user \ No newline at end of file +*.user +Light.TemporaryStreams.snk diff --git a/Directory.Build.props b/Directory.Build.props index eddcafb..f8bcf3f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,13 @@ net8.0 enable disable + true true true + Kenny Pflug + Kenny Pflug + Copyright (c) 2025 Kenny Pflug + Light.TemporaryStreams + 1.0.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 2425cd9..ca2af1a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/Light.TemporaryStreams.Public.snk b/Light.TemporaryStreams.Public.snk new file mode 100644 index 0000000..b7264d7 Binary files /dev/null and b/Light.TemporaryStreams.Public.snk differ diff --git a/Light.TemporaryStreams.sln b/Light.TemporaryStreams.sln index bf0074f..a49c9b0 100644 --- a/Light.TemporaryStreams.sln +++ b/Light.TemporaryStreams.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionIt Light.TemporaryStreams.sln.DotSettings = Light.TemporaryStreams.sln.DotSettings Directory.Packages.props = Directory.Packages.props global.json = global.json + Light.TemporaryStreams.Public.snk = Light.TemporaryStreams.Public.snk + readme.md = readme.md EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{677E4EE1-7062-46AB-81FF-8D20E9316ED6}" @@ -22,6 +24,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{05D48EEC-A2AB-4143-9533-A633E7B25EA3}" ProjectSection(SolutionItems) = preProject .github\workflows\build-and-test.yml = .github\workflows\build-and-test.yml + .github\workflows\release-on-nuget.yml = .github\workflows\release-on-nuget.yml EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{048D0C61-6CF0-43E6-B7DB-1FDD8F791D57}" @@ -38,6 +41,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Light.TemporaryStreams", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Light.TemporaryStreams.Tests", "tests\Light.TemporaryStreams.Tests\Light.TemporaryStreams.Tests.csproj", "{93CCDAD2-A16A-4BA3-A805-4FC7C4B518C8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "actions", "actions", "{68BCD857-ACA2-4D68-A0D5-1B0E92FD444B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cache-nuget", "cache-nuget", "{1D23A697-FFF5-4FA2-B540-5F8CBBF6863C}" + ProjectSection(SolutionItems) = preProject + .github\actions\cache-nuget\action.yml = .github\actions\cache-nuget\action.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -106,5 +116,7 @@ Global {4D6B4F48-1E4B-4ACA-9F32-829442DB5E56} = {DA93B299-75F5-4A49-B2A6-4A1247047E5E} {61725DD8-D81C-4EC0-A0A1-63D96A87DAC1} = {048D0C61-6CF0-43E6-B7DB-1FDD8F791D57} {93CCDAD2-A16A-4BA3-A805-4FC7C4B518C8} = {DA93B299-75F5-4A49-B2A6-4A1247047E5E} + {68BCD857-ACA2-4D68-A0D5-1B0E92FD444B} = {677E4EE1-7062-46AB-81FF-8D20E9316ED6} + {1D23A697-FFF5-4FA2-B540-5F8CBBF6863C} = {68BCD857-ACA2-4D68-A0D5-1B0E92FD444B} EndGlobalSection EndGlobal diff --git a/Light.TemporaryStreams.sln.DotSettings b/Light.TemporaryStreams.sln.DotSettings index 0a92497..65d7d9b 100644 --- a/Light.TemporaryStreams.sln.DotSettings +++ b/Light.TemporaryStreams.sln.DotSettings @@ -1,14 +1,26 @@ - - True - HINT - SUGGESTION - HINT - SUGGESTION - SUGGESTION - HINT - DO_NOT_SHOW - DO_NOT_SHOW - DO_NOT_SHOW + + True + HINT + SUGGESTION + HINT + SUGGESTION + SUGGESTION + HINT + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW True <?xml version="1.0" encoding="utf-16"?><Profile name="Kenny's Kleanup"><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><CppAddTypenameTemplateKeywords>True</CppAddTypenameTemplateKeywords><CppCStyleToStaticCastDescriptor>True</CppCStyleToStaticCastDescriptor><CppRedundantDereferences>True</CppRedundantDereferences><CppDeleteRedundantAccessSpecifier>True</CppDeleteRedundantAccessSpecifier><CppRemoveCastDescriptor>True</CppRemoveCastDescriptor><CppRemoveElseKeyword>True</CppRemoveElseKeyword><CppShortenQualifiedName>True</CppShortenQualifiedName><CppDeleteRedundantSpecifier>True</CppDeleteRedundantSpecifier><CppRemoveStatement>True</CppRemoveStatement><CppDeleteRedundantTypenameTemplateKeywords>True</CppDeleteRedundantTypenameTemplateKeywords><CppReplaceExpressionWithBooleanConst>True</CppReplaceExpressionWithBooleanConst><CppMakeIfConstexpr>True</CppMakeIfConstexpr><CppMakePostfixOperatorPrefix>True</CppMakePostfixOperatorPrefix><CppMakeVariableConstexpr>True</CppMakeVariableConstexpr><CppChangeSmartPointerToMakeFunction>True</CppChangeSmartPointerToMakeFunction><CppReplaceThrowWithRethrowFix>True</CppReplaceThrowWithRethrowFix><CppTypeTraitAliasDescriptor>True</CppTypeTraitAliasDescriptor><CppRemoveRedundantConditionalExpressionDescriptor>True</CppRemoveRedundantConditionalExpressionDescriptor><CppSimplifyConditionalExpressionDescriptor>True</CppSimplifyConditionalExpressionDescriptor><CppReplaceExpressionWithNullptr>True</CppReplaceExpressionWithNullptr><CppReplaceTieWithStructuredBindingDescriptor>True</CppReplaceTieWithStructuredBindingDescriptor><CppUseAssociativeContainsDescriptor>True</CppUseAssociativeContainsDescriptor><CppUseEraseAlgorithmDescriptor>True</CppUseEraseAlgorithmDescriptor><CppCodeStyleCleanupDescriptor ArrangeBraces="True" ArrangeAuto="True" ArrangeFunctionDeclarations="True" ArrangeNestedNamespaces="True" ArrangeTypeAliases="True" ArrangeCVQualifiers="True" ArrangeSlashesInIncludeDirectives="True" ArrangeOverridingFunctions="True" SortIncludeDirectives="True" SortMemberInitializers="True" /><CppReformatCode>True</CppReformatCode><CSReorderTypeMembers>True</CSReorderTypeMembers><CSCodeStyleAttributes ArrangeVarStyle="True" ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><FSharpReformatCode>True</FSharpReformatCode><ShaderLabReformatCode>True</ShaderLabReformatCode><RemoveCodeRedundanciesVB>True</RemoveCodeRedundanciesVB><VBMakeFieldReadonly>True</VBMakeFieldReadonly><Xaml.RemoveRedundantNamespaceAlias>True</Xaml.RemoveRedundantNamespaceAlias><Xaml.RedundantFreezeAttribute>True</Xaml.RedundantFreezeAttribute><Xaml.RemoveRedundantModifiersAttribute>True</Xaml.RemoveRedundantModifiersAttribute><Xaml.RemoveRedundantNameAttribute>True</Xaml.RemoveRedundantNameAttribute><Xaml.RemoveRedundantResource>True</Xaml.RemoveRedundantResource><Xaml.RemoveRedundantCollectionProperty>True</Xaml.RemoveRedundantCollectionProperty><Xaml.RemoveRedundantAttachedPropertySetter>True</Xaml.RemoveRedundantAttachedPropertySetter><Xaml.RemoveRedundantStyledValue>True</Xaml.RemoveRedundantStyledValue><Xaml.RemoveForbiddenResourceName>True</Xaml.RemoveForbiddenResourceName><Xaml.RemoveRedundantGridDefinitionsAttribute>True</Xaml.RemoveRedundantGridDefinitionsAttribute><Xaml.RemoveRedundantUpdateSourceTriggerAttribute>True</Xaml.RemoveRedundantUpdateSourceTriggerAttribute><Xaml.RemoveRedundantBindingModeAttribute>True</Xaml.RemoveRedundantBindingModeAttribute><Xaml.RemoveRedundantGridSpanAttribut>True</Xaml.RemoveRedundantGridSpanAttribut><XMLReformatCode>True</XMLReformatCode><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><VBOptimizeImports>True</VBOptimizeImports><VBShortenReferences>True</VBShortenReferences><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><VBReformatCode>True</VBReformatCode><VBFormatDocComments>True</VBFormatDocComments><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSharpReformatComments>True</CSharpReformatComments><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><IDEA_SETTINGS>&lt;profile version="1.0"&gt; &lt;option name="myName" value="Kenny's Kleanup" /&gt; @@ -74,84 +86,136 @@ &lt;/profile&gt;</RIDER_SETTINGS></Profile> Kenny's Kleanup Kenny's Kleanup - False + False Required - Required - Required + Required + Required Required ExpressionBody - ExpressionBody + ExpressionBody ExpressionBody - False + False True - True - True - True - True - True - True - True + True + True + True + True + True + True + True True - True - TOGETHER_SAME_LINE - True - True - True - NO_INDENT - True - True - True - False + True + TOGETHER_SAME_LINE + True + True + True + NO_INDENT + True + True + True + False True - 90 + 90 1 - 10000 + 10000 COMPACT True - True + True True True - NEVER + NEVER NEVER - False - ALWAYS - IF_OWNER_IS_SINGLE_LINE - NEVER + False + ALWAYS + IF_OWNER_IS_SINGLE_LINE + NEVER False - True - True + True + True False - True - True - CHOP_IF_LONG + True + True + CHOP_IF_LONG CHOP_IF_LONG - False - True - True - True - True - True - True - False + False + True + True + True + True + True + True + False CHOP_IF_LONG CHOP_IF_LONG - CHOP_IF_LONG - CHOP_IF_LONG + CHOP_IF_LONG + CHOP_IF_LONG CHOP_ALWAYS - CHOP_IF_LONG - RemoveIndent - RemoveIndent - False - ByFirstAttr + CHOP_IF_LONG + RemoveIndent + RemoveIndent + False + ByFirstAttr 150 False False False False True - False - True + False + True True False False @@ -159,9 +223,14 @@ False True False - True - True - True - True + True + True + True + True True + True diff --git a/images/light-logo.png b/images/light-logo.png new file mode 100644 index 0000000..1b016d0 Binary files /dev/null and b/images/light-logo.png differ diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..6557fcc --- /dev/null +++ b/readme.md @@ -0,0 +1,214 @@ +# Light.TemporaryStreams 🌊 + +[![License](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](https://github.com/feO2x/Light.TemporaryStreams/blob/main/LICENSE) +[![NuGet](https://img.shields.io/badge/NuGet-1.0.0-blue.svg?style=for-the-badge)](https://www.nuget.org/packages/Light.TemporaryStreams/1.0.0/) +[![Documentation](https://img.shields.io/badge/Docs-Changelog-yellowgreen.svg?style=for-the-badge)](https://github.com/feO2x/Light.GuardClauses/releases) + +## Overview 🔍 + +Light.TemporaryStreams is a lightweight .NET library that helps you convert non-seekable streams into seekable temporary +streams. A temporary stream is either backed by a memory stream (for input smaller than 80 KB) or a file stream to a +temporary file. This is particularly useful for backend services that receive streams from HTTP requests (e.g., +`application/octet-stream`, custom-parsed `multipart/form-data`) or download files from storage systems for further +processing. + +## Key Features ✨ + +- 🚀 Easy conversion of non-seekable streams to seekable temporary streams +- 💾 Automatic management of temporary files (creation and deletion) +- 🔄 Smart switching between memory-based and file-based streams based on size (similar behavior to ASP.NET Core's + `IFormFile`) +- 🧩 Plugin system for extending functionality (e.g., calculating hashes during stream copying) +- 🔌 Integration with Microsoft.Extensions.DependencyInjection and Microsoft.Extensions.Logging + +## Installation 📦 + +```bash +dotnet add package Light.TemporaryStreams +``` + +For just the core functionality without DI and logging integration: + +```bash +dotnet add package Light.TemporaryStreams.Core +``` + +## Basic Usage 🚀 + +First, register the `ITemporaryStreamService` and other dependencies of Light.TemporaryStreams with your dependency injection container: + +```csharp +services.AddTemporaryStreamService(); +``` + +Then, inject the `ITemporaryStreamService` into any class that needs to convert non-seekable streams to seekable +temporary streams: + +```csharp +using Light.TemporaryStreams; +using System.IO; +using System.Threading.Tasks; + +public class SomeService +{ + private readonly ITemporaryStreamService _temporaryStreamService; + private readonly IS3UploadClient _s3UploadClient; + + public SomeService(ITemporaryStreamService temporaryStreamService, IS3UploadClient s3UploadClient) + { + _temporaryStreamService = temporaryStreamService; + _s3UploadClient = s3UploadClient; + } + + public async Task ProcessStreamAsync(Stream nonSeekableStream, CancellationToken cancellationToken = default) + { + // A temporary stream is either backed by a memory stream or a file stream + // and thus seekable. + await using TemporaryStream temporaryStream = + await _temporaryStreamService.CopyToTemporaryStreamAsync(nonSeekableStream, cancellationToken); + + // Do something here with the temporary stream (analysis, processing, etc.). + // For example, your code base has a PdfProcessor that requires a seekable stream. + using (var pdf = new PdfProcessor(temporaryStream, leaveOpen: true)) + { + var emptyOrIrrelevantPages = pdf.DetermineEmptyOrIrrelevantPages(); + pdf.RemovePages(emptyOrIrrelevantPages); + } + + // Once you are done with processing, you can easily reset the stream to Position 0. + // You can also use resilience patterns here and always reset the stream + // for each upload attempt. + temporaryStream.ResetStreamPosition(); + await _s3UploadClient.UploadAsync(temporaryStream); + + // When the temporary stream is disposed, it will automatically delete the + // underlying file if necessary. No need to worry about manual cleanup. + // This is also great when a temporary stream is returned in an + // MVC Controller action or in Minimal API endpoint. + } +} +``` + +## How It Works 🛠️ + +### Smart Memory Usage + +A `TemporaryStream` is a wrapper around either: + +- 🧠 A `MemoryStream` (for smaller files, less than 80 KB by default) +- 📄 A `FileStream` to a temporary file (for 80 KB or larger files) + +This approach is similar to how `IFormFile` works in ASP.NET Core. You can adjust the threshold for using file streams +using the `TemporaryStreamServiceOptions.FileThresholdInBytes` property. + +Use the `TemporaryStream.IsFileBased` property to check if the stream is backed by a file or a memory stream. Use +`TemporaryStream.TryGetUnderlyingFilePath` or `TemporaryStream.GetUnderlyingFilePath` to get the absolute file path. + +### Automatic Cleanup + +When a `TemporaryStream` instance is disposed: + +- If the underlying stream is a `FileStream`, the temporary file is automatically deleted +- You don't need to worry about cleaning up temporary files manually + +You can adjust this behavior using the `TemporaryStreamServiceOptions.DisposeBehavior` property. + +### Temporary File Management + +By default, temporary files are created using `Path.GetTempFileName()`. You can pass your own file path by providing a +value to the optional `filePath` argument of `ITemporaryStreamService.CreateTemporaryStream` or the +`CopyToTemporaryStreamAsync` extension methods. + +By default, Light.TemporaryStreams uses `FileMode.Create`, thus files are either created or overwritten. You can adjust +this behavior using the `TemporaryStreamServiceOptions.FileStreamOptions` property. + +### Temporary Stream Service Options + +When you call `services.AddTemporaryStreamService()`, a singleton instance of `TemporaryStreamServiceOptions` is +registered with the DI container. This default instance is used when you do not explicitly pass a reference to +`ITemporaryStreamService.CreateTemporaryStream` or `CopyToTemporaryStreamAsync`. + +However, if you want to deviate from the defaults in certain use cases, simply instantiate your own and pass them to the +`options` argument of aforementioned methods. The `TemporaryStreamServiceOptions` class is an immutable record. + +## Plugins 🧩 + +`CopyToTemporaryStreamAsync` supports a plugin system that allows you to extend the behavior of the stream copying +process. Light.TemporaryStreams comes with a `HashingPlugin` to calculate hashes. You can also create your own plugins +by implementing the `ICopyToTemporaryStreamPlugin` interface. + +### Basic Usage of HashingPlugin + +```csharp +// You can simply pass any instance of System.Security.Cryptography.HashAlgorithm +// to the hashing plugin constructor. They will be disposed of when the hashingPlugin is disposed of. +await using var hashingPlugin = new HashingPlugin([SHA1.Create(), MD5.Create()]); +await using var temporaryStream = await _temporaryStreamService + .CopyToTemporaryStreamAsync(stream, [hashingPlugin], cancellationToken: cancellationToken); + +// After copying is done, you can call GetHash to obtain the hash as a base64 string +// or GetHashArray to obtain the hash in its raw byte array form. +// Calling these methods before `CopyToTemporaryStreamAsync` has completed will result +// in an InvalidOperationException. +string sha1Base64Hash = hashingPlugin.GetHash(nameof(SHA1)); +byte[] md5HashArray = hashingPlugin.GetHashArray(nameof(MD5)); +``` + +### Hexadecimal Hashes via CopyToHashCalculator + +The `HashAlgorithm` instances passed to the `HashingPlugin` constructor in the previous example are actually converted into instances of `CopyToHashCalculator` via an implicit conversion operator. You can instantiate this class yourself to have more control over the conversion method that converts a hash byte array into a string as well as the name used to identify the hash calculator. + +```csharp +var sha1Calculator = new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.UpperHexadecimal, "SHA1"); +var md5Calculator = new CopyToHashCalculator(MD5.Create(), HashConversionMethod.None, "MD5"); +await using var hashingPlugin = new HashingPlugin([sha1Calculator, md5Calculator]); + +await using var temporaryStream = await _temporaryStreamService + .CopyToTemporaryStreamAsync(stream, [hashingPlugin], cancellationToken: cancellationToken); + +string sha1HexadecimalHash = hashingPlugin.GetHash(nameof(SHA1)); +byte[] md5HashArray = hashingPlugin.GetHashArray(nameof(MD5)); +``` + +## When To Use Light.TemporaryStreams 🤔 + +- Your service implements endpoints that receives `application/octet-stream` that you need to process further. +- Your service implements endpoints that receives `multipart/form-data` and you cannot use `IFormFile`, for example because the request has both JSON and binary data. See [this blog post by Andrew Lock](https://andrewlock.net/reading-json-and-binary-data-from-multipart-form-data-sections-in-aspnetcore/) for an example. +- Your service downloads files from storage systems like S3 and processes them further. + +## Light.TemporaryStreams.Core vs. Light.TemporaryStreams 🧰 + +### Light.TemporaryStreams.Core + +This package contains the core implementation including: + +- `ITemporaryStreamService` interface +- `TemporaryStreamService` implementation +- `TemporaryStream` class +- `TemporaryStreamServiceOptions` for configuration +- Extension method `CopyToTemporaryStreamAsync` +- Plugin system `ICopyToTemporaryStreamPlugin` and existing plugin `HashingPlugin` + +### Light.TemporaryStreams + +This package builds on Core and adds integration with: + +- Microsoft.Extensions.DependencyInjection for registering services +- Microsoft.Extensions.Logging for logging when a temporary stream cannot be properly deleted + +Use Light.TemporaryStreams.Core if you're working in a non-DI environment or have your own DI container. +Use Light.TemporaryStreams if you're working in an ASP.NET Core application or any other application supporting +Microsoft.Extensions.DependencyInjection. + +## Contributing 🤝 + +Contributions are welcome! First, create an issue to discuss your idea. After that, you can submit pull requests. + +## License 📜 + +This project is licensed under the MIT License - see +the [LICENSE](https://github.com/feO2x/Light.TemporaryStreams/blob/main/LICENSE) file for details. + +## Let there be... Light 💡 + +![Light Libraries Logo](https://raw.githubusercontent.com/feO2x/Light.GuardClauses/main/Images/light_logo.png) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4d31c2b..3984beb 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,6 +8,25 @@ true true - Light.TemporaryStreams + git + https://github.com/feO2x/Light.TemporaryStreams.git + https://github.com/feO2x/Light.TemporaryStreams + true + true + snupkg + true + streaming;memory-management;form-file + light-logo.png + readme.md + MIT + + + + + + + + + diff --git a/src/Light.TemporaryStreams.Core/Light.TemporaryStreams.Core.csproj b/src/Light.TemporaryStreams.Core/Light.TemporaryStreams.Core.csproj index 17f6863..e7c0671 100644 --- a/src/Light.TemporaryStreams.Core/Light.TemporaryStreams.Core.csproj +++ b/src/Light.TemporaryStreams.Core/Light.TemporaryStreams.Core.csproj @@ -1,5 +1,20 @@ + + + + The core library of Light.TemporaryStreams, containing that base implementation without Microsoft.Extensions.Logging and Microsoft.Extensions.DependencyInjection integration. Provides temporary streams, similar to how IFormFile works in ASP.NET Core. + + Light.TemporaryStreams.Core 1.0.0 + --------------------------------- + + - Initial release 🚀 + - use ITemporaryStreamService and the CopyToTemporaryStreamAsync extension method to create temporary seekable streams easily + - use the HashingPlugin to calculate hashes during the copy operation, or write your own plugins via ICopyToTemporaryStreamPlugin + - check out TemporaryStreamServiceOptions to configure the service + + + diff --git a/src/Light.TemporaryStreams.Core/TemporaryStream.cs b/src/Light.TemporaryStreams.Core/TemporaryStream.cs index 46ac25e..a0a6dc3 100644 --- a/src/Light.TemporaryStreams.Core/TemporaryStream.cs +++ b/src/Light.TemporaryStreams.Core/TemporaryStream.cs @@ -327,4 +327,9 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo /// public override void WriteByte(byte value) => UnderlyingStream.WriteByte(value); + + /// + /// Resets the position of the stream to the beginning. + /// + public void ResetStreamPosition() => Position = 0; } diff --git a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs index e600e79..d6edcb6 100644 --- a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs +++ b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs @@ -63,6 +63,8 @@ await source { await source.CopyToAsync(temporaryStream, cancellationToken).ConfigureAwait(false); } + + temporaryStream.ResetStreamPosition(); } catch { @@ -141,6 +143,8 @@ await source { await plugins[i].AfterCopyAsync(cancellationToken).ConfigureAwait(false); } + + temporaryStream.ResetStreamPosition(); } catch { diff --git a/src/Light.TemporaryStreams.Core/packages.lock.json b/src/Light.TemporaryStreams.Core/packages.lock.json index 2511975..3b8dd2c 100644 --- a/src/Light.TemporaryStreams.Core/packages.lock.json +++ b/src/Light.TemporaryStreams.Core/packages.lock.json @@ -13,6 +13,26 @@ "requested": "[8.0.17, )", "resolved": "8.0.17", "contentHash": "x5/y4l8AtshpBOrCZdlE4txw8K3e3s9meBFeZeR3l8hbbku2V7kK6ojhXvrbjg1rk3G+JqL1BI26gtgc1ZrdUw==" + }, + "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.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" } } } diff --git a/src/Light.TemporaryStreams/Light.TemporaryStreams.csproj b/src/Light.TemporaryStreams/Light.TemporaryStreams.csproj index 9deda95..6381a73 100644 --- a/src/Light.TemporaryStreams/Light.TemporaryStreams.csproj +++ b/src/Light.TemporaryStreams/Light.TemporaryStreams.csproj @@ -1,5 +1,21 @@  + + + + Provides temporary streams, similar to how IFormFile works in ASP.NET Core. With full integration with Microsoft.Extensions.Logging and Microsoft.Extensions.DependencyInjection. + + Light.TemporaryStreams 1.0.0 + --------------------------------- + + - Initial release 🚀 + - use the services.AddTemporaryStreamService extension method to integrate ITemporaryStreamService into Microsoft.Extensions.DependencyInjection + - use ITemporaryStreamService and the CopyToTemporaryStreamAsync extension method to create temporary seekable streams easily + - use the HashingPlugin to calculate hashes during the copy operation, or write your own plugins via ICopyToTemporaryStreamPlugin + - check out TemporaryStreamServiceOptions to configure the service + + + diff --git a/src/Light.TemporaryStreams/packages.lock.json b/src/Light.TemporaryStreams/packages.lock.json index 74dee5a..7b86821 100644 --- a/src/Light.TemporaryStreams/packages.lock.json +++ b/src/Light.TemporaryStreams/packages.lock.json @@ -23,6 +23,26 @@ "resolved": "8.0.17", "contentHash": "x5/y4l8AtshpBOrCZdlE4txw8K3e3s9meBFeZeR3l8hbbku2V7kK6ojhXvrbjg1rk3G+JqL1BI26gtgc1ZrdUw==" }, + "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.SourceLink.Common": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" + }, "light.temporarystreams.core": { "type": "Project", "dependencies": { diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index b6f38ce..7a2be64 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -11,7 +11,6 @@ false true true - Light.TemporaryStreams diff --git a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs index 65da05d..657442d 100644 --- a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs +++ b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs @@ -289,7 +289,7 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseC ); // Assert - temporaryStream.Should().NotBeNull(); + temporaryStream.Position.Should().Be(0); temporaryStream.Length.Should().Be(sourceData.Length); hashingPlugin.GetHash(nameof(SHA1)).Should().Be(Convert.ToBase64String(SHA1.HashData(sourceData))); } @@ -312,7 +312,7 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldForw ); // Assert - temporaryStream.Should().NotBeNull(); + temporaryStream.Position.Should().Be(0); temporaryStream.IsFileBased.Should().BeTrue(); temporaryStream.Length.Should().Be(sourceData.Length); temporaryStream.GetUnderlyingFilePath().Should().Be(filePath); @@ -456,11 +456,10 @@ private static async Task AssertTemporaryStreamContentsMatchAsync( CancellationToken cancellationToken ) { - temporaryStream.Should().NotBeNull(); + temporaryStream.Position.Should().Be(0); temporaryStream.IsFileBased.Should().Be(expectFileBased); temporaryStream.Length.Should().Be(expectedData.Length); - temporaryStream.Position = 0; var copiedData = new byte[expectedData.Length]; await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); copiedData.Should().Equal(expectedData);