From 13ee03473ca054dd1e33f775fb647bb76e30348b Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Sun, 25 Apr 2021 14:54:48 -0500 Subject: [PATCH] refactor: new service cache class Allows reading and writing objects to a local object store (a directory starting at the user's home dir). --- src/Trash.Tests/Cache/ServiceCacheTest.cs | 139 ++++++++++++++++++ .../Command/CliTypeActivatorTest.cs | 1 + src/Trash/AppPaths.cs | 2 +- src/Trash/Cache/CacheObjectNameAttribute.cs | 14 ++ src/Trash/Cache/CacheStoragePath.cs | 17 +++ src/Trash/Cache/ICacheStoragePath.cs | 7 + src/Trash/Cache/IServiceCache.cs | 8 + src/Trash/Cache/ServiceCache.cs | 57 +++++++ src/Trash/Command/IServiceCommand.cs | 1 + src/Trash/Command/ServiceCommand.cs | 4 +- src/Trash/CompositionRoot.cs | 8 +- src/Trash/Radarr/RadarrCommand.cs | 4 +- src/Trash/Sonarr/SonarrCommand.cs | 4 + 13 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 src/Trash.Tests/Cache/ServiceCacheTest.cs create mode 100644 src/Trash/Cache/CacheObjectNameAttribute.cs create mode 100644 src/Trash/Cache/CacheStoragePath.cs create mode 100644 src/Trash/Cache/ICacheStoragePath.cs create mode 100644 src/Trash/Cache/IServiceCache.cs create mode 100644 src/Trash/Cache/ServiceCache.cs diff --git a/src/Trash.Tests/Cache/ServiceCacheTest.cs b/src/Trash.Tests/Cache/ServiceCacheTest.cs new file mode 100644 index 00000000..2e0ce080 --- /dev/null +++ b/src/Trash.Tests/Cache/ServiceCacheTest.cs @@ -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(); + var cache = new ServiceCache(filesystem, storagePath); + + Action act = () => cache.Load(); + + act.Should() + .Throw(); + } + + [Test] + public void Load_WithAttribute_ParsesCorrectly() + { + var filesystem = Substitute.For(); + var storagePath = Substitute.For(); + var cache = new ServiceCache(filesystem, storagePath); + + storagePath.Path.Returns("testpath"); + + dynamic testJson = new {TestValue = "Foo"}; + filesystem.File.ReadAllText(Arg.Any()) + .Returns(_ => JsonConvert.SerializeObject(testJson)); + + var obj = cache.Load(); + + obj.TestValue.Should().Be("Foo"); + filesystem.File.Received().ReadAllText($"testpath{Path.DirectorySeparatorChar}{ValidObjectName}.json"); + } + + [Test] + public void Load_WithAttributeInvalidName_ThrowsException() + { + var filesystem = Substitute.For(); + var storagePath = Substitute.For(); + var cache = new ServiceCache(filesystem, storagePath); + + Action act = () => cache.Load(); + + act.Should() + .Throw() + .WithMessage("*'invalid+name' has unacceptable characters*"); + } + + [Test] + public void Load_WithoutAttribute_Throws() + { + var filesystem = Substitute.For(); + var storagePath = Substitute.For(); + var cache = new ServiceCache(filesystem, storagePath); + + Action act = () => cache.Load(); + + act.Should() + .Throw() + .WithMessage("CacheObjectNameAttribute is missing*"); + } + + [Test] + public void Save_WithAttribute_ParsesCorrectly() + { + var filesystem = Substitute.For(); + var storagePath = Substitute.For(); + 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(); + var storagePath = Substitute.For(); + var cache = new ServiceCache(filesystem, storagePath); + + Action act = () => cache.Save(new ObjectWithAttributeInvalidChars()); + + act.Should() + .Throw() + .WithMessage("*'invalid+name' has unacceptable characters*"); + } + + [Test] + public void Save_WithoutAttribute_Throws() + { + var filesystem = Substitute.For(); + var storagePath = Substitute.For(); + var cache = new ServiceCache(filesystem, storagePath); + + Action act = () => cache.Save(new ObjectWithoutAttribute()); + + act.Should() + .Throw() + .WithMessage("CacheObjectNameAttribute is missing*"); + } + } +} diff --git a/src/Trash.Tests/Command/CliTypeActivatorTest.cs b/src/Trash.Tests/Command/CliTypeActivatorTest.cs index 051c2caa..62c33df9 100644 --- a/src/Trash.Tests/Command/CliTypeActivatorTest.cs +++ b/src/Trash.Tests/Command/CliTypeActivatorTest.cs @@ -20,6 +20,7 @@ namespace Trash.Tests.Command public bool Preview => false; public bool Debug => false; public List? Config => null; + public string CacheStoragePath => ""; } [Test] diff --git a/src/Trash/AppPaths.cs b/src/Trash/AppPaths.cs index b2e6cfee..513a775e 100644 --- a/src/Trash/AppPaths.cs +++ b/src/Trash/AppPaths.cs @@ -6,7 +6,7 @@ namespace Trash internal static class AppPaths { 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"); } diff --git a/src/Trash/Cache/CacheObjectNameAttribute.cs b/src/Trash/Cache/CacheObjectNameAttribute.cs new file mode 100644 index 00000000..2b2a65b6 --- /dev/null +++ b/src/Trash/Cache/CacheObjectNameAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace Trash.Cache +{ + public class CacheObjectNameAttribute : Attribute + { + public CacheObjectNameAttribute(string name) + { + Name = name; + } + + public string Name { get; } + } +} diff --git a/src/Trash/Cache/CacheStoragePath.cs b/src/Trash/Cache/CacheStoragePath.cs new file mode 100644 index 00000000..b696e381 --- /dev/null +++ b/src/Trash/Cache/CacheStoragePath.cs @@ -0,0 +1,17 @@ +using System; +using Trash.Command; + +namespace Trash.Cache +{ + public class CacheStoragePath : ICacheStoragePath + { + private readonly Lazy _cmd; + + public CacheStoragePath(Lazy cmd) + { + _cmd = cmd; + } + + public string Path => _cmd.Value.CacheStoragePath; + } +} diff --git a/src/Trash/Cache/ICacheStoragePath.cs b/src/Trash/Cache/ICacheStoragePath.cs new file mode 100644 index 00000000..557f3fc0 --- /dev/null +++ b/src/Trash/Cache/ICacheStoragePath.cs @@ -0,0 +1,7 @@ +namespace Trash.Cache +{ + public interface ICacheStoragePath + { + string Path { get; } + } +} diff --git a/src/Trash/Cache/IServiceCache.cs b/src/Trash/Cache/IServiceCache.cs new file mode 100644 index 00000000..71681293 --- /dev/null +++ b/src/Trash/Cache/IServiceCache.cs @@ -0,0 +1,8 @@ +namespace Trash.Cache +{ + public interface IServiceCache + { + T Load(); + void Save(T obj); + } +} diff --git a/src/Trash/Cache/ServiceCache.cs b/src/Trash/Cache/ServiceCache.cs new file mode 100644 index 00000000..6c9402f8 --- /dev/null +++ b/src/Trash/Cache/ServiceCache.cs @@ -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() + { + var json = _fileSystem.File.ReadAllText(PathFromAttribute()); + return JObject.Parse(json).ToObject(); + } + + public void Save(T obj) + { + _fileSystem.File.WriteAllText(PathFromAttribute(), + JsonConvert.SerializeObject(obj, Formatting.Indented)); + } + + private static string GetCacheObjectNameAttribute() + { + var attribute = typeof(T).GetCustomAttribute(); + if (attribute == null) + { + throw new ArgumentException($"{nameof(CacheObjectNameAttribute)} is missing on type {nameof(T)}"); + } + + return attribute.Name; + } + + private string PathFromAttribute() + { + var objectName = GetCacheObjectNameAttribute(); + if (!AllowedObjectNameCharacters.IsMatch(objectName)) + { + throw new ArgumentException($"Object name '{objectName}' has unacceptable characters"); + } + + return Path.Join(_storagePath.Path, objectName + ".json"); + } + } +} diff --git a/src/Trash/Command/IServiceCommand.cs b/src/Trash/Command/IServiceCommand.cs index 13a2eced..bd118413 100644 --- a/src/Trash/Command/IServiceCommand.cs +++ b/src/Trash/Command/IServiceCommand.cs @@ -7,5 +7,6 @@ namespace Trash.Command bool Preview { get; } bool Debug { get; } List? Config { get; } + string CacheStoragePath { get; } } } diff --git a/src/Trash/Command/ServiceCommand.cs b/src/Trash/Command/ServiceCommand.cs index e342d138..5b750ec0 100644 --- a/src/Trash/Command/ServiceCommand.cs +++ b/src/Trash/Command/ServiceCommand.cs @@ -26,8 +26,6 @@ namespace Trash.Command Log = logger; } - public static string DefaultConfigPath { get; } = Path.Join(AppContext.BaseDirectory, "trash.yml"); - protected ILogger Log { get; } 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.")] public List Config { get; [UsedImplicitly] set; } = new() {AppPaths.DefaultConfigPath}; + public abstract string CacheStoragePath { get; } + private void SetupLogging() { _loggingLevelSwitch.MinimumLevel = diff --git a/src/Trash/CompositionRoot.cs b/src/Trash/CompositionRoot.cs index dbc118e8..a8bde525 100644 --- a/src/Trash/CompositionRoot.cs +++ b/src/Trash/CompositionRoot.cs @@ -4,6 +4,7 @@ using Autofac; using CliFx; using Serilog; using Serilog.Core; +using Trash.Cache; using Trash.Command; using Trash.Config; 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. builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly()) - .Where(t => t.IsAssignableTo(typeof(ICommand))); + .Where(t => t.IsAssignableTo(typeof(ICommand))) + .As() + .AsSelf(); // Used to access the chosen command class. This is assigned from CliTypeActivator builder.RegisterType() @@ -87,6 +90,9 @@ namespace Trash builder.RegisterType() .As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + ConfigurationRegistrations(builder); CommandRegistrations(builder); diff --git a/src/Trash/Radarr/RadarrCommand.cs b/src/Trash/Radarr/RadarrCommand.cs index 56294a18..03d97ca7 100644 --- a/src/Trash/Radarr/RadarrCommand.cs +++ b/src/Trash/Radarr/RadarrCommand.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Threading.Tasks; using CliFx.Attributes; using Flurl.Http; @@ -29,7 +30,8 @@ namespace Trash.Radarr _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() { diff --git a/src/Trash/Sonarr/SonarrCommand.cs b/src/Trash/Sonarr/SonarrCommand.cs index 3693665b..4ddce8c8 100644 --- a/src/Trash/Sonarr/SonarrCommand.cs +++ b/src/Trash/Sonarr/SonarrCommand.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Threading.Tasks; using CliFx.Attributes; using Flurl.Http; @@ -35,6 +36,9 @@ namespace Trash.Sonarr // 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() { try