×
Back to book

Creating a New Storage Provider (C#Bot)

This article will detail how to create a new storage provider in C#Bot.

Introduction

In C#Bot, files are stored in storage providers, which are an abstraction over any system that can store files. More information can be seen [here](). In this article we will go over the process of extending C#Bot to create a new storage provider.

Code Structure

Before creating a new provider, it is important to understand how a storage provider works. All storage providers are implemented in serverside/src/Services/Files/Providers and all implement the IUploadStorageProvider interface. This interface contains a limited set of storage operations which can be interacted with by the rest of the application. Another key location to consider is the registration of the provider in Startup and adding configuration in serverside/src/Configuration.

Data in a storage provider is stored in a Container and has a FileName. The implementation of a container is up to the storage provider. For example, in a file system based storage provider, a container could be implemented by folders. Conversely, in a key value store based provider, containers could be implemented by prefixing keys with the container name.

Creating a New Provider

For this tutorial we are going to be constructing a basic in-memory storage provider to store our files in. In this implementation of a storage provider, we are going to be implementing the concept of containers with the container name concatenated to the file name separated by a /. This means a key will have the format of Container/FileName.

It is important to note since this is going to be an in-memory store in the source code, it is not suitable for use in a production application and is for tutorial purposes only.

The first thing we must do is create a file for our new provider at serverside/src/Services/Files/Providers/InMemoryStorageProvider.cs. With this file we can add the following contents.

using System.Collections.Concurrent;

namespace Climateaction.Services.Files.Providers
{
    public class InMemoryStorageProvider : IUploadStorageProvider
    {
        /// <summary>
        /// The data store for our files. This is just a simple dictionary that stores byte arrays against keys.
        /// </summary>
        private static readonly ConcurrentDictionary<string, byte[]> _contents = new ConcurrentDictionary<string, byte[]>();
    }
}

Now we have our class skeleton, we can implement the rest of the interface methods we require.

GetAsync

The GetAsync method is used to retrieve a single file from the storage provider. This will return a Task which contains a stream of bytes representing the file.

public Task<Stream> GetAsync(StorageGetOptions options, CancellationToken cancellationToken = default)
{
    var bytes = _contents[$"{options.Container}/{options.FileName}"];
    return Task.FromResult(new MemoryStream(bytes) as Stream);
}

ListAsync

This function will list the contents of a container. Due to our container implementation, we must get all keys that start with Container/.

public Task<IEnumerable<string>> ListAsync(StorageListOptions options, CancellationToken cancellationToken = default)
{
    var keys = _contents.Keys.Where(x => x.StartsWith($"{options.Container}/"));
    return Task.FromResult(keys);
}

ExistsAsync

This method will check if the file exists in the specified container:

public Task<bool> ExistsAsync(StorageExistsOptions options, CancellationToken cancellationToken = default)
{
    return Task.FromResult(_contents.ContainsKey($"{options.Container}/{options.FileName}"));
}

PutAsync

This method is used to create a new file. For this implementation we are reading the stream into a MemoryStream which can then be converted into a byte array and saved to the hashmap. In the case of the overwrite option is set to false, then we should throw an IOException if a file with this name already exists in the container.

public Task PutAsync(StoragePutOptions options, CancellationToken cancellationToken = default)
{
    if (!options.Overwrite && _contents.ContainsKey($"{options.Container}/{options.FileName}"))
    {
        throw new IOException("File already exists");
    }

    var stream = new MemoryStream();
    options.Content.CopyTo(stream);
    _contents[$"{options.Container}/{options.FileName}"] = stream.ToArray();
    return Task.CompletedTask;
}

DeleteAsync

This method is used to delete a file in the provider. For this implementation all we have to do is remove the key from the dictionary.

public Task DeleteAsync(StorageDeleteOptions options, CancellationToken cancellationToken = default)
{
    _contents.Remove($"{options.Container}/{options.FileName}", out _);
    return Task.CompletedTask;
}

ContainerExistsAsync

This method checks to see if a container exists in the provider. For providers which do not have the concept of physical containers (such as this one), this function should return whether any files exist in this container.

public Task<bool> ContainerExistsAsync(StorageContainerExistsOptions options, CancellationToken cancellationToken = default)
{
    var hasFiles = _contents.Keys.Any(x => x.StartsWith($"{options.Container}/"));
    return Task.FromResult(hasFiles);
}

DeleteContainerAsync

This method will delete all files in a container and the container itself, if it is physically manifested.

public Task DeleteContainerAsync(StorageDeleteContainerOptions options, CancellationToken cancellationToken = default)
{
    var keys = _contents.Keys.Where(x => x.StartsWith($"{options.Container}/"));

    foreach (var key in keys)
    {
        _contents.Remove(key, out _);
    }

    return Task.CompletedTask;
}

OnFetch

This function is used to implement a way for a storage provider to manually handle serving the client from the API. If this function returns null, the file controller will fetch the file using GetAsync and return the contents of that function. Otherwise, this function will return a new function which will be executed by the file controller. This is useful if the backing provider already has a method to serve files over http; an example of this is Amazon S3 using presigned urls.

public Func<CancellationToken, Task<IActionResult>> OnFetch(StorageOnFetchOptions options)
{
    return async token =>
    {
        var file = await GetAsync(new StorageGetOptions
        {
            Container = options.File.Container,
            FileName = options.File.FileId,
        }, token);

        var cd = new ContentDispositionHeaderValue(options.Download ? "attachment" : "inline")
        {
            Name = options.File.FileName,
            FileNameStar = options.File.FileName,
            Size = file.Length,
            FileName = options.File.FileName,
        };

        options.HttpContext.Response.Headers["Content-Disposition"] = cd.ToString();
        options.HttpContext.Response.Headers["Content-Type"] = options.File.ContentType;

        return new FileStreamResult(file, options.File.ContentType)
        {
            LastModified = options.File.Modified,
        };
    };
}

It is important to note in this implementation of the storage provider, doing this manually provides no benefits and it ids only done for demonstration purposes. The recommended approach to take would be to return null.

public Func<CancellationToken, Task<IActionResult>> OnFetch(StorageOnFetchOptions options)
{
    return null;
}

Dispose

Since there is nothing we will need to dispose of with this provider, we can provide and empty Dispose method.

public void Dispose()
{
}

Registering the Provider

The provider must now be registered for use in the application. Firstly, we will add this new provider as an option in the configuration. In serverside/src/Configuration/StorageProviderConfiguration.cs change the enum to look like the following.

public enum StorageProviders
{
    FILE_SYSTEM,
    S3,
    // % protected region % [Add any extra storage provider enum entries here] on begin
    IN_MEMORY,
    // % protected region % [Add any extra storage provider enum entries here] end
}

Now we must register this provider in dependency injection. In serverside/src/Startup.cs locate the protected region labelled Configure storage provider services here and change it to the following.

// % protected region % [Configure storage provider services here] on begin
// Configure the file system provider to use
var storageOptions = new StorageProviderConfiguration();
Configuration.GetSection("StorageProvider").Bind(storageOptions);
switch (storageOptions.Provider)
{
    case StorageProviders.S3:
        services.TryAddScoped<IUploadStorageProvider, S3StorageProvider>();
        break;
    case StorageProviders.IN_MEMORY:
        services.TryAddScoped<IUploadStorageProvider, InMemoryStorageProvider>();
        break;
    case StorageProviders.FILE_SYSTEM:
    default:
        services.TryAddScoped<IUploadStorageProvider, FileSystemStorageProvider>();
        break;
}
// % protected region % [Configure storage provider services here] end

Configuring the Provider

Finally we have to change our appsettings to use the provider we made. In serverside/src/appsettings.Development.xml locate the protected region labelled Add any extra app configurations here and add the following.

<!-- % protected region % [Add any extra app configurations here] on begin -->
<StorageProvider>
    <Provider>IN_MEMORY</Provider>
</StorageProvider>
<!-- % protected region % [Add any extra app configurations here] end -->

Now, if you run your server you will be storing files in your new provider.