Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Compendium.sln
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Integration", "Integration"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.Kubernetes.Sandbox.IntegrationTests", "tests\Integration\Compendium.Adapters.Kubernetes.Sandbox.IntegrationTests\Compendium.Adapters.Kubernetes.Sandbox.IntegrationTests.csproj", "{D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Storage", "src\Abstractions\Compendium.Abstractions.Storage\Compendium.Abstractions.Storage.csproj", "{B4108100-52A7-4680-A0C4-7EB6955DB5F6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Storage.Tests", "tests\Unit\Compendium.Abstractions.Storage.Tests\Compendium.Abstractions.Storage.Tests.csproj", "{585E082A-E775-426B-BEA5-D41D3AF53DE7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -448,6 +452,14 @@ Global
{D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B}.Release|Any CPU.Build.0 = Release|Any CPU
{B4108100-52A7-4680-A0C4-7EB6955DB5F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B4108100-52A7-4680-A0C4-7EB6955DB5F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B4108100-52A7-4680-A0C4-7EB6955DB5F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B4108100-52A7-4680-A0C4-7EB6955DB5F6}.Release|Any CPU.Build.0 = Release|Any CPU
{585E082A-E775-426B-BEA5-D41D3AF53DE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{585E082A-E775-426B-BEA5-D41D3AF53DE7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{585E082A-E775-426B-BEA5-D41D3AF53DE7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{585E082A-E775-426B-BEA5-D41D3AF53DE7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6}
Expand Down Expand Up @@ -529,5 +541,7 @@ Global
{D098747F-BBF1-488B-9CC6-0E89D693440D} = {0CB45A8C-EFA9-418F-A1DE-1C5B082B238A}
{BFC22E6F-1619-4CC2-9773-0B57DA972B64} = {17245130-8317-4D67-B86D-BFA713DE2C1C}
{D8DAEEF0-931B-4CE8-BEC4-767162B4CE7B} = {A2517BFF-352C-4123-81B2-2D848F3FB497}
{B4108100-52A7-4680-A0C4-7EB6955DB5F6} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2}
{585E082A-E775-426B-BEA5-D41D3AF53DE7} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629}
EndGlobalSection
EndGlobal
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<AssemblyName>Compendium.Abstractions.Storage</AssemblyName>
<RootNamespace>Compendium.Abstractions.Storage</RootNamespace>
<PackageId>Compendium.Abstractions.Storage</PackageId>
<Description>Object-storage abstractions for Compendium Framework: IObjectStore port for provider-agnostic object/blob storage (Put / Get / Delete / Exists / List / Presign).</Description>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Compendium.Abstractions\Compendium.Abstractions.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Compendium.Abstractions.Storage.Tests" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// -----------------------------------------------------------------------
// <copyright file="GlobalUsings.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

global using Compendium.Core.Results;
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// -----------------------------------------------------------------------
// <copyright file="IObjectStore.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

using Compendium.Abstractions.Storage.Models;

namespace Compendium.Abstractions.Storage;

/// <summary>
/// Provides provider-agnostic object/blob storage operations.
/// Implementations target backends such as AWS S3, Azure Blob Storage, Google Cloud Storage,
/// MinIO, or local filesystems.
/// </summary>
public interface IObjectStore
{
/// <summary>
/// Uploads an object to the store, replacing any existing object at the same key.
/// </summary>
/// <param name="key">The object key (path) inside the bucket / container.</param>
/// <param name="content">The readable stream containing the payload to upload.</param>
/// <param name="metadata">Optional metadata (content type, cache control, custom headers).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result containing the stored <see cref="ObjectInfo"/> on success, or an error.</returns>
Task<Result<ObjectInfo>> PutAsync(
string key,
Stream content,
ObjectMetadata? metadata = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Downloads an object from the store. The caller is responsible for disposing the returned <see cref="ObjectStream"/>.
/// </summary>
/// <param name="key">The object key to fetch.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result containing the <see cref="ObjectStream"/> on success, or an error.</returns>
Task<Result<ObjectStream>> GetAsync(
string key,
CancellationToken cancellationToken = default);

/// <summary>
/// Deletes an object from the store. Deleting a non-existent key returns success.
/// </summary>
/// <param name="key">The object key to delete.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result indicating success or an error.</returns>
Task<Result> DeleteAsync(
string key,
CancellationToken cancellationToken = default);

/// <summary>
/// Determines whether an object exists at the given key.
/// </summary>
/// <param name="key">The object key to test.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result containing <c>true</c> if the object exists, otherwise <c>false</c>; or an error.</returns>
Task<Result<bool>> ExistsAsync(
string key,
CancellationToken cancellationToken = default);

/// <summary>
/// Lists a single page of objects matching the supplied options.
/// </summary>
/// <param name="options">Listing options (prefix, page size, continuation token). When <c>null</c>, defaults are used.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result containing the next <see cref="ListPage"/>, or an error.</returns>
Task<Result<ListPage>> ListAsync(
ListOptions? options = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Generates a time-limited presigned URL that allows performing the given action without further authentication.
/// </summary>
/// <param name="key">The object key to presign.</param>
/// <param name="action">The action allowed by the presigned URL (<see cref="PresignedAction.Get"/> or <see cref="PresignedAction.Put"/>).</param>
/// <param name="expiresIn">The lifetime of the presigned URL.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A result containing the presigned URL, or an error.</returns>
Task<Result<Uri>> GetPresignedUrlAsync(
string key,
PresignedAction action,
TimeSpan expiresIn,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// -----------------------------------------------------------------------
// <copyright file="ListOptions.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

namespace Compendium.Abstractions.Storage.Models;

/// <summary>
/// Specifies options for listing objects in an object store.
/// </summary>
/// <param name="Prefix">If set, only objects whose key starts with this prefix are returned.</param>
/// <param name="MaxKeys">The maximum number of keys to return in a single page. Defaults to <c>1000</c>.</param>
/// <param name="ContinuationToken">An opaque token returned by a previous list call to fetch the next page.</param>
public sealed record ListOptions(
string? Prefix = null,
int MaxKeys = 1000,
string? ContinuationToken = null);

/// <summary>
/// Represents a single page of results returned by <see cref="IObjectStore.ListAsync"/>.
/// </summary>
/// <param name="Items">The objects in this page, in provider-defined order.</param>
/// <param name="NextContinuationToken">An opaque token used to fetch the next page, or <c>null</c> if no more pages remain.</param>
public sealed record ListPage(
IReadOnlyList<ObjectInfo> Items,
string? NextContinuationToken = null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// -----------------------------------------------------------------------
// <copyright file="ObjectInfo.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

namespace Compendium.Abstractions.Storage.Models;

/// <summary>
/// Describes an object stored in an object store.
/// </summary>
/// <param name="Key">The object key (path) inside the bucket / container.</param>
/// <param name="Size">The size of the object in bytes.</param>
/// <param name="ETag">The provider-supplied entity tag (typically a content hash).</param>
/// <param name="ContentType">The MIME type of the object, when known.</param>
/// <param name="LastModified">The UTC instant at which the object was last modified.</param>
/// <param name="Metadata">Arbitrary user-defined metadata associated with the object.</param>
public sealed record ObjectInfo(
string Key,
long Size,
string ETag,
string? ContentType,
DateTimeOffset LastModified,
IReadOnlyDictionary<string, string>? Metadata = null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// -----------------------------------------------------------------------
// <copyright file="ObjectMetadata.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

namespace Compendium.Abstractions.Storage.Models;

/// <summary>
/// Describes metadata applied to an object on upload.
/// </summary>
/// <param name="ContentType">The MIME type of the object (for example <c>image/png</c>).</param>
/// <param name="CacheControl">The HTTP <c>Cache-Control</c> header to apply when serving the object.</param>
/// <param name="ContentDisposition">The HTTP <c>Content-Disposition</c> header to apply when serving the object.</param>
/// <param name="Custom">Arbitrary user-defined metadata stored alongside the object.</param>
public sealed record ObjectMetadata(
string? ContentType = null,
string? CacheControl = null,
string? ContentDisposition = null,
IReadOnlyDictionary<string, string>? Custom = null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// -----------------------------------------------------------------------
// <copyright file="ObjectStream.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

namespace Compendium.Abstractions.Storage.Models;

/// <summary>
/// Represents the content of an object together with its metadata, returned by
/// <see cref="IObjectStore.GetAsync"/>.
/// </summary>
/// <remarks>
/// Disposing this instance disposes the underlying <see cref="System.IO.Stream"/>.
/// Callers should always dispose the returned object (typically with <c>using</c>).
/// </remarks>
public sealed class ObjectStream : IDisposable, IAsyncDisposable
{
private readonly Stream _content;
private bool _disposed;

/// <summary>
/// Initializes a new instance of the <see cref="ObjectStream"/> class.
/// </summary>
/// <param name="content">The readable content stream. Ownership is transferred to this instance.</param>
/// <param name="info">The metadata describing the object.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="content"/> or <paramref name="info"/> is <c>null</c>.</exception>
public ObjectStream(Stream content, ObjectInfo info)
{
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(info);

_content = content;
Info = info;
}

/// <summary>
/// Gets the readable stream containing the object payload.
/// </summary>
public Stream Content
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _content;
}
}

/// <summary>
/// Gets the metadata describing the object.
/// </summary>
public ObjectInfo Info { get; }

/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}

_content.Dispose();
_disposed = true;
}

/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}

await _content.DisposeAsync().ConfigureAwait(false);
_disposed = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// -----------------------------------------------------------------------
// <copyright file="PresignedAction.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

namespace Compendium.Abstractions.Storage.Models;

/// <summary>
/// Specifies the action allowed by a presigned URL.
/// </summary>
public enum PresignedAction
{
/// <summary>
/// Allows downloading the object via the presigned URL (HTTP GET).
/// </summary>
Get = 0,

/// <summary>
/// Allows uploading the object via the presigned URL (HTTP PUT).
/// </summary>
Put = 1,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// -----------------------------------------------------------------------
// <copyright file="StorageErrors.cs" company="Sassy Solutions">
// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License.
// See LICENSE in the project root for license information.
// </copyright>
// -----------------------------------------------------------------------

namespace Compendium.Abstractions.Storage;

/// <summary>
/// Provides standardized error definitions for object-storage operations.
/// </summary>
public static class StorageErrors
{
/// <summary>
/// Gets the error code prefix for storage errors.
/// </summary>
public const string Prefix = "Storage";

/// <summary>
/// The requested object key was not found in the bucket.
/// </summary>
public static Error NotFound(string key) =>
Error.NotFound($"{Prefix}.NotFound", $"Object '{key}' was not found.");

/// <summary>
/// The caller does not have permission to perform the requested operation.
/// </summary>
public static Error AccessDenied(string key) =>
Error.Forbidden($"{Prefix}.AccessDenied", $"Access denied for object '{key}'.");

/// <summary>
/// The bucket name is missing, malformed, or does not exist.
/// </summary>
public static Error InvalidBucket(string bucket) =>
Error.Validation($"{Prefix}.InvalidBucket", $"Bucket '{bucket}' is invalid or does not exist.");

/// <summary>
/// The provider rejected the request because too many requests have been issued.
/// </summary>
public static Error Throttled(TimeSpan? retryAfter = null) =>
Error.TooManyRequests(
$"{Prefix}.Throttled",
retryAfter.HasValue
? $"Storage provider throttled the request. Retry after {retryAfter.Value.TotalSeconds} seconds."
: "Storage provider throttled the request. Please try again later.");

/// <summary>
/// The object payload exceeds the maximum allowed size.
/// </summary>
public static Error ContentTooLarge(long size, long maximum) =>
Error.Validation(
$"{Prefix}.ContentTooLarge",
$"Object size {size} bytes exceeds the maximum of {maximum} bytes.");

/// <summary>
/// An object with the same key already exists and the operation requires it to be absent.
/// </summary>
public static Error ConflictExists(string key) =>
Error.Conflict($"{Prefix}.ConflictExists", $"Object '{key}' already exists.");
}
Loading
Loading