Skip to content

Commit 36edf06

Browse files
author
Ghaith Prosoft
committed
Refactor file storage API for extensibility and clarity
Refactored `IFileStorageManager` to introduce a more structured and extensible API: - Replaced legacy methods with `CreateAsync` and `CreateWithVariantsAsync` for better file handling. - Added support for associating files with entities via `IFileAttachable`. Enhanced `InMemoryFileStorageManager` and `LocalFileStorageManager`: - Implemented new methods to align with the updated interface. - Improved handling of image variants (e.g., thumbnails, compressed files). - Added utility methods for managing in-memory storage. Introduced `IFileAttachable` interface to enable domain entities to manage file attachments directly. Improved XML documentation, refactored path-handling logic, and removed unused code for better maintainability.
1 parent 0e8e553 commit 36edf06

5 files changed

Lines changed: 129 additions & 51 deletions

File tree

Lines changed: 32 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Microsoft.AspNetCore.Http;
1+
using KitStack.Abstractions.Models;
2+
using Microsoft.AspNetCore.Http;
23

34
namespace KitStack.Abstractions.Interfaces;
45

@@ -9,55 +10,43 @@ namespace KitStack.Abstractions.Interfaces;
910
public interface IFileStorageManager
1011
{
1112
/// <summary>
12-
/// Create / upload content for the given file entry.
13+
/// Create and store a file for the specified entity type <typeparamref name="T"/>.
14+
/// The implementation should produce a populated <see cref="IFileEntry"/> describing
15+
/// the stored primary/original file. Providers may also create image variants according
16+
/// to their configuration but this method returns the primary entry only.
1317
/// </summary>
14-
//Task CreateAsync(IFileEntry fileEntry, Stream content, CancellationToken cancellationToken = default);
15-
16-
///// <summary>
17-
///// Read the file content into a byte array.
18-
///// </summary>
19-
//Task<byte[]> ReadAsync(IFileEntry fileEntry, CancellationToken cancellationToken = default);
20-
21-
///// <summary>
22-
///// Open a read stream for the file content.
23-
///// Caller is responsible for disposing the returned stream.
24-
///// </summary>
25-
//Task<Stream> ReadAsStreamAsync(IFileEntry fileEntry, CancellationToken cancellationToken = default);
26-
27-
///// <summary>
28-
///// Delete the file referenced by fileEntry.
29-
///// </summary>
30-
//Task DeleteAsync(IFileEntry fileEntry, CancellationToken cancellationToken = default);
31-
32-
///// <summary>
33-
///// Mark or move the file into archive tier (provider-defined).
34-
///// </summary>
35-
//Task ArchiveAsync(IFileEntry fileEntry, CancellationToken cancellationToken = default);
36-
37-
///// <summary>
38-
///// Restore the file from archive tier (provider-defined).
39-
///// </summary>
40-
//Task UnArchiveAsync(IFileEntry fileEntry, CancellationToken cancellationToken = default);
18+
/// <typeparam name="T">Entity type the file is associated with.</typeparam>
19+
/// <param name="file">The uploaded form file to store.</param>
20+
/// <param name="category">Logical category or module name used to organize storage (required).</param>
21+
/// <param name="cancellationToken">Cancellation token.</param>
22+
/// <returns>A task that resolves to the stored <see cref="IFileEntry"/>.</returns>
23+
Task<IFileEntry> CreateAsync<T>(IFormFile file, string? category, CancellationToken cancellationToken = default)
24+
where T : class;
4125

4226
/// <summary>
43-
/// High-level convenience: upload an IFormFile for entity T into the configured local store.
44-
/// If the file is an image and image-processing options are enabled, the manager will create
45-
/// variants (thumbnail, compressed, additional sizes) according to configuration.
46-
/// Returns the provider-relative path of the primary stored file (suitable for storing in DB).
27+
/// Create and store a file associated with the provided entity instance.
28+
/// Implementations may attach or mutate the entity (for example adding a FileEntry)
29+
/// when the entity implements <see cref="IFileAttachable"/>.
4730
/// </summary>
48-
//Task<string> UploadAsync<T>(IFormFile? file, string? category, CancellationToken cancellationToken = default)
49-
// where T : class;
31+
/// <typeparam name="T">Entity type which implements <see cref="IFileAttachable"/>.</typeparam>
32+
/// <param name="entity">The entity instance to associate the file with.</param>
33+
/// <param name="file">The uploaded form file to store.</param>
34+
/// <param name="category">Logical category or module name used to organize storage (required).</param>
35+
/// <param name="cancellationToken">Cancellation token.</param>
36+
/// <returns>A task that resolves to the stored <see cref="IFileEntry"/>.</returns>
37+
Task<IFileEntry> CreateAsync<T>(T entity, IFormFile file, string? category, CancellationToken cancellationToken = default)
38+
where T : class, IFileAttachable;
5039

5140
/// <summary>
52-
/// High-level helper: create and store a file associated with the given entity.
53-
/// Returns a populated IFileEntry describing the stored file (primary/original).
54-
/// - The implementation SHOULD NOT mutate the entity by default, but if the entity exposes
55-
/// a compatible method (e.g. AddFileAttachment(FileEntry)) the provider MAY call it.
56-
/// - Image variants (thumbnail/compressed/other sizes) are created according to provider options.
41+
/// Create the primary/original file and any configured image variants (thumbnail, compressed,
42+
/// or additional sizes). Returns the primary <see cref="IFileEntry"/> and a list of variant
43+
/// entries that were created by the provider.
5744
/// </summary>
58-
Task<IFileEntry> CreateAsync<T>(IFormFile file, string? category, CancellationToken cancellationToken = default)
59-
where T : class;
60-
45+
/// <typeparam name="T">Entity type the file is associated with.</typeparam>
46+
/// <param name="file">The uploaded form file to store.</param>
47+
/// <param name="category">Logical category or module name used to organize storage (required).</param>
48+
/// <param name="cancellationToken">Cancellation token.</param>
49+
/// <returns>A task that resolves to a tuple containing the primary entry and created variants.</returns>
6150
Task<(IFileEntry Primary, List<IFileEntry> Variants)> CreateWithVariantsAsync<T>(IFormFile file, string? category, CancellationToken cancellationToken = default)
6251
where T : class;
6352
}

src/KitStack.Abstractions/Models/FileEntry.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@ public class FileEntry : IFileEntry
2727
public DateTimeOffset UploadedTime { get; set; } = DateTimeOffset.UtcNow;
2828

2929
public string? VariantType { get; set; }
30-
}
30+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using KitStack.Abstractions.Interfaces;
2+
3+
namespace KitStack.Abstractions.Models;
4+
5+
/// <summary>
6+
/// Optional interface for domain entities that can hold file attachments.
7+
/// Implement this on your entity classes to receive file entries directly from storage helpers.
8+
/// </summary>
9+
public interface IFileAttachable
10+
{
11+
/// <summary>
12+
/// Add a file attachment to the entity (should not persist by itself).
13+
/// Implementations typically add to an in-memory collection; persistence is the caller's responsibility.
14+
/// </summary>
15+
void AddFileAttachment(IFileEntry fileEntry);
16+
}

src/KitStack.Fakes/Services/InMemoryFileStorageManager.cs

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
using KitStack.Abstractions.Models;
44
using KitStack.Abstractions.Utilities;
55
using System.Runtime.InteropServices;
6-
using System.IO;
7-
using System.Linq;
86
using KitStack.Fakes.Contracts;
97
using KitStack.Fakes.Models;
108
using KitStack.Fakes.Options;
@@ -22,7 +20,16 @@ public class InMemoryFileStorageManager(IOptions<FakeOptions>? options = null) :
2220
private readonly ConcurrentDictionary<string, FakeStoredFile> _store = new();
2321
private readonly FakeOptions _options = options?.Value ?? new FakeOptions();
2422

25-
// High-level convenience to create/store an IFormFile for an entity T (in-memory)
23+
/// <summary>
24+
/// Create and store an uploaded <see cref="IFormFile"/> for the specified entity type <typeparamref name="T"/>.
25+
/// The file content is preserved in memory and a populated <see cref="IFileEntry"/> is returned.
26+
/// The returned entry's <see cref="IFileEntry.FileLocation"/> is a provider-relative path (URL-safe).
27+
/// </summary>
28+
/// <typeparam name="T">Entity type the file is associated with.</typeparam>
29+
/// <param name="file">Uploaded form file to store (required).</param>
30+
/// <param name="category">Logical category or module name used to organize storage (required).</param>
31+
/// <param name="cancellationToken">Cancellation token.</param>
32+
/// <returns>A task that resolves to the stored <see cref="IFileEntry"/>.</returns>
2633
public async Task<IFileEntry> CreateAsync<T>(IFormFile file, string? category, CancellationToken cancellationToken = default)
2734
where T : class
2835
{
@@ -69,6 +76,37 @@ public async Task<IFileEntry> CreateAsync<T>(IFormFile file, string? category, C
6976
return fileEntry;
7077
}
7178

79+
/// <summary>
80+
/// Create and store an uploaded file and associate it with the provided <paramref name="entity"/>.
81+
/// If the entity implements <see cref="IFileAttachable"/>, this method will call
82+
/// <see cref="IFileAttachable.AddFileAttachment"/> to attach the created file entry.
83+
/// </summary>
84+
/// <typeparam name="T">Entity type which implements <see cref="IFileAttachable"/>.</typeparam>
85+
/// <param name="entity">Entity instance to attach the file entry to (required).</param>
86+
/// <param name="file">Uploaded form file to store (required).</param>
87+
/// <param name="category">Logical category or module name used to organize storage (required).</param>
88+
/// <param name="cancellationToken">Cancellation token.</param>
89+
/// <returns>The created primary <see cref="IFileEntry"/>.</returns>
90+
public async Task<IFileEntry> CreateAsync<T>(T entity, IFormFile file, string? category, CancellationToken cancellationToken)
91+
where T : class, IFileAttachable
92+
{
93+
var primary = await CreateAsync<T>(file, category, cancellationToken).ConfigureAwait(false);
94+
95+
entity.AddFileAttachment(primary);
96+
return primary;
97+
}
98+
99+
/// <summary>
100+
/// Create the primary/original file and in-memory image variants (if the uploaded file is an image).
101+
/// Returns the primary <see cref="IFileEntry"/> and a list of created variant entries. Variants are
102+
/// stored in the in-memory fake store so tests can inspect them via <see cref="ListFiles"/> or
103+
/// <see cref="TryGetFile"/>.
104+
/// </summary>
105+
/// <typeparam name="T">Entity type the file is associated with.</typeparam>
106+
/// <param name="file">Uploaded form file to store (required).</param>
107+
/// <param name="category">Logical category or module name used to organize storage (required).</param>
108+
/// <param name="cancellationToken">Cancellation token.</param>
109+
/// <returns>A task that resolves to a tuple containing the primary entry and created variants.</returns>
72110
public async Task<(IFileEntry Primary, List<IFileEntry> Variants)> CreateWithVariantsAsync<T>(IFormFile file, string? category, CancellationToken cancellationToken = default)
73111
where T : class
74112
{
@@ -138,6 +176,13 @@ public async Task<IFileEntry> CreateAsync<T>(IFormFile file, string? category, C
138176
return (primary, variants);
139177
}
140178

179+
/// <summary>
180+
/// Build a simple <see cref="FileEntry"/> describing an in-memory variant file.
181+
/// The <paramref name="relativePath"/> should be a provider-relative path (URL-safe).
182+
/// </summary>
183+
/// <param name="relativePath">Provider-relative path for the variant.</param>
184+
/// <param name="content">Byte content of the variant (used to set Size).</param>
185+
/// <returns>A new <see cref="FileEntry"/> instance for the variant.</returns>
141186
private static FileEntry BuildVariantFileEntryInMemory(string relativePath, byte[] content)
142187
{
143188
// Derive VariantType from the containing folder name
@@ -159,16 +204,34 @@ private static FileEntry BuildVariantFileEntryInMemory(string relativePath, byte
159204
return entry;
160205
}
161206

207+
/// <summary>
208+
/// Simulate a small operation delay when configured via <see cref="FakeOptions.OperationDelayMs"/>.
209+
/// This helps tests exercise timeouts and concurrency scenarios without hitting real IO.
210+
/// </summary>
211+
/// <param name="cancellationToken">Cancellation token.</param>
162212
private async Task SimulateDelayAsync(CancellationToken cancellationToken)
163213
{
164214
if (_options.OperationDelayMs > 0)
165215
await Task.Delay(_options.OperationDelayMs, cancellationToken).ConfigureAwait(false);
166216
}
167217

168218
// IFakeFileStore
219+
/// <summary>
220+
/// Return a read-only snapshot of files currently stored in the fake in-memory store.
221+
/// Useful for assertions in unit tests.
222+
/// </summary>
169223
public IReadOnlyCollection<FakeStoredFile> ListFiles() => _store.Values.ToList().AsReadOnly();
170224

225+
/// <summary>
226+
/// Attempt to retrieve a stored file by its provider-relative location.
227+
/// </summary>
228+
/// <param name="fileLocation">Provider-relative file location to lookup.</param>
229+
/// <param name="file">Out parameter set to the stored file when found; otherwise null.</param>
230+
/// <returns>True if the file was found, false otherwise.</returns>
171231
public bool TryGetFile(string fileLocation, out FakeStoredFile? file) => _store.TryGetValue(fileLocation, out file);
172232

233+
/// <summary>
234+
/// Clear all stored files from the in-memory fake store. Useful for test teardown.
235+
/// </summary>
173236
public void Clear() => _store.Clear();
174237
}

src/KitStack.Storage.Local/Services/LocalFileStorageManager.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ public async Task<IFileEntry> CreateAsync<T>(IFormFile file, string? category, C
8484
}
8585

8686

87+
public async Task<IFileEntry> CreateAsync<T>(T entity, IFormFile file, string? category, CancellationToken cancellationToken = default)
88+
where T : class, IFileAttachable
89+
{
90+
var primary = await CreateAsync<T>(file, category, cancellationToken).ConfigureAwait(false);
91+
92+
entity.AddFileAttachment(primary);
93+
return primary;
94+
}
95+
8796
/// <summary>
8897
/// Create and store the primary file and image variants as configured.
8998
/// Returns the primary FileEntry and a list of FileEntry objects for variants that were created.
@@ -106,10 +115,12 @@ public async Task<IFileEntry> CreateAsync<T>(IFormFile file, string? category, C
106115
await stream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
107116
var bytes = ms.ToArray();
108117

118+
var relativeFolderPath = Path.GetDirectoryName(primary.FileLocation) ?? string.Empty;
119+
var uplaodFolderPath = Path.Combine(_basePath, relativeFolderPath);
109120
// Compressed variant
110121
if (_option.ImageProcessing.CreateCompressed)
111122
{
112-
var compressedRelative = await CreateCompressedVariantAsync(bytes, Path.Combine(_basePath, Path.GetDirectoryName(primary.FileLocation) ?? string.Empty), Path.GetDirectoryName(primary.FileLocation) ?? string.Empty, new Guid(primary.Id.ToString()), cancellationToken).ConfigureAwait(false);
123+
var compressedRelative = await CreateCompressedVariantAsync(bytes, uplaodFolderPath, relativeFolderPath, new Guid(primary.Id.ToString()), cancellationToken).ConfigureAwait(false);
113124
var compressedEntry = BuildVariantFileEntry(compressedRelative);
114125
compressedEntry.Metadata ??= new Dictionary<string, string>();
115126
compressedEntry.VariantType = "compressed";
@@ -123,7 +134,7 @@ public async Task<IFileEntry> CreateAsync<T>(IFormFile file, string? category, C
123134
// Thumbnail variant
124135
if (_option.ImageProcessing.CreateThumbnail)
125136
{
126-
var thumbRelative = await CreateThumbnailVariantAsync(bytes, Path.Combine(_basePath, Path.GetDirectoryName(primary.FileLocation) ?? string.Empty), Path.GetDirectoryName(primary.FileLocation) ?? string.Empty, new Guid(primary.Id.ToString()), cancellationToken).ConfigureAwait(false);
137+
var thumbRelative = await CreateThumbnailVariantAsync(bytes, uplaodFolderPath, relativeFolderPath, new Guid(primary.Id.ToString()), cancellationToken).ConfigureAwait(false);
127138
var thumbEntry = BuildVariantFileEntry(thumbRelative);
128139
thumbEntry.Metadata ??= new Dictionary<string, string>();
129140
thumbEntry.VariantType = "thumbnail";
@@ -137,8 +148,7 @@ public async Task<IFileEntry> CreateAsync<T>(IFormFile file, string? category, C
137148
// Additional sizes
138149
if (_option.ImageProcessing.AdditionalSizes != null && _option.ImageProcessing.AdditionalSizes.Length > 0)
139150
{
140-
var uplaodFolder = Path.Combine(_basePath, Path.GetDirectoryName(primary.FileLocation) ?? string.Empty);
141-
var variantPaths = await CreateAdditionalVariantsAsync(bytes, uplaodFolder, Path.GetDirectoryName(primary.FileLocation) ?? string.Empty, _option.ImageProcessing.AdditionalSizes, cancellationToken).ConfigureAwait(false);
151+
var variantPaths = await CreateAdditionalVariantsAsync(bytes, uplaodFolderPath, relativeFolderPath, _option.ImageProcessing.AdditionalSizes, cancellationToken).ConfigureAwait(false);
142152
var variantEntries = variantPaths.Select(p =>
143153
{
144154
var ve = BuildVariantFileEntry(p);

0 commit comments

Comments
 (0)