Skip to content

Commit 213c2a5

Browse files
committed
site
1 parent 8e24159 commit 213c2a5

File tree

76 files changed

+2118
-719
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+2118
-719
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: CI
1+
name: build-and-test
22

33
on:
44
pull_request:

.github/workflows/jekyll-gh-pages.yml

Lines changed: 195 additions & 228 deletions
Large diffs are not rendered by default.

AGENTS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,24 @@ If no new rule is detected → do not update the file.
8383
- Docs live in `docs/` and `README.md`
8484
- Keep a GitHub Pages docs site in sync with `docs/`, using `DOCS-EXAMPLE/` as the reference template for structure and CI/pipeline
8585
- When adding new docs pages under `docs/Features/`, `docs/ADR/`, or `docs/API/`, also update the corresponding `index.md` to link the page so it’s discoverable in the docs catalog/navigation (the site generator will still publish the page even without the link)
86+
- Docs site navigation: do not include a `Templates` page (keep templates in-repo, but don’t surface a dedicated docs-site section for them)
87+
- Docs content: when referencing repo file paths (e.g. `Tests/.../X.cs`), make them clickable by linking to the corresponding GitHub `blob/tree` URL
8688
- Update docs when behaviour changes
8789
- Update configuration examples when required
8890
- Documentation must include clear schemas/diagrams (prefer Mermaid) for every non-trivial feature and integration so GitHub users can understand flows quickly
8991
- When adding new projects/providers, ensure `README.md` clearly documents installation, DI wiring, and basic usage examples
9092
- Where feasible, prefer provider options that can build vendor SDK clients from credentials (to reduce consumer boilerplate) while still allowing client injection for advanced scenarios
9193
- Avoid "ownership flags" like `ownsClient`; prefer a clear swap point (wrapper/factory) so lifetime and disposal rules stay simple and predictable
9294
- For providers that rely on vendor SDK clients (Graph/Drive/Dropbox/etc.), document how to obtain credentials/keys/tokens and include a minimal code snippet that builds the required SDK client instance
95+
- CloudKit docs: explicitly clarify that `ContainerId` is a CloudKit container identifier (not a secret) tied to an Apple App ID, and document the optional `HttpClient`/`ICloudKitClient` injection points for advanced HTTP customization and testing
96+
- Credentials docs: keep provider sections consistent (What you need → Typical steps → Minimal SDK/DI snippet → Suggested configuration keys) so consumers can wire providers without guesswork
97+
- Docs: keep the testing strategy discoverable from `docs/Development/setup.md` (link or embedded section) so users find how/why tests run during initial setup
98+
- Docs: validate all Mermaid diagrams render on the docs site (Mermaid v10.9.5) and fix any syntax errors before shipping docs changes
99+
- Docs site: include `sitemap.xml` and reference it from `robots.txt` so search engines can discover all pages after every rebuild
100+
- Docs site: display the project version from `Directory.Build.props` (not CI run numbers) and keep the footer copyright line slightly smaller than the rest for a cleaner visual hierarchy
101+
- Docs site: display the short project name `Storage` in the site title/nav (keep `ManagedCode.Storage` in content where it refers to package IDs)
102+
- Docs: do not add ADRs for docs-site generation/pipeline changes; document the docs-site build, SEO, and GitHub Pages workflow under `docs/Development/` instead
103+
- Docs site: do not generate redirect/alias pages like `/Storage/`; keep a single canonical home URL (`/`) and remove unused routes
93104

94105
### Testing (ALL TASKS)
95106

@@ -143,6 +154,7 @@ If no new rule is detected → do not update the file.
143154
- Suffix async APIs with `Async`; keep test names aligned with existing patterns (e.g., `DownloadFile_WhenFileExists_ReturnsSuccess`)
144155
- Remove unused usings and let analyzers guide layout
145156
- When a `foreach` loop’s first step is just transforming the iteration variable (e.g., `var y = Map(x)`), prefer mapping the sequence explicitly with `.Select(...)` so intent is clearer and analyzers stay quiet
157+
- Avoid buffering whole files into `MemoryStream` in product code (assume multi‑GB files); stream directly to the destination (response stream / file stream) and use incremental hashing/CRC when you need verification
146158
- No magic literals — extract to constants, enums, or config when it improves clarity
147159

148160
### Git & PRs

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,13 @@ public virtual async Task<ActionResult> DownloadBytesAsync([FromRoute] string pa
146146
return Problem("File name is required", statusCode: StatusCodes.Status400BadRequest);
147147
}
148148

149-
var download = await Storage.DownloadAsync(path, cancellationToken);
150-
if (download.IsFailed)
149+
var streamResult = await Storage.GetStreamAsync(path, cancellationToken);
150+
if (streamResult.IsFailed)
151151
{
152-
return Problem(download.Problem?.Title ?? "File not found", statusCode: (int?)download.Problem?.StatusCode ?? StatusCodes.Status404NotFound);
152+
return Problem(streamResult.Problem?.Title ?? "File not found", statusCode: (int?)streamResult.Problem?.StatusCode ?? StatusCodes.Status404NotFound);
153153
}
154154

155-
await using var tempStream = new MemoryStream();
156-
await download.Value.FileStream.CopyToAsync(tempStream, cancellationToken);
157-
return File(tempStream.ToArray(), MimeHelper.GetMimeType(path), path);
155+
return File(streamResult.Value, MimeHelper.GetMimeType(path), path, enableRangeProcessing: _options.EnableRangeProcessing);
158156
}
159157

160158
/// <inheritdoc />
@@ -237,6 +235,11 @@ public class StorageServerOptions
237235
/// </summary>
238236
public const int DefaultInMemoryUploadThresholdBytes = 256 * 1024;
239237

238+
/// <summary>
239+
/// Default threshold in bytes for endpoints that return an in-memory payload (for example: <see cref="FileContentResult"/>).
240+
/// </summary>
241+
public const int DefaultInMemoryDownloadThresholdBytes = 256 * 1024;
242+
240243
/// <summary>
241244
/// Default boundary length limit applied to multipart requests.
242245
/// </summary>
@@ -262,6 +265,12 @@ public class StorageServerOptions
262265
/// </summary>
263266
public int InMemoryUploadThresholdBytes { get; set; } = DefaultInMemoryUploadThresholdBytes;
264267

268+
/// <summary>
269+
/// Gets or sets the maximum payload size (in bytes) that will be materialised in memory when an API returns the entire payload as bytes.
270+
/// Use streaming endpoints for larger payloads.
271+
/// </summary>
272+
public int InMemoryDownloadThresholdBytes { get; set; } = DefaultInMemoryDownloadThresholdBytes;
273+
265274
/// <summary>
266275
/// Gets or sets the maximum allowed length for multipart boundaries when parsing raw upload streams.
267276
/// </summary>

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
using System;
12
using System.IO;
23
using System.Threading;
34
using System.Threading.Tasks;
45
using ManagedCode.MimeTypes;
56
using ManagedCode.Storage.Core;
7+
using ManagedCode.Storage.Server.Controllers;
68
using Microsoft.AspNetCore.Http;
79
using Microsoft.AspNetCore.Mvc;
10+
using Microsoft.Extensions.DependencyInjection;
811
using IResult = Microsoft.AspNetCore.Http.IResult;
912

1013
namespace ManagedCode.Storage.Server.Extensions.Controller;
@@ -14,6 +17,12 @@ namespace ManagedCode.Storage.Server.Extensions.Controller;
1417
/// </summary>
1518
public static class ControllerDownloadExtensions
1619
{
20+
private static StorageServerOptions ResolveServerOptions(ControllerBase controller)
21+
{
22+
var services = controller.HttpContext?.RequestServices;
23+
return services?.GetService<StorageServerOptions>() ?? new StorageServerOptions();
24+
}
25+
1726
/// <summary>
1827
/// Streams the specified blob to the caller using <see cref="IResult"/>.
1928
/// </summary>
@@ -61,13 +70,24 @@ public static async Task<FileContentResult> DownloadAsFileContentResultAsync(
6170
string blobName,
6271
CancellationToken cancellationToken = default)
6372
{
73+
var serverOptions = ResolveServerOptions(controller);
74+
6475
var result = await storage.DownloadAsync(blobName, cancellationToken);
6576
if (result.IsFailed)
6677
throw new FileNotFoundException(blobName);
6778

68-
using var memoryStream = new MemoryStream();
69-
await result.Value.FileStream.CopyToAsync(memoryStream, cancellationToken);
70-
return new FileContentResult(memoryStream.ToArray(), MimeHelper.GetMimeType(blobName))
79+
await using var localFile = result.Value;
80+
81+
var length = localFile.FileInfo.Length;
82+
if (length > serverOptions.InMemoryDownloadThresholdBytes)
83+
{
84+
throw new InvalidOperationException(
85+
$"Blob '{blobName}' is {length} bytes which exceeds the in-memory download threshold of {serverOptions.InMemoryDownloadThresholdBytes} bytes. " +
86+
"Use DownloadAsFileResultAsync or DownloadAsStreamAsync for large files.");
87+
}
88+
89+
var bytes = await localFile.ReadAllBytesAsync(cancellationToken);
90+
return new FileContentResult(bytes, MimeHelper.GetMimeType(blobName))
7191
{
7292
FileDownloadName = blobName
7393
};

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public static async Task<BlobMetadata> UploadFormFileAsync(
4545
var serverOptions = ResolveServerOptions(controller);
4646
if (file.Length > serverOptions.InMemoryUploadThresholdBytes)
4747
{
48-
var localFile = await file.ToLocalFileAsync(cancellationToken);
48+
await using var localFile = await file.ToLocalFileAsync(cancellationToken);
4949
var result = await storage.UploadAsync(localFile.FileInfo, uploadOptions, cancellationToken);
5050
result.ThrowIfFail();
5151
return result.Value!;
@@ -73,7 +73,7 @@ public static async Task<BlobMetadata> UploadFromBrowserFileAsync(
7373

7474
if (file.Size > serverOptions.InMemoryUploadThresholdBytes)
7575
{
76-
var localFile = await file.ToLocalFileAsync(cancellationToken);
76+
await using var localFile = await file.ToLocalFileAsync(cancellationToken);
7777
var result = await storage.UploadAsync(localFile.FileInfo, uploadOptions, cancellationToken);
7878
result.ThrowIfFail();
7979
return result.Value!;
@@ -155,11 +155,7 @@ public static async Task<BlobMetadata> UploadFromStreamAsync(
155155

156156
uploadOptions ??= new UploadOptions(fileName, mimeType: contentType);
157157

158-
using var memoryStream = new MemoryStream();
159-
await section.Body.CopyToAsync(memoryStream, cancellationToken);
160-
memoryStream.Position = 0;
161-
162-
var result = await storage.UploadAsync(memoryStream, uploadOptions, cancellationToken);
158+
var result = await storage.UploadAsync(section.Body, uploadOptions, cancellationToken);
163159
result.ThrowIfFail();
164160
return result.Value!;
165161
}

Integraions/ManagedCode.Storage.Server/Extensions/File/BrowserFileExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ public static class BrowserFileExtensions
1010
public static async Task<LocalFile> ToLocalFileAsync(this IBrowserFile formFile, CancellationToken cancellationToken = default)
1111
{
1212
var localFile = LocalFile.FromRandomNameWithExtension(formFile.Name);
13-
await localFile.CopyFromStreamAsync(formFile.OpenReadStream(), cancellationToken);
13+
await using (var stream = formFile.OpenReadStream())
14+
{
15+
await localFile.CopyFromStreamAsync(stream, cancellationToken);
16+
}
1417
return localFile;
1518
}
1619
}

Integraions/ManagedCode.Storage.Server/Extensions/File/FormFileExtensions.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Collections.Generic;
2+
using System.Linq;
23
using System.Runtime.CompilerServices;
34
using System.Threading;
45
using System.Threading.Tasks;
@@ -12,14 +13,19 @@ public static class FormFileExtensions
1213
public static async Task<LocalFile> ToLocalFileAsync(this IFormFile formFile, CancellationToken cancellationToken = default)
1314
{
1415
var localFile = LocalFile.FromRandomNameWithExtension(formFile.FileName);
15-
await localFile.CopyFromStreamAsync(formFile.OpenReadStream(), cancellationToken);
16+
await using (var stream = formFile.OpenReadStream())
17+
{
18+
await localFile.CopyFromStreamAsync(stream, cancellationToken);
19+
}
1620
return localFile;
1721
}
1822

1923
public static async IAsyncEnumerable<LocalFile> ToLocalFilesAsync(this IFormFileCollection formFileCollection,
2024
[EnumeratorCancellation] CancellationToken cancellationToken = default)
2125
{
22-
foreach (var formFile in formFileCollection)
23-
yield return await formFile.ToLocalFileAsync(cancellationToken);
26+
foreach (var localFileTask in formFileCollection.Select(formFile => formFile.ToLocalFileAsync(cancellationToken)))
27+
{
28+
yield return await localFileTask;
29+
}
2430
}
2531
}

ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,7 @@ public async Task<Stream> OpenWriteAsync(
184184
}
185185
}
186186

187-
// For now, return a memory stream that will be uploaded when disposed
188-
// This is a simplified implementation - real streaming would require provider-specific support
187+
// Return a file-backed write stream that uploads on dispose to avoid buffering large payloads in memory.
189188
return new VfsWriteStream(_vfs.Storage, _path.ToBlobKey(), options, _cache, _vfs.Options, _logger);
190189
}
191190

@@ -217,10 +216,22 @@ public async Task<byte[]> ReadAllBytesAsync(CancellationToken cancellationToken
217216
{
218217
_logger.LogDebug("Reading all bytes: {Path}", _path);
219218

220-
await using var stream = await OpenReadAsync(cancellationToken: cancellationToken);
221-
using var memoryStream = new MemoryStream();
222-
await stream.CopyToAsync(memoryStream, cancellationToken);
223-
return memoryStream.ToArray();
219+
await EnsureMetadataLoadedAsync(cancellationToken);
220+
221+
if (_blobMetadata == null)
222+
{
223+
throw new VfsNotFoundException(_path);
224+
}
225+
226+
var result = await _vfs.Storage.DownloadAsync(_path.ToBlobKey(), cancellationToken);
227+
228+
if (!result.IsSuccess || result.Value == null)
229+
{
230+
throw new VfsOperationException($"Failed to read all bytes for file: {_path}");
231+
}
232+
233+
await using var localFile = result.Value;
234+
return await localFile.ReadAllBytesAsync(cancellationToken);
224235
}
225236

226237
/// <inheritdoc />
@@ -269,7 +280,9 @@ public async ValueTask<IReadOnlyDictionary<string, string>> GetMetadataAsync(
269280
{
270281
var cacheKey = $"file_custom_metadata:{_vfs.ContainerName}:{_path}";
271282

272-
if (_vfs.Options.EnableCache && _cache.TryGetValue(cacheKey, out IReadOnlyDictionary<string, string> cached))
283+
if (_vfs.Options.EnableCache
284+
&& _cache.TryGetValue(cacheKey, out IReadOnlyDictionary<string, string>? cached)
285+
&& cached is not null)
273286
{
274287
_logger.LogDebug("File metadata (cached): {Path}", _path);
275288
return cached;
@@ -281,7 +294,7 @@ public async ValueTask<IReadOnlyDictionary<string, string>> GetMetadataAsync(
281294
{
282295
_cache.Set(cacheKey, metadata, _vfs.Options.CacheTTL);
283296
var metadataKey = $"file_metadata:{_vfs.ContainerName}:{_path}";
284-
if (_cache.TryGetValue(metadataKey, out MetadataCacheEntry entry))
297+
if (_cache.TryGetValue(metadataKey, out MetadataCacheEntry? entry) && entry is not null)
285298
{
286299
entry.CustomMetadata = metadata;
287300
_cache.Set(metadataKey, entry, _vfs.Options.CacheTTL);
@@ -358,7 +371,7 @@ private async Task EnsureMetadataLoadedAsync(CancellationToken cancellationToken
358371
if (_vfs.Options.EnableCache)
359372
{
360373
var metadataKey = $"file_metadata:{_vfs.ContainerName}:{_path}";
361-
if (_cache.TryGetValue(metadataKey, out MetadataCacheEntry entry))
374+
if (_cache.TryGetValue(metadataKey, out MetadataCacheEntry? entry) && entry is not null)
362375
{
363376
_vfsMetadata = entry.Metadata;
364377
_blobMetadata = entry.BlobMetadata;
@@ -376,4 +389,4 @@ private async Task EnsureMetadataLoadedAsync(CancellationToken cancellationToken
376389
// This is a simplified ETag extraction - real implementation would depend on the storage provider
377390
return null;
378391
}
379-
}
392+
}

0 commit comments

Comments
 (0)