Skip to content

Commit 0d8e39f

Browse files
committed
iteration
1 parent 73fbfc8 commit 0d8e39f

18 files changed

+859
-10
lines changed

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ If I tell you to remember something, you do the same, update
99

1010

1111
## Rules to follow
12-
TBA
12+
- Ensure storage-related changes keep broad automated coverage around 85-90% using generic, provider-agnostic tests across file systems, storages, and integrations.
13+
- 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.
14+
- Provide base ASP.NET controllers with minimal routing so consumers can inherit and customize routes, authorization, and behaviors without rigid defaults.
15+
- Favor controller extension patterns and optionally expose interfaces to guide consumers on recommended actions so they can implement custom endpoints easily.
1316

1417
# Repository Guidelines
1518

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Threading;
2+
using System.Threading.Tasks;
3+
using ManagedCode.Communication;
4+
using ManagedCode.Storage.Core.Models;
5+
using ManagedCode.Storage.Server.Models;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Mvc;
8+
9+
namespace ManagedCode.Storage.Server.Controllers;
10+
11+
/// <summary>
12+
/// Describes the recommended set of endpoints for storage-backed controllers.
13+
/// Implementations can inherit <see cref="StorageControllerBase{TStorage}"/> or compose their own controllers using the extension methods.
14+
/// </summary>
15+
public interface IStorageController
16+
{
17+
/// <summary>
18+
/// Uploads a single file using a multipart/form-data request.
19+
/// </summary>
20+
Task<Result<BlobMetadata>> UploadAsync(IFormFile file, CancellationToken cancellationToken);
21+
22+
/// <summary>
23+
/// Uploads a file using the raw request body stream and metadata headers.
24+
/// </summary>
25+
Task<Result<BlobMetadata>> UploadStreamAsync(string fileName, string? contentType, string? directory, CancellationToken cancellationToken);
26+
27+
/// <summary>
28+
/// Returns a file download result for the specified path.
29+
/// </summary>
30+
Task<ActionResult> DownloadAsync(string path, CancellationToken cancellationToken);
31+
32+
/// <summary>
33+
/// Streams file content to the caller, enabling range processing when supported.
34+
/// </summary>
35+
Task<IActionResult> StreamAsync(string path, CancellationToken cancellationToken);
36+
37+
/// <summary>
38+
/// Materialises a file into memory and returns it as a <see cref="FileContentResult"/>.
39+
/// </summary>
40+
Task<ActionResult> DownloadBytesAsync(string path, CancellationToken cancellationToken);
41+
42+
/// <summary>
43+
/// Persists a chunk within an active chunked-upload session.
44+
/// </summary>
45+
Task<Result> UploadChunkAsync(FileUploadPayload payload, CancellationToken cancellationToken);
46+
47+
/// <summary>
48+
/// Completes an upload session by merging chunks and optionally committing to backing storage.
49+
/// </summary>
50+
Task<Result<ChunkUploadCompleteResponse>> CompleteChunksAsync(ChunkUploadCompleteRequest request, CancellationToken cancellationToken);
51+
52+
/// <summary>
53+
/// Aborts an active chunked upload and removes temporary state.
54+
/// </summary>
55+
IActionResult AbortChunks(string uploadId);
56+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using ManagedCode.Storage.Core;
2+
using ManagedCode.Storage.Server.ChunkUpload;
3+
using Microsoft.AspNetCore.Mvc;
4+
5+
namespace ManagedCode.Storage.Server.Controllers;
6+
7+
/// <summary>
8+
/// Default storage controller exposing all storage endpoints using the shared <see cref="IStorage"/> instance.
9+
/// </summary>
10+
[Route("api/storage")]
11+
public class StorageController : StorageControllerBase<IStorage>
12+
{
13+
/// <summary>
14+
/// Initialises a new instance of the default storage controller.
15+
/// </summary>
16+
/// <param name="storage">The shared storage instance.</param>
17+
/// <param name="chunkUploadService">Chunk upload coordinator.</param>
18+
/// <param name="options">Server behaviour options.</param>
19+
public StorageController(
20+
IStorage storage,
21+
ChunkUploadService chunkUploadService,
22+
StorageServerOptions options) : base(storage, chunkUploadService, options)
23+
{
24+
}
25+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
using System;
2+
using System.IO;
3+
using System.Net;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using ManagedCode.Communication;
7+
using ManagedCode.MimeTypes;
8+
using ManagedCode.Storage.Core;
9+
using ManagedCode.Storage.Core.Models;
10+
using ManagedCode.Storage.Server.ChunkUpload;
11+
using ManagedCode.Storage.Server.Extensions.Controller;
12+
using ManagedCode.Storage.Server.Models;
13+
using Microsoft.AspNetCore.Http;
14+
using Microsoft.AspNetCore.Mvc;
15+
16+
namespace ManagedCode.Storage.Server.Controllers;
17+
18+
/// <summary>
19+
/// Provides a reusable ASP.NET Core controller that wires storage upload, download, and chunked-transfer endpoints.
20+
/// </summary>
21+
public abstract class StorageControllerBase<TStorage> : ControllerBase, IStorageController where TStorage : IStorage
22+
{
23+
private readonly StorageServerOptions _options;
24+
25+
/// <summary>
26+
/// Initialises a new instance that exposes storage functionality through HTTP endpoints.
27+
/// </summary>
28+
/// <param name="storage">Storage provider used to fulfil requests.</param>
29+
/// <param name="chunkUploadService">Chunk upload orchestrator.</param>
30+
/// <param name="options">Runtime options controlling streaming behaviour.</param>
31+
protected StorageControllerBase(
32+
TStorage storage,
33+
ChunkUploadService chunkUploadService,
34+
StorageServerOptions options)
35+
{
36+
Storage = storage ?? throw new ArgumentNullException(nameof(storage));
37+
ChunkUploadService = chunkUploadService ?? throw new ArgumentNullException(nameof(chunkUploadService));
38+
_options = options ?? throw new ArgumentNullException(nameof(options));
39+
}
40+
41+
/// <summary>
42+
/// Gets the storage provider used by the controller.
43+
/// </summary>
44+
protected TStorage Storage { get; }
45+
46+
/// <summary>
47+
/// Gets the chunk upload coordinator used for large uploads.
48+
/// </summary>
49+
protected ChunkUploadService ChunkUploadService { get; }
50+
51+
[HttpPost("upload"), ProducesResponseType(typeof(Result<BlobMetadata>), StatusCodes.Status200OK)]
52+
/// <inheritdoc />
53+
public virtual async Task<Result<BlobMetadata>> UploadAsync([FromForm] IFormFile file, CancellationToken cancellationToken)
54+
{
55+
if (file is null)
56+
{
57+
return Result<BlobMetadata>.Fail(HttpStatusCode.BadRequest, "File payload is missing");
58+
}
59+
60+
try
61+
{
62+
return await Result.From(() => this.UploadFormFileAsync(Storage, file, cancellationToken: cancellationToken), cancellationToken);
63+
}
64+
catch (Exception ex)
65+
{
66+
return Result<BlobMetadata>.Fail(ex);
67+
}
68+
}
69+
70+
[HttpPost("upload/stream"), ProducesResponseType(typeof(Result<BlobMetadata>), StatusCodes.Status200OK)]
71+
/// <inheritdoc />
72+
public virtual async Task<Result<BlobMetadata>> UploadStreamAsync(
73+
[FromHeader(Name = StorageServerHeaders.FileName)] string fileName,
74+
[FromHeader(Name = StorageServerHeaders.ContentType)] string? contentType,
75+
[FromHeader(Name = StorageServerHeaders.Directory)] string? directory,
76+
CancellationToken cancellationToken)
77+
{
78+
if (string.IsNullOrWhiteSpace(fileName))
79+
{
80+
return Result<BlobMetadata>.Fail(HttpStatusCode.BadRequest, "X-File-Name header is required");
81+
}
82+
83+
var options = new UploadOptions(fileName, directory, contentType);
84+
85+
try
86+
{
87+
await using var uploadStream = Request.Body;
88+
var result = await Storage.UploadAsync(uploadStream, options, cancellationToken);
89+
return result;
90+
}
91+
catch (Exception ex)
92+
{
93+
return Result<BlobMetadata>.Fail(ex);
94+
}
95+
}
96+
97+
[HttpGet("download/{*path}")]
98+
/// <inheritdoc />
99+
public virtual async Task<ActionResult> DownloadAsync([FromRoute] string path, CancellationToken cancellationToken)
100+
{
101+
if (string.IsNullOrWhiteSpace(path))
102+
{
103+
return Problem("File name is required", statusCode: StatusCodes.Status400BadRequest);
104+
}
105+
106+
var result = await Storage.GetStreamAsync(path, cancellationToken);
107+
if (result.IsFailed)
108+
{
109+
return Problem(result.Problem?.Title ?? "File not found", statusCode: (int?)result.Problem?.StatusCode ?? StatusCodes.Status404NotFound);
110+
}
111+
112+
return File(result.Value, MimeHelper.GetMimeType(path), path, enableRangeProcessing: _options.EnableRangeProcessing);
113+
}
114+
115+
[HttpGet("stream/{*path}")]
116+
/// <inheritdoc />
117+
public virtual async Task<IActionResult> StreamAsync([FromRoute] string path, CancellationToken cancellationToken)
118+
{
119+
if (string.IsNullOrWhiteSpace(path))
120+
{
121+
return Problem("File name is required", statusCode: StatusCodes.Status400BadRequest);
122+
}
123+
124+
var streamResult = await Storage.GetStreamAsync(path, cancellationToken);
125+
if (streamResult.IsFailed)
126+
{
127+
return Problem(streamResult.Problem?.Title ?? "File not found", statusCode: (int?)streamResult.Problem?.StatusCode ?? StatusCodes.Status404NotFound);
128+
}
129+
130+
return File(streamResult.Value, MimeHelper.GetMimeType(path), fileDownloadName: null, enableRangeProcessing: _options.EnableRangeProcessing);
131+
}
132+
133+
[HttpGet("download-bytes/{*path}")]
134+
/// <inheritdoc />
135+
public virtual async Task<ActionResult> DownloadBytesAsync([FromRoute] string path, CancellationToken cancellationToken)
136+
{
137+
if (string.IsNullOrWhiteSpace(path))
138+
{
139+
return Problem("File name is required", statusCode: StatusCodes.Status400BadRequest);
140+
}
141+
142+
var download = await Storage.DownloadAsync(path, cancellationToken);
143+
if (download.IsFailed)
144+
{
145+
return Problem(download.Problem?.Title ?? "File not found", statusCode: (int?)download.Problem?.StatusCode ?? StatusCodes.Status404NotFound);
146+
}
147+
148+
await using var tempStream = new MemoryStream();
149+
await download.Value.FileStream.CopyToAsync(tempStream, cancellationToken);
150+
return File(tempStream.ToArray(), MimeHelper.GetMimeType(path), path);
151+
}
152+
153+
[HttpPost("upload-chunks/upload"), ProducesResponseType(typeof(Result), StatusCodes.Status200OK)]
154+
/// <inheritdoc />
155+
public virtual async Task<Result> UploadChunkAsync([FromForm] FileUploadPayload payload, CancellationToken cancellationToken)
156+
{
157+
if (payload?.File is null)
158+
{
159+
return Result.Fail(HttpStatusCode.BadRequest, "File chunk payload is required");
160+
}
161+
162+
if (payload.Payload is null || string.IsNullOrWhiteSpace(payload.Payload.UploadId))
163+
{
164+
return Result.Fail(HttpStatusCode.BadRequest, "UploadId is required");
165+
}
166+
167+
return await ChunkUploadService.AppendChunkAsync(payload, cancellationToken);
168+
}
169+
170+
[HttpPost("upload-chunks/complete"), ProducesResponseType(typeof(Result<ChunkUploadCompleteResponse>), StatusCodes.Status200OK)]
171+
/// <inheritdoc />
172+
public virtual async Task<Result<ChunkUploadCompleteResponse>> CompleteChunksAsync([FromBody] ChunkUploadCompleteRequest request, CancellationToken cancellationToken)
173+
{
174+
if (request is null)
175+
{
176+
return Result<ChunkUploadCompleteResponse>.Fail(HttpStatusCode.BadRequest, "Completion request is required");
177+
}
178+
return await ChunkUploadService.CompleteAsync(request, Storage, cancellationToken);
179+
}
180+
181+
[HttpDelete("upload-chunks/{uploadId}")]
182+
/// <inheritdoc />
183+
public virtual IActionResult AbortChunks([FromRoute] string uploadId)
184+
{
185+
if (string.IsNullOrWhiteSpace(uploadId))
186+
{
187+
return Problem("Upload id is required", statusCode: StatusCodes.Status400BadRequest);
188+
}
189+
190+
ChunkUploadService.Abort(uploadId);
191+
return NoContent();
192+
}
193+
}
194+
195+
public static class StorageServerHeaders
196+
{
197+
/// <summary>
198+
/// Header name conveying the file name supplied for stream uploads.
199+
/// </summary>
200+
public const string FileName = "X-File-Name";
201+
202+
/// <summary>
203+
/// Header name conveying the MIME type supplied for stream uploads.
204+
/// </summary>
205+
public const string ContentType = "X-Content-Type";
206+
207+
/// <summary>
208+
/// Header name conveying the logical directory for stream uploads.
209+
/// </summary>
210+
public const string Directory = "X-Directory";
211+
}
212+
213+
public class StorageServerOptions
214+
{
215+
/// <summary>
216+
/// Gets or sets a value indicating whether range processing is enabled for streaming responses.
217+
/// </summary>
218+
public bool EnableRangeProcessing { get; set; } = true;
219+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System;
2+
using ManagedCode.Storage.Server.ChunkUpload;
3+
using ManagedCode.Storage.Server.Controllers;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.Extensions.DependencyInjection;
6+
7+
namespace ManagedCode.Storage.Server.Extensions.DependencyInjection;
8+
9+
/// <summary>
10+
/// Provides helpers for wiring storage server components into an <see cref="IServiceCollection"/>.
11+
/// </summary>
12+
public static class StorageServerBuilderExtensions
13+
{
14+
/// <summary>
15+
/// Registers server-side services required for HTTP controllers and chunk uploads.
16+
/// </summary>
17+
/// <param name="services">The service collection.</param>
18+
/// <param name="configureServer">Optional configuration for <see cref="StorageServerOptions"/>.</param>
19+
/// <param name="configureChunks">Optional configuration for <see cref="ChunkUploadOptions"/>.</param>
20+
/// <returns>The original <paramref name="services"/> for chaining.</returns>
21+
public static IServiceCollection AddStorageServer(this IServiceCollection services, Action<StorageServerOptions>? configureServer = null, Action<ChunkUploadOptions>? configureChunks = null)
22+
{
23+
var serverOptions = new StorageServerOptions();
24+
configureServer?.Invoke(serverOptions);
25+
services.AddSingleton(serverOptions);
26+
27+
services.Configure<ApiBehaviorOptions>(options =>
28+
{
29+
options.SuppressModelStateInvalidFilter = true;
30+
});
31+
32+
services.AddChunkUploadHandling(configureChunks);
33+
return services;
34+
}
35+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using ManagedCode.Storage.Server.Hubs;
3+
using Microsoft.Extensions.DependencyInjection;
4+
5+
namespace ManagedCode.Storage.Server.Extensions.DependencyInjection;
6+
7+
/// <summary>
8+
/// Provides registration helpers for SignalR-based storage streaming.
9+
/// </summary>
10+
public static class StorageSignalRServiceCollectionExtensions
11+
{
12+
/// <summary>
13+
/// Registers <see cref="StorageHubOptions"/> for SignalR storage hubs.
14+
/// </summary>
15+
/// <param name="services">Target service collection.</param>
16+
/// <param name="configure">Optional configuration delegate for hub options.</param>
17+
/// <returns>The original <paramref name="services"/>.</returns>
18+
public static IServiceCollection AddStorageSignalR(this IServiceCollection services, Action<StorageHubOptions>? configure = null)
19+
{
20+
var options = new StorageHubOptions();
21+
configure?.Invoke(options);
22+
services.AddSingleton(options);
23+
return services;
24+
}
25+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using ManagedCode.Storage.Server.Hubs;
2+
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.Routing;
4+
5+
namespace ManagedCode.Storage.Server.Extensions;
6+
7+
public static class StorageEndpointRouteBuilderExtensions
8+
{
9+
public static IEndpointRouteBuilder MapStorageHub(this IEndpointRouteBuilder endpoints, string pattern = "/hubs/storage")
10+
{
11+
endpoints.MapHub<StorageHub>(pattern);
12+
return endpoints;
13+
}
14+
}

0 commit comments

Comments
 (0)