refactor: new service cache class

Allows reading and writing objects to a local object store (a directory
starting at the user's home dir).
recyclarr
Robert Dailey 4 years ago
parent f1e9b4f507
commit 13ee03473c

@ -0,0 +1,139 @@
using System;
using System.IO;
using System.IO.Abstractions;
using FluentAssertions;
using Newtonsoft.Json;
using NSubstitute;
using NUnit.Framework;
using Trash.Cache;
namespace Trash.Tests.Cache
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ServiceCacheTest
{
private class ObjectWithoutAttribute
{
}
private const string ValidObjectName = "azAZ_09";
[CacheObjectName(ValidObjectName)]
private class ObjectWithAttribute
{
public string TestValue { get; init; } = "";
}
[CacheObjectName("invalid+name")]
private class ObjectWithAttributeInvalidChars
{
}
[Test]
public void Load_NoFileExists_ThrowsException()
{
// use a real filesystem to test no file existing
var filesystem = new FileSystem();
var storagePath = Substitute.For<ICacheStoragePath>();
var cache = new ServiceCache(filesystem, storagePath);
Action act = () => cache.Load<ObjectWithAttribute>();
act.Should()
.Throw<FileNotFoundException>();
}
[Test]
public void Load_WithAttribute_ParsesCorrectly()
{
var filesystem = Substitute.For<IFileSystem>();
var storagePath = Substitute.For<ICacheStoragePath>();
var cache = new ServiceCache(filesystem, storagePath);
storagePath.Path.Returns("testpath");
dynamic testJson = new {TestValue = "Foo"};
filesystem.File.ReadAllText(Arg.Any<string>())
.Returns(_ => JsonConvert.SerializeObject(testJson));
var obj = cache.Load<ObjectWithAttribute>();
obj.TestValue.Should().Be("Foo");
filesystem.File.Received().ReadAllText($"testpath{Path.DirectorySeparatorChar}{ValidObjectName}.json");
}
[Test]
public void Load_WithAttributeInvalidName_ThrowsException()
{
var filesystem = Substitute.For<IFileSystem>();
var storagePath = Substitute.For<ICacheStoragePath>();
var cache = new ServiceCache(filesystem, storagePath);
Action act = () => cache.Load<ObjectWithAttributeInvalidChars>();
act.Should()
.Throw<ArgumentException>()
.WithMessage("*'invalid+name' has unacceptable characters*");
}
[Test]
public void Load_WithoutAttribute_Throws()
{
var filesystem = Substitute.For<IFileSystem>();
var storagePath = Substitute.For<ICacheStoragePath>();
var cache = new ServiceCache(filesystem, storagePath);
Action act = () => cache.Load<ObjectWithoutAttribute>();
act.Should()
.Throw<ArgumentException>()
.WithMessage("CacheObjectNameAttribute is missing*");
}
[Test]
public void Save_WithAttribute_ParsesCorrectly()
{
var filesystem = Substitute.For<IFileSystem>();
var storagePath = Substitute.For<ICacheStoragePath>();
var cache = new ServiceCache(filesystem, storagePath);
storagePath.Path.Returns("testpath");
cache.Save(new ObjectWithAttribute {TestValue = "Foo"});
dynamic expectedJson = new {TestValue = "Foo"};
var expectedPath = $"testpath{Path.DirectorySeparatorChar}{ValidObjectName}.json";
filesystem.File.Received()
.WriteAllText(expectedPath, JsonConvert.SerializeObject(expectedJson, Formatting.Indented));
}
[Test]
public void Save_WithAttributeInvalidName_ThrowsException()
{
var filesystem = Substitute.For<IFileSystem>();
var storagePath = Substitute.For<ICacheStoragePath>();
var cache = new ServiceCache(filesystem, storagePath);
Action act = () => cache.Save(new ObjectWithAttributeInvalidChars());
act.Should()
.Throw<ArgumentException>()
.WithMessage("*'invalid+name' has unacceptable characters*");
}
[Test]
public void Save_WithoutAttribute_Throws()
{
var filesystem = Substitute.For<IFileSystem>();
var storagePath = Substitute.For<ICacheStoragePath>();
var cache = new ServiceCache(filesystem, storagePath);
Action act = () => cache.Save(new ObjectWithoutAttribute());
act.Should()
.Throw<ArgumentException>()
.WithMessage("CacheObjectNameAttribute is missing*");
}
}
}

@ -20,6 +20,7 @@ namespace Trash.Tests.Command
public bool Preview => false; public bool Preview => false;
public bool Debug => false; public bool Debug => false;
public List<string>? Config => null; public List<string>? Config => null;
public string CacheStoragePath => "";
} }
[Test] [Test]

@ -6,7 +6,7 @@ namespace Trash
internal static class AppPaths internal static class AppPaths
{ {
public static string AppDataPath { get; } = public static string AppDataPath { get; } =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "trash-updater"); Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "trash-updater");
public static string DefaultConfigPath { get; } = Path.Join(AppContext.BaseDirectory, "trash.yml"); public static string DefaultConfigPath { get; } = Path.Join(AppContext.BaseDirectory, "trash.yml");
} }

@ -0,0 +1,14 @@
using System;
namespace Trash.Cache
{
public class CacheObjectNameAttribute : Attribute
{
public CacheObjectNameAttribute(string name)
{
Name = name;
}
public string Name { get; }
}
}

@ -0,0 +1,17 @@
using System;
using Trash.Command;
namespace Trash.Cache
{
public class CacheStoragePath : ICacheStoragePath
{
private readonly Lazy<IServiceCommand> _cmd;
public CacheStoragePath(Lazy<IServiceCommand> cmd)
{
_cmd = cmd;
}
public string Path => _cmd.Value.CacheStoragePath;
}
}

@ -0,0 +1,7 @@
namespace Trash.Cache
{
public interface ICacheStoragePath
{
string Path { get; }
}
}

@ -0,0 +1,8 @@
namespace Trash.Cache
{
public interface IServiceCache
{
T Load<T>();
void Save<T>(T obj);
}
}

@ -0,0 +1,57 @@
using System;
using System.IO;
using System.IO.Abstractions;
using System.Reflection;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Trash.Cache
{
public class ServiceCache : IServiceCache
{
private static readonly Regex AllowedObjectNameCharacters = new(@"^\w+$", RegexOptions.Compiled);
private readonly IFileSystem _fileSystem;
private readonly ICacheStoragePath _storagePath;
public ServiceCache(IFileSystem fileSystem, ICacheStoragePath storagePath)
{
_fileSystem = fileSystem;
_storagePath = storagePath;
}
public T Load<T>()
{
var json = _fileSystem.File.ReadAllText(PathFromAttribute<T>());
return JObject.Parse(json).ToObject<T>();
}
public void Save<T>(T obj)
{
_fileSystem.File.WriteAllText(PathFromAttribute<T>(),
JsonConvert.SerializeObject(obj, Formatting.Indented));
}
private static string GetCacheObjectNameAttribute<T>()
{
var attribute = typeof(T).GetCustomAttribute<CacheObjectNameAttribute>();
if (attribute == null)
{
throw new ArgumentException($"{nameof(CacheObjectNameAttribute)} is missing on type {nameof(T)}");
}
return attribute.Name;
}
private string PathFromAttribute<T>()
{
var objectName = GetCacheObjectNameAttribute<T>();
if (!AllowedObjectNameCharacters.IsMatch(objectName))
{
throw new ArgumentException($"Object name '{objectName}' has unacceptable characters");
}
return Path.Join(_storagePath.Path, objectName + ".json");
}
}
}

@ -7,5 +7,6 @@ namespace Trash.Command
bool Preview { get; } bool Preview { get; }
bool Debug { get; } bool Debug { get; }
List<string>? Config { get; } List<string>? Config { get; }
string CacheStoragePath { get; }
} }
} }

@ -26,8 +26,6 @@ namespace Trash.Command
Log = logger; Log = logger;
} }
public static string DefaultConfigPath { get; } = Path.Join(AppContext.BaseDirectory, "trash.yml");
protected ILogger Log { get; } protected ILogger Log { get; }
public async ValueTask ExecuteAsync(IConsole console) public async ValueTask ExecuteAsync(IConsole console)
@ -71,6 +69,8 @@ namespace Trash.Command
"If not specified, the script will look for `trash.yml` in the same directory as the executable.")] "If not specified, the script will look for `trash.yml` in the same directory as the executable.")]
public List<string> Config { get; [UsedImplicitly] set; } = new() {AppPaths.DefaultConfigPath}; public List<string> Config { get; [UsedImplicitly] set; } = new() {AppPaths.DefaultConfigPath};
public abstract string CacheStoragePath { get; }
private void SetupLogging() private void SetupLogging()
{ {
_loggingLevelSwitch.MinimumLevel = _loggingLevelSwitch.MinimumLevel =

@ -4,6 +4,7 @@ using Autofac;
using CliFx; using CliFx;
using Serilog; using Serilog;
using Serilog.Core; using Serilog.Core;
using Trash.Cache;
using Trash.Command; using Trash.Command;
using Trash.Config; using Trash.Config;
using Trash.Radarr.Api; using Trash.Radarr.Api;
@ -66,7 +67,9 @@ namespace Trash
{ {
// Register all types deriving from CliFx's ICommand. These are all of our supported subcommands. // Register all types deriving from CliFx's ICommand. These are all of our supported subcommands.
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly()) builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.Where(t => t.IsAssignableTo(typeof(ICommand))); .Where(t => t.IsAssignableTo(typeof(ICommand)))
.As<IServiceCommand>()
.AsSelf();
// Used to access the chosen command class. This is assigned from CliTypeActivator // Used to access the chosen command class. This is assigned from CliTypeActivator
builder.RegisterType<ActiveServiceCommandProvider>() builder.RegisterType<ActiveServiceCommandProvider>()
@ -87,6 +90,9 @@ namespace Trash
builder.RegisterType<FileSystem>() builder.RegisterType<FileSystem>()
.As<IFileSystem>(); .As<IFileSystem>();
builder.RegisterType<ServiceCache>().As<IServiceCache>();
builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>();
ConfigurationRegistrations(builder); ConfigurationRegistrations(builder);
CommandRegistrations(builder); CommandRegistrations(builder);

@ -1,4 +1,5 @@
using System; using System;
using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using Flurl.Http; using Flurl.Http;
@ -29,7 +30,8 @@ namespace Trash.Radarr
_qualityUpdaterFactory = qualityUpdaterFactory; _qualityUpdaterFactory = qualityUpdaterFactory;
} }
// todo: Add options to exclude parts of YAML on the fly? public override string CacheStoragePath { get; } =
Path.Join(AppPaths.AppDataPath, "cache/radarr");
public override async Task Process() public override async Task Process()
{ {

@ -1,4 +1,5 @@
using System; using System;
using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using Flurl.Http; using Flurl.Http;
@ -35,6 +36,9 @@ namespace Trash.Sonarr
// todo: Add options to exclude parts of YAML on the fly? // todo: Add options to exclude parts of YAML on the fly?
public override string CacheStoragePath { get; } =
Path.Join(AppPaths.AppDataPath, "cache/sonarr");
public override async Task Process() public override async Task Process()
{ {
try try

Loading…
Cancel
Save