4
\$\begingroup\$

I asked about an abstraction layer for accessing files (link) a couple of days ago (I decided to not call it a follow-up anymore as there are too many changes). I tried to incorporate many of the ideas from the feedbacks but it wasn't easy. In the meantime I noticed that what I'm actually doing is nothing else but CRUD so by implementing this pattern I was able to solve a lot more problems that I initially intended to. I think I now have found THE API that is consistent, flexible and allows to work with various kinds of resources; and it's also dependency-injection-friendly.

The idea for the new API is inspired by REST and specifically by HTTP verbs: GET, PUT, POST, DELETE and that resources are being accessed by some name.

[PublicAPI]
public interface IResourceProvider
{
    [NotNull]
    ResourceMetadata Metadata { get; }

    [ItemNotNull]
    Task<IResourceInfo> GetAsync([NotNull] UriString uri, ResourceMetadata metadata = null);

    [ItemNotNull]
    Task<IResourceInfo> PostAsync([NotNull] UriString uri, [NotNull] Stream value, ResourceMetadata metadata = null);

    [ItemNotNull]
    Task<IResourceInfo> PutAsync([NotNull] UriString uri, [NotNull] Stream value, ResourceMetadata metadata = null);

    [ItemNotNull]
    Task<IResourceInfo> DeleteAsync([NotNull] UriString uri, ResourceMetadata metadata = null);
}

This main interface is pretty generic so that I can use it with different resources. The IResourceInfo that each method returns is defined as:

[PublicAPI]
public interface IResourceInfo : IEquatable<IResourceInfo>, IEquatable<string>
{
    [NotNull]
    UriString Uri { get; }

    bool Exists { get; }

    long? Length { get; }

    DateTime? CreatedOn { get; }

    DateTime? ModifiedOn { get; }

    Task CopyToAsync(Stream stream);

    Task<object> DeserializeAsync(Type targetType);
}

and provides basic information about the resource.

Resources are identified by my UriString. I use it as replacement for Uri. (I asked about it here where it was called SimpleUri.)

ResourceMetadata is just a wrapper around the IImmutableDictionary

public class ResourceMetadata
{
    private readonly IImmutableDictionary<SoftString, object> _metadata;

    private ResourceMetadata(IImmutableDictionary<SoftString, object> metadata) => _metadata = metadata;

    public static ResourceMetadata Empty => new ResourceMetadata(ImmutableDictionary<SoftString, object>.Empty);

    public object this[SoftString key] => _metadata[key];
    public int Count => _metadata.Count;
    public IEnumerable<SoftString> Keys => _metadata.Keys;
    public IEnumerable<object> Values => _metadata.Values;
    public bool ContainsKey(SoftString key) => _metadata.ContainsKey(key);
    public bool Contains(KeyValuePair<SoftString, object> pair) => _metadata.Contains(pair);
    public bool TryGetKey(SoftString equalKey, out SoftString actualKey) => _metadata.TryGetKey(equalKey, out actualKey);
    public bool TryGetValue(SoftString key, out object value) => _metadata.TryGetValue(key, out value);
    public ResourceMetadata Add(SoftString key, object value) => new ResourceMetadata(_metadata.Add(key, value));
    public ResourceMetadata SetItem(SoftString key, object value) => new ResourceMetadata(_metadata.SetItem(key, value));
}

Its purpose is to carry additional information about the request (like a specific resource-provider name) or resource-provider abbilities or other metadata:

public static class ResourceMetadataKeys
{
    public static string ProviderDefaultName { get; } = nameof(ProviderDefaultName);
    public static string ProviderCustomName { get; } = nameof(ProviderCustomName);
    public static string CanGet { get; } = nameof(CanGet);
    public static string CanPost { get; } = nameof(CanPost);
    public static string CanPut { get; } = nameof(CanPut);
    public static string CanDelete { get; } = nameof(CanDelete);
    public static string Scheme { get; } = nameof(Scheme);
    public static string Serializer { get; } = nameof(Serializer);
    public static string AllowRelativeUri { get; } = nameof(AllowRelativeUri);
}

Each of these interfaces have an abstract implementation. Their purpose is provide general parameter validation and to wrap inner exceptions in exceptions adding information about the resource-provider and the request. This is useful for debugging and helps to reduce error-handling in concrete types. This also specifies a DefaultScheme which is ionymous. It means that the scheme part is irrelevant and should be ignored. This is used by the CompositeResourceProvider that can look for a resource in several places. Otherwise scheme matters.

[DebuggerDisplay("{DebuggerDisplay,nq}")]
public abstract class ResourceProvider : IResourceProvider
{
    public static readonly string DefaultScheme = "ionymous";

    protected ResourceProvider(ResourceMetadata metadata)
    {
        if (!metadata.ContainsKey(ResourceMetadataKeys.Scheme)) throw new ArgumentException(paramName: nameof(metadata), message: $"Resource provider metadata must specify the scheme.");

        // If this is a decorator then the decorated resource-provider already has set this.
        if (!metadata.ContainsKey(ProviderDefaultName))
        {
            metadata = metadata.Add(ProviderDefaultName, GetType().ToPrettyString());
        }

        Metadata = metadata;
    }

    private string DebuggerDisplay => this.ToDebuggerDisplayString(builder =>
    {            
        builder.DisplayCollection(x => x.Metadata.ProviderNames());
        builder.DisplayMember(x => x.Scheme);
    });

    public virtual ResourceMetadata Metadata { get; }

    public virtual SoftString Scheme => (SoftString)(string)Metadata[ResourceMetadataKeys.Scheme];

    #region Wrappers

    // These wrappers are to provide helpful exceptions.

    public async Task<IResourceInfo> GetAsync(UriString uri, ResourceMetadata metadata = null)
    {
        ValidateCanMethod();
        ValidateSchemeNotEmpty(uri);
        ValidateSchemeMatches(uri);

        try
        {
            return await GetAsyncInternal(uri, metadata);
        }
        catch (Exception inner)
        {
            throw CreateException(uri, metadata, inner);
        }
    }

    public async Task<IResourceInfo> PostAsync(UriString uri, Stream value, ResourceMetadata metadata = null)
    {
        ValidateCanMethod();
        ValidateSchemeNotEmpty(uri);
        ValidateSchemeMatches(uri);

        try
        {
            return await PostAsyncInternal(uri, value, metadata);
        }
        catch (Exception inner)
        {
            throw CreateException(uri, metadata, inner);
        }
    }

    public async Task<IResourceInfo> PutAsync(UriString uri, Stream value, ResourceMetadata metadata = null)
    {
        ValidateCanMethod();
        ValidateSchemeNotEmpty(uri);
        ValidateSchemeMatches(uri);

        try
        {
            return await PutAsyncInternal(uri, value, metadata);
        }
        catch (Exception inner)
        {
            throw CreateException(uri, metadata, inner);
        }
    }

    public async Task<IResourceInfo> DeleteAsync(UriString uri, ResourceMetadata metadata = null)
    {
        ValidateCanMethod();
        ValidateSchemeNotEmpty(uri);
        ValidateSchemeMatches(uri);

        try
        {
            return await DeleteAsyncInternal(uri, metadata);
        }
        catch (Exception inner)
        {
            throw CreateException(uri, metadata, inner);
        }
    }

    #endregion

    #region Internal

    protected virtual Task<IResourceInfo> GetAsyncInternal(UriString uri, ResourceMetadata metadata = null) => throw new NotSupportedException();

    protected virtual Task<IResourceInfo> PostAsyncInternal(UriString uri, Stream value, ResourceMetadata metadata = null) => throw new NotSupportedException();

    protected virtual Task<IResourceInfo> PutAsyncInternal(UriString uri, Stream value, ResourceMetadata metadata = null) => throw new NotSupportedException();

    protected virtual Task<IResourceInfo> DeleteAsyncInternal(UriString uri, ResourceMetadata metadata = null) => throw new NotSupportedException();

    #endregion

    #region Helpers

    protected Exception CreateException(UriString uri, ResourceMetadata metadata, Exception inner, [CallerMemberName] string memberName = null)
    {
        // ReSharper disable once AssignNullToNotNullAttribute
        var method = Regex.Replace(memberName, "Async$", string.Empty).ToUpper();

        throw DynamicException.Create
        (
            memberName,
            $"{GetType().ToPrettyString()} was unable to perform {method} for the given resource '{uri}'.",
            inner
        );
    }

    #endregion

    #region Validations

    protected void ValidateSchemeMatches([NotNull] UriString uri)
    {
        if (Metadata.TryGetValue(AllowRelativeUri, out bool allow) && allow)
        {
            return;
        }

        if (SoftString.Comparer.Equals(Scheme, DefaultScheme))
        {
            return;
        }

        if (!SoftString.Comparer.Equals(uri.Scheme, Scheme))
        {
            throw DynamicException.Create
            (
                "InvalidScheme",
                $"{GetType().ToPrettyString()} requires scheme '{Scheme}'."
            );
        }
    }

    protected void ValidateCanMethod([CallerMemberName] string memberName = null)
    {
        // ReSharper disable once AssignNullToNotNullAttribute
        var method = Regex.Replace(memberName, "Async$", string.Empty);

        if (!Metadata.TryGetValue($"Can{method}", out bool can) || !can)
        {
            throw DynamicException.Create
            (
                $"{method}NotSupported",
                $"{GetType().ToPrettyString()} doesn't support '{method.ToUpper()}'."
            );
        }
    }

    protected void ValidateSchemeNotEmpty([NotNull] UriString uri)
    {
        if (Metadata.TryGetValue(AllowRelativeUri, out bool allow) && allow)
        {
            return;
        }

        if (!uri.Scheme)
        {
            throw DynamicException.Create("SchemeNotFound", $"Uri '{uri}' does not contain scheme.");
        }
    }

    #endregion
}

ResourceInfo is implemented as following:

[PublicAPI]
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public abstract class ResourceInfo : IResourceInfo
{
    protected ResourceInfo([NotNull] UriString uri)
    {
        if (uri == null) throw new ArgumentNullException(nameof(uri));

        Uri = uri.IsRelative ? new UriString($"{ResourceProvider.DefaultScheme}:{uri}") : uri;
    }

    private string DebuggerDisplay => this.ToDebuggerDisplayString(builder =>
    {
        builder.DisplayMember(x => x.Uri);
        builder.DisplayMember(x => x.Exists);
    });

    #region IResourceInfo

    public virtual UriString Uri { get; }

    public abstract bool Exists { get; }

    public abstract long? Length { get; }

    public abstract DateTime? CreatedOn { get; }

    public abstract DateTime? ModifiedOn { get; }

    #endregion

    #region Wrappers

    // These wrappers are to provide helpful exceptions.

    public async Task CopyToAsync(Stream stream)
    {
        AssertExists();

        try
        {
            await CopyToAsyncInternal(stream);
        }
        catch (Exception inner)
        {
            throw DynamicException.Create
            (
                $"{nameof(CopyToAsync)}",
                $"Affected resource '{Uri}'.",
                inner
            );
        }
    }

    public async Task<object> DeserializeAsync(Type targetType)
    {
        AssertExists();

        try
        {
            return await DeserializeAsyncInternal(targetType);
        }
        catch (Exception inner)
        {
            throw DynamicException.Create
            (
                $"{nameof(DeserializeAsync)}",
                $"Affected resource '{Uri}'.",
                inner
            );
        }
    }

    #endregion

    #region Internal

    protected abstract Task CopyToAsyncInternal(Stream stream);

    protected abstract Task<object> DeserializeAsyncInternal(Type targetType);

    #endregion

    #region IEquatable<IResourceInfo>

    public override bool Equals(object obj) => obj is IResourceInfo resource && Equals(resource);

    public bool Equals(IResourceInfo other) => ResourceInfoEqualityComparer.Default.Equals(other, this);

    public bool Equals(string other) => !string.IsNullOrWhiteSpace(other) && ResourceInfoEqualityComparer.Default.Equals((UriString)other, Uri);

    public override int GetHashCode() => ResourceInfoEqualityComparer.Default.GetHashCode(this);

    #endregion

    #region Helpers

    protected void AssertExists([CallerMemberName] string memberName = null)
    {
        if (!Exists)
        {
            // ReSharper disable once AssignNullToNotNullAttribute
            throw DynamicException.Create(memberName, $"Resource '{Uri}' does not exist.");
        }
    }

    #endregion
}

Example

As an example let me show you one of my many new resource-providers, the AppSettingProvider:

public class AppSettingProvider : ResourceProvider
{
    private readonly ITypeConverter _uriStringToSettingIdentifierConverter;

    public AppSettingProvider(ITypeConverter uriStringToSettingIdentifierConverter = null)
        : base(
            ResourceMetadata.Empty
                .Add(ResourceMetadataKeys.CanGet, true)
                .Add(ResourceMetadataKeys.CanPut, true)
                .Add(ResourceMetadataKeys.Scheme, "setting")
        )
    {
        _uriStringToSettingIdentifierConverter = uriStringToSettingIdentifierConverter;
    }

    protected override Task<IResourceInfo> GetAsyncInternal(UriString uri, ResourceMetadata metadata = null)
    {
        var settingIdentifier = (string)_uriStringToSettingIdentifierConverter?.Convert(uri, typeof(string)) ?? uri;
        var exeConfig = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
        var actualKey = FindActualKey(exeConfig, settingIdentifier) ?? settingIdentifier;
        var element = exeConfig.AppSettings.Settings[actualKey];
        return Task.FromResult<IResourceInfo>(new AppSettingInfo(uri, element?.Value));
    }

    protected override async Task<IResourceInfo> PutAsyncInternal(UriString uri, Stream stream, ResourceMetadata metadata = null)
    {
        using (var valueReader = new StreamReader(stream))
        {
            var value = await valueReader.ReadToEndAsync();

            var settingIdentifier = (string)_uriStringToSettingIdentifierConverter?.Convert(uri, typeof(string)) ?? uri;
            var exeConfig = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
            var actualKey = FindActualKey(exeConfig, settingIdentifier) ?? settingIdentifier;
            var element = exeConfig.AppSettings.Settings[actualKey];

            if (element is null)
            {
                exeConfig.AppSettings.Settings.Add(settingIdentifier, value);
            }
            else
            {
                exeConfig.AppSettings.Settings[actualKey].Value = value;
            }

            exeConfig.Save(ConfigurationSaveMode.Minimal);

            return await GetAsync(uri);
        }
    }

    [CanBeNull]
    private static string FindActualKey(Configuration exeConfig, string key)
    {
        return
            exeConfig
                .AppSettings
                .Settings
                .AllKeys
                .FirstOrDefault(k => SoftString.Comparer.Equals(k, key));
    }
}

internal class AppSettingInfo : ResourceInfo
{
    [CanBeNull] 
    private readonly string _value;

    internal AppSettingInfo([NotNull] UriString uri, [CanBeNull] string value) : base(uri)
    {
        _value = value;
    }

    public override bool Exists => !(_value is null);

    public override long? Length => _value?.Length;

    public override DateTime? CreatedOn { get; }

    public override DateTime? ModifiedOn { get; }

    protected override async Task CopyToAsyncInternal(Stream stream)
    {        
        // ReSharper disable once AssignNullToNotNullAttribute - this isn't null here
        using (var valueStream = _value.ToStreamReader())
        {
            await valueStream.BaseStream.CopyToAsync(stream);
        }
    }

    protected override Task<object> DeserializeAsyncInternal(Type targetType)
    {         
        return Task.FromResult<object>(_value);
    }
}

Other types that I've needed and implemented so far, are:

  • AppSettingProvider
  • ConnectionStringProvider
  • PhysicalFileProvider
  • PhysicalDirectoryProvider
  • InMemoryResourceProvider
  • EmbeddedFileProvider
  • CompositeResourceProvider ✓ (I use this for accessing resources by name or get the first match)
  • SqlServerProvider ✓ (I use this for settings)
  • JsonResourceProvider ✓ (I use this as a translating decorator for other resource-providers)
  • EnvironmentVariableProvider
  • SettingProvider ✓ (I use this as a translating decorator for setting names)

I intend to add more later, e.g.:

  • Registry
  • Ftp (this one would require a new API for opening sessions)
  • and many more...

It works surprisingly well. So, how do you like it and how would you improve it? The interfaces are very simple and some things are hidden inside ResourceMetadata but with an API for everything you must make some tradeoffs.

I'm particularly interested about what you think of the wrapper or decorator methods and parameter validation. I know, I used a couple regions but this is an absolute excepiton. I also wonder what you say about how I specify the abilities of each resource-provider via their ResourceMetadata.

I call it IOnymous and the complete code is as always on GitHub where you can take a look at all of it.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.