Skip to content

Commit 254225e

Browse files
committed
refactroring + tests
1 parent 554b158 commit 254225e

File tree

25 files changed

+921
-204
lines changed

25 files changed

+921
-204
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ If I tell you to remember something, you do the same, update
1313
- Deliver ASP.NET integrations that expose upload/download controllers, SignalR streaming, and matching HTTP and SignalR clients built on the storage layer for files, streams, and chunked transfers.
1414
- Provide base ASP.NET controllers with minimal routing so consumers can inherit and customize routes, authorization, and behaviors without rigid defaults.
1515
- Favor controller extension patterns and optionally expose interfaces to guide consumers on recommended actions so they can implement custom endpoints easily.
16+
- For comprehensive storage platform upgrades, follow the nine-step flow: solidify SignalR streaming hub/client with logging and tests, harden controller upload paths (standard/stream/chunked) with large-file coverage, add keyed DI registrations and cross-provider sync fixtures, extend VFS with keyed support and >1 GB trials, create streamed large-file/CRC helpers, run end-to-end suites (controllers, SignalR, VFS, cross-provider), verify Blazor upload extensions, expand docs with VFS + provider identity guidance + keyed samples, and finish by running the full preview-enabled test suite addressing warnings.
17+
- Normalise MIME lookups through `MimeHelper`; avoid ad-hoc MIME resolution helpers so all content-type logic flows through its APIs.
1618

1719
# Repository Guidelines
1820

Integraions/ManagedCode.Storage.Client.SignalR/ManagedCode.Storage.Client.SignalR.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222

2323
<ItemGroup>
2424
<PackageReference Include="ManagedCode.MimeTypes" Version="1.0.3" />
25-
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.8" />
26-
<PackageReference Include="Microsoft.AspNetCore.Http.Connections.Client" Version="8.0.8" />
25+
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.9" />
26+
<PackageReference Include="Microsoft.AspNetCore.Http.Connections.Client" Version="9.0.9" />
2727
</ItemGroup>
2828

2929
<ItemGroup>

Integraions/ManagedCode.Storage.Client.SignalR/StorageSignalRClient.cs

Lines changed: 54 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
using System.IO;
44
using System.Linq;
55
using System.Net.Http;
6+
using System.Runtime.CompilerServices;
67
using System.Threading;
7-
using System.Threading.Channels;
88
using System.Threading.Tasks;
99
using ManagedCode.Storage.Client.SignalR.Models;
1010
using Microsoft.AspNetCore.SignalR;
@@ -158,17 +158,11 @@ public async Task<StorageTransferStatus> UploadAsync(Stream stream, StorageUploa
158158
throw new ArgumentException("The upload descriptor must contain a file name.", nameof(descriptor));
159159
}
160160

161-
descriptor.TransferId = string.IsNullOrWhiteSpace(descriptor.TransferId)
162-
? Guid.NewGuid().ToString("N")
163-
: descriptor.TransferId;
164-
165161
if (stream.CanSeek)
166162
{
167163
stream.Seek(0, SeekOrigin.Begin);
168164
}
169165

170-
var handler = CreateProgressRelay(descriptor.TransferId, progress);
171-
172166
var bufferSize = _options?.StreamBufferSize ?? 64 * 1024;
173167
if (bufferSize <= 0)
174168
{
@@ -181,57 +175,32 @@ public async Task<StorageTransferStatus> UploadAsync(Stream stream, StorageUploa
181175
throw new InvalidOperationException("UploadChannelCapacity must be greater than zero.");
182176
}
183177

184-
var channel = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(channelCapacity)
185-
{
186-
SingleReader = true,
187-
SingleWriter = true,
188-
FullMode = BoundedChannelFullMode.Wait
189-
});
178+
var transferId = await connection.InvokeAsync<string>("BeginUploadStreamAsync", descriptor, cancellationToken).ConfigureAwait(false);
179+
descriptor.TransferId = transferId;
190180

191-
Task producerTask = ProduceChunksAsync(stream, channel.Writer, bufferSize, cancellationToken);
192-
var completionSource = new TaskCompletionSource<StorageTransferStatus>(TaskCreationOptions.RunContinuationsAsynchronously);
181+
var handler = CreateProgressRelay(transferId, progress);
193182

194-
void OnCompleted(object? _, StorageTransferStatus status)
195-
{
196-
if (string.Equals(status.TransferId, descriptor.TransferId, StringComparison.OrdinalIgnoreCase))
197-
{
198-
completionSource.TrySetResult(status);
199-
}
200-
}
183+
var statusStream = connection.StreamAsync<StorageTransferStatus>(
184+
"UploadStreamContentAsync",
185+
transferId,
186+
ReadChunksAsync(stream, bufferSize, cancellationToken),
187+
cancellationToken);
201188

202-
void OnFaulted(object? _, StorageTransferStatus status)
203-
{
204-
if (string.Equals(status.TransferId, descriptor.TransferId, StringComparison.OrdinalIgnoreCase))
205-
{
206-
completionSource.TrySetException(new HubException(status.Error ?? "Upload failed."));
207-
}
208-
}
189+
StorageTransferStatus? lastStatus = null;
209190

210-
void OnCanceled(object? _, StorageTransferStatus status)
191+
try
211192
{
212-
if (string.Equals(status.TransferId, descriptor.TransferId, StringComparison.OrdinalIgnoreCase))
193+
await foreach (var status in statusStream.WithCancellation(cancellationToken).ConfigureAwait(false))
213194
{
214-
completionSource.TrySetCanceled(cancellationToken);
195+
lastStatus = status;
215196
}
216197
}
217-
218-
TransferCompleted += OnCompleted;
219-
TransferFaulted += OnFaulted;
220-
TransferCanceled += OnCanceled;
221-
222-
try
223-
{
224-
await connection.SendAsync("UploadStreamAsync", descriptor, channel.Reader, cancellationToken).ConfigureAwait(false);
225-
await producerTask.ConfigureAwait(false);
226-
return await completionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
227-
}
228198
finally
229199
{
230-
TransferCompleted -= OnCompleted;
231-
TransferFaulted -= OnFaulted;
232-
TransferCanceled -= OnCanceled;
233200
handler?.Dispose();
234201
}
202+
203+
return lastStatus ?? throw new HubException($"Upload stream for transfer '{transferId}' completed without status.");
235204
}
236205

237206
/// <inheritdoc />
@@ -261,14 +230,35 @@ public async Task<StorageTransferStatus> DownloadAsync(string blobName, Stream d
261230

262231
destination.Flush();
263232

264-
return lastStatus ?? new StorageTransferStatus
233+
if (lastStatus is null)
265234
{
266-
Operation = "download",
267-
ResourceName = blobName,
268-
BytesTransferred = totalBytes,
269-
TotalBytes = totalBytes,
270-
IsCompleted = true
271-
};
235+
return new StorageTransferStatus
236+
{
237+
Operation = "download",
238+
ResourceName = blobName,
239+
BytesTransferred = totalBytes,
240+
TotalBytes = totalBytes,
241+
IsCompleted = true
242+
};
243+
}
244+
245+
if (!lastStatus.IsCompleted)
246+
{
247+
lastStatus = new StorageTransferStatus
248+
{
249+
TransferId = lastStatus.TransferId,
250+
Operation = lastStatus.Operation,
251+
ResourceName = lastStatus.ResourceName,
252+
BytesTransferred = lastStatus.BytesTransferred > 0 ? lastStatus.BytesTransferred : totalBytes,
253+
TotalBytes = lastStatus.TotalBytes ?? totalBytes,
254+
IsCompleted = true,
255+
IsCanceled = lastStatus.IsCanceled,
256+
Error = lastStatus.Error,
257+
Metadata = lastStatus.Metadata
258+
};
259+
}
260+
261+
return lastStatus;
272262
}
273263

274264
/// <inheritdoc />
@@ -444,27 +434,22 @@ private IDisposable CreateDownloadProgressRelay(string blobName, IProgress<Stora
444434
});
445435
}
446436

447-
private static async Task ProduceChunksAsync(Stream source, ChannelWriter<byte[]> writer, int bufferSize, CancellationToken cancellationToken)
437+
private static async IAsyncEnumerable<byte[]> ReadChunksAsync(
438+
Stream source,
439+
int bufferSize,
440+
[EnumeratorCancellation] CancellationToken cancellationToken)
448441
{
449442
var buffer = new byte[bufferSize];
450-
451-
try
443+
while (true)
452444
{
453-
while (true)
445+
int read = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false);
446+
if (read <= 0)
454447
{
455-
var read = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false);
456-
if (read <= 0)
457-
{
458-
break;
459-
}
460-
461-
var chunk = buffer.Take(read).ToArray();
462-
await writer.WriteAsync(chunk, cancellationToken).ConfigureAwait(false);
448+
yield break;
463449
}
464-
}
465-
finally
466-
{
467-
writer.TryComplete();
450+
451+
var chunk = buffer.AsSpan(0, read).ToArray();
452+
yield return chunk;
468453
}
469454
}
470455

Integraions/ManagedCode.Storage.Server/Controllers/StorageControllerBase.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,28 @@ public static class StorageServerHeaders
218218
/// </summary>
219219
public class StorageServerOptions
220220
{
221+
/// <summary>
222+
/// Default threshold in bytes after which uploads are buffered to disk instead of kept in memory.
223+
/// </summary>
224+
public const int DefaultInMemoryUploadThresholdBytes = 256 * 1024;
225+
226+
/// <summary>
227+
/// Default boundary length limit applied to multipart requests.
228+
/// </summary>
229+
public const int DefaultMultipartBoundaryLengthLimit = 70;
230+
221231
/// <summary>
222232
/// Gets or sets a value indicating whether range processing is enabled for streaming responses.
223233
/// </summary>
224234
public bool EnableRangeProcessing { get; set; } = true;
235+
236+
/// <summary>
237+
/// Gets or sets the maximum payload size (in bytes) that will be buffered in memory before switching to a file-backed upload path.
238+
/// </summary>
239+
public int InMemoryUploadThresholdBytes { get; set; } = DefaultInMemoryUploadThresholdBytes;
240+
241+
/// <summary>
242+
/// Gets or sets the maximum allowed length for multipart boundaries when parsing raw upload streams.
243+
/// </summary>
244+
public int MultipartBoundaryLengthLimit { get; set; } = DefaultMultipartBoundaryLengthLimit;
225245
}

Integraions/ManagedCode.Storage.Server/Extensions/Controller/ControllerUploadExtensions.cs

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
using System.IO;
33
using System.Threading;
44
using System.Threading.Tasks;
5+
using ManagedCode.Communication;
56
using ManagedCode.Storage.Core;
67
using ManagedCode.Storage.Core.Models;
7-
using ManagedCode.Communication;
8+
using ManagedCode.Storage.Server.Controllers;
89
using ManagedCode.Storage.Server.ChunkUpload;
910
using ManagedCode.Storage.Server.Extensions.File;
1011
using ManagedCode.Storage.Server.Helpers;
@@ -14,6 +15,7 @@
1415
using Microsoft.AspNetCore.Mvc;
1516
using Microsoft.AspNetCore.WebUtilities;
1617
using Microsoft.Net.Http.Headers;
18+
using Microsoft.Extensions.DependencyInjection;
1719

1820
namespace ManagedCode.Storage.Server.Extensions.Controller;
1921

@@ -22,8 +24,11 @@ namespace ManagedCode.Storage.Server.Extensions.Controller;
2224
/// </summary>
2325
public static class ControllerUploadExtensions
2426
{
25-
private const int DefaultMultipartBoundaryLengthLimit = 70;
26-
private const int MinLengthForLargeFile = 256 * 1024;
27+
private static StorageServerOptions ResolveServerOptions(ControllerBase controller)
28+
{
29+
var services = controller.HttpContext?.RequestServices;
30+
return services?.GetService<StorageServerOptions>() ?? new StorageServerOptions();
31+
}
2732

2833
/// <summary>
2934
/// Uploads a form file to storage and returns blob metadata.
@@ -32,21 +37,22 @@ public static async Task<BlobMetadata> UploadFormFileAsync(
3237
this ControllerBase controller,
3338
IStorage storage,
3439
IFormFile file,
35-
UploadOptions? options = null,
40+
UploadOptions? uploadOptions = null,
3641
CancellationToken cancellationToken = default)
3742
{
38-
options ??= new UploadOptions(file.FileName, mimeType: file.ContentType);
43+
uploadOptions ??= new UploadOptions(file.FileName, mimeType: file.ContentType);
3944

40-
if (file.Length > MinLengthForLargeFile)
45+
var serverOptions = ResolveServerOptions(controller);
46+
if (file.Length > serverOptions.InMemoryUploadThresholdBytes)
4147
{
4248
var localFile = await file.ToLocalFileAsync(cancellationToken);
43-
var result = await storage.UploadAsync(localFile.FileInfo, options, cancellationToken);
49+
var result = await storage.UploadAsync(localFile.FileInfo, uploadOptions, cancellationToken);
4450
result.ThrowIfFail();
4551
return result.Value!;
4652
}
4753

4854
await using var stream = file.OpenReadStream();
49-
var uploadResult = await storage.UploadAsync(stream, options, cancellationToken);
55+
var uploadResult = await storage.UploadAsync(stream, uploadOptions, cancellationToken);
5056
uploadResult.ThrowIfFail();
5157
return uploadResult.Value!;
5258
}
@@ -58,21 +64,23 @@ public static async Task<BlobMetadata> UploadFromBrowserFileAsync(
5864
this ControllerBase controller,
5965
IStorage storage,
6066
IBrowserFile file,
61-
UploadOptions? options = null,
67+
UploadOptions? uploadOptions = null,
6268
CancellationToken cancellationToken = default)
6369
{
64-
options ??= new UploadOptions(file.Name, mimeType: file.ContentType);
70+
uploadOptions ??= new UploadOptions(file.Name, mimeType: file.ContentType);
71+
72+
var serverOptions = ResolveServerOptions(controller);
6573

66-
if (file.Size > MinLengthForLargeFile)
74+
if (file.Size > serverOptions.InMemoryUploadThresholdBytes)
6775
{
6876
var localFile = await file.ToLocalFileAsync(cancellationToken);
69-
var result = await storage.UploadAsync(localFile.FileInfo, options, cancellationToken);
77+
var result = await storage.UploadAsync(localFile.FileInfo, uploadOptions, cancellationToken);
7078
result.ThrowIfFail();
7179
return result.Value!;
7280
}
7381

7482
await using var stream = file.OpenReadStream();
75-
var uploadResult = await storage.UploadAsync(stream, options, cancellationToken);
83+
var uploadResult = await storage.UploadAsync(stream, uploadOptions, cancellationToken);
7684
uploadResult.ThrowIfFail();
7785
return uploadResult.Value!;
7886
}
@@ -120,17 +128,19 @@ public static async Task<BlobMetadata> UploadFromStreamAsync(
120128
this ControllerBase controller,
121129
IStorage storage,
122130
HttpRequest request,
123-
UploadOptions? options = null,
131+
UploadOptions? uploadOptions = null,
124132
CancellationToken cancellationToken = default)
125133
{
126134
if (!StreamHelper.IsMultipartContentType(request.ContentType))
127135
{
128136
throw new InvalidOperationException("Not a multipart request");
129137
}
130138

139+
var serverOptions = ResolveServerOptions(controller);
140+
131141
var boundary = StreamHelper.GetBoundary(
132142
MediaTypeHeaderValue.Parse(request.ContentType),
133-
DefaultMultipartBoundaryLengthLimit);
143+
serverOptions.MultipartBoundaryLengthLimit);
134144

135145
var multipartReader = new MultipartReader(boundary, request.Body);
136146
var section = await multipartReader.ReadNextSectionAsync(cancellationToken);
@@ -143,13 +153,13 @@ public static async Task<BlobMetadata> UploadFromStreamAsync(
143153
var fileName = contentDisposition.FileName.Value;
144154
var contentType = section.ContentType;
145155

146-
options ??= new UploadOptions(fileName, mimeType: contentType);
156+
uploadOptions ??= new UploadOptions(fileName, mimeType: contentType);
147157

148158
using var memoryStream = new MemoryStream();
149159
await section.Body.CopyToAsync(memoryStream, cancellationToken);
150160
memoryStream.Position = 0;
151161

152-
var result = await storage.UploadAsync(memoryStream, options, cancellationToken);
162+
var result = await storage.UploadAsync(memoryStream, uploadOptions, cancellationToken);
153163
result.ThrowIfFail();
154164
return result.Value!;
155165
}

0 commit comments

Comments
 (0)