refactor: fluent validation for configuration

recyclarr
Robert Dailey 3 years ago
parent 1db23e6be9
commit aecd2dc5dc

@ -1,5 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="FluentValidation" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
</Project>

@ -0,0 +1,43 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FluentValidation;
using FluentValidation.Validators;
namespace Common.Extensions
{
public static class FluentValidationExtensions
{
// From: https://github.com/FluentValidation/FluentValidation/issues/1648
public static IRuleBuilderOptions<T, TProperty?> SetNonNullableValidator<T, TProperty>(
this IRuleBuilder<T, TProperty?> ruleBuilder, IValidator<TProperty> validator, params string[] ruleSets)
{
var adapter = new NullableChildValidatorAdaptor<T, TProperty>(validator, validator.GetType())
{
RuleSets = ruleSets
};
return ruleBuilder.SetAsyncValidator(adapter);
}
private class NullableChildValidatorAdaptor<T, TProperty> : ChildValidatorAdaptor<T, TProperty>,
IPropertyValidator<T, TProperty?>, IAsyncPropertyValidator<T, TProperty?>
{
public NullableChildValidatorAdaptor(IValidator<TProperty> validator, Type validatorType)
: base(validator, validatorType)
{
}
public override Task<bool> IsValidAsync(ValidationContext<T> context, TProperty? value,
CancellationToken cancellation)
{
return base.IsValidAsync(context, value!, cancellation);
}
public override bool IsValid(ValidationContext<T> context, TProperty? value)
{
return base.IsValid(context, value!);
}
}
}
}

@ -2,34 +2,35 @@
<ItemGroup>
<!-- Test Packages -->
<PackageReference Update="AutofacContrib.NSubstitute" Version="7.*" />
<PackageReference Update="FluentAssertions.Json" Version="5.*" />
<PackageReference Update="FluentAssertions" Version="5.*" />
<PackageReference Update="FluentAssertions.Json" Version="5.*" />
<PackageReference Update="GitHubActionsTestLogger" Version="1.*" />
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="16.*" />
<PackageReference Update="NSubstitute.Analyzers.CSharp" Version="1.*" />
<PackageReference Update="NSubstitute" Version="4.*" />
<PackageReference Update="NUnit.Analyzers" Version="3.*" />
<PackageReference Update="NSubstitute.Analyzers.CSharp" Version="1.*" />
<PackageReference Update="NUnit" Version="3.*" />
<PackageReference Update="NUnit.Analyzers" Version="3.*" />
<PackageReference Update="NUnit3TestAdapter" Version="3.*" />
<PackageReference Update="Serilog.Sinks.NUnit" Version="1.*" />
<PackageReference Update="Serilog.Sinks.TestCorrelator" Version="3.*" />
<!-- Non-Test Packages -->
<PackageReference Update="Autofac" Version="6.*" />
<PackageReference Update="Autofac.Extensions.DependencyInjection" Version="7.*" />
<PackageReference Update="Autofac.Extras.AggregateService" Version="6.*" />
<PackageReference Update="Autofac" Version="6.*" />
<PackageReference Update="CliFx" Version="2.*" />
<PackageReference Update="Flurl.Http" Version="3.*" />
<PackageReference Update="FluentValidation" Version="10.*" />
<PackageReference Update="Flurl" Version="3.*" />
<PackageReference Update="Flurl.Http" Version="3.*" />
<PackageReference Update="JetBrains.Annotations" Version="*"/>
<PackageReference Update="Microsoft.CodeAnalysis.NetAnalyzers" Version="5.*"/>
<PackageReference Update="morelinq" Version="3.*" />
<PackageReference Update="Nerdbank.GitVersioning" Version="3.*"/>
<PackageReference Update="Serilog" Version="2.*" />
<PackageReference Update="Serilog.Sinks.Console" Version="3.*" />
<PackageReference Update="Serilog.Sinks.File" Version="4.*" />
<PackageReference Update="Serilog" Version="2.*" />
<PackageReference Update="System.Data.HashFunction.FNV" Version="2.*" />
<PackageReference Update="System.IO.Abstractions" Version="13.*" />
<PackageReference Update="YamlDotNet" Version="10.*" />
<PackageReference Update="morelinq" Version="3.*" />
</ItemGroup>
</Project>

@ -1,5 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Trash\Trash.csproj" />
</ItemGroup>
</Project>

@ -8,7 +8,7 @@ using Trash.Command;
// ReSharper disable MethodHasAsyncOverload
namespace Trash.Tests.CreateConfig
namespace Trash.Tests.Command
{
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -5,7 +5,7 @@ using FluentAssertions;
using NUnit.Framework;
using Trash.Command.Helpers;
namespace Trash.Tests.Command
namespace Trash.Tests.Command.Helpers
{
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -8,13 +8,14 @@ using System.Text;
using Common;
using Common.Extensions;
using FluentAssertions;
using JetBrains.Annotations;
using FluentValidation;
using FluentValidation.Results;
using NSubstitute;
using NUnit.Framework;
using TestLibrary;
using Trash.Config;
using TrashLib.Config;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
using YamlDotNet.Serialization.ObjectFactories;
@ -32,43 +33,10 @@ namespace Trash.Tests.Config
[SuppressMessage("Microsoft.Design", "CA1034",
Justification = "YamlDotNet requires this type to be public so it may access it")]
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class TestConfigValidFalse : IServiceConfiguration
public class TestConfig : IServiceConfiguration
{
public const string Msg = "validate_false";
public string BaseUrl { get; init; } = "";
public string ApiKey { get; init; } = "";
public bool IsValid(out string msg)
{
msg = Msg;
return false;
}
public string BuildUrl()
{
throw new NotImplementedException();
}
}
[SuppressMessage("Microsoft.Design", "CA1034",
Justification = "YamlDotNet requires this type to be public so it may access it")]
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class TestConfigValidTrue : IServiceConfiguration
{
public string BaseUrl { get; init; } = "";
public string ApiKey { get; init; } = "";
public bool IsValid(out string msg)
{
msg = "";
return true;
}
public string BuildUrl()
{
throw new NotImplementedException();
}
public string BaseUrl => "";
public string ApiKey => "";
}
[Test]
@ -94,7 +62,9 @@ namespace Trash.Tests.Config
var actualActiveConfigs = new List<SonarrConfiguration>();
provider.ActiveConfiguration = Arg.Do<SonarrConfiguration>(a => actualActiveConfigs.Add(a));
var loader = new ConfigurationLoader<SonarrConfiguration>(provider, fs, new DefaultObjectFactory());
var validator = Substitute.For<IValidator<SonarrConfiguration>>();
var loader =
new ConfigurationLoader<SonarrConfiguration>(provider, fs, new DefaultObjectFactory(), validator);
var fakeFiles = new List<string>
{
@ -118,10 +88,12 @@ namespace Trash.Tests.Config
[Test]
public void Parse_using_stream()
{
var validator = Substitute.For<IValidator<SonarrConfiguration>>();
var configLoader = new ConfigurationLoader<SonarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory());
new DefaultObjectFactory(),
validator);
var configs = configLoader.LoadFromStream(GetResourceData("Load_UsingStream_CorrectParsing.yml"), "sonarr");
@ -156,12 +128,20 @@ namespace Trash.Tests.Config
}
[Test]
public void Validation_failure_throws()
public void Throw_when_validation_fails()
{
var configLoader = new ConfigurationLoader<TestConfigValidFalse>(
var validator = Substitute.For<IValidator<TestConfig>>();
var configLoader = new ConfigurationLoader<TestConfig>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory());
new DefaultObjectFactory(),
validator);
// force the validator to return a validation error
validator.Validate(Arg.Any<TestConfig>()).Returns(new ValidationResult
{
Errors = {new ValidationFailure("PropertyName", "Test Validation Failure")}
});
var testYml = @"
fubar:
@ -169,17 +149,18 @@ fubar:
";
Action act = () => configLoader.LoadFromStream(new StringReader(testYml), "fubar");
act.Should().Throw<ConfigurationException>()
.WithMessage($"*{TestConfigValidFalse.Msg}");
act.Should().Throw<ConfigurationException>();
}
[Test]
public void Validation_success_does_not_throw()
{
var configLoader = new ConfigurationLoader<TestConfigValidTrue>(
var validator = Substitute.For<IValidator<TestConfig>>();
var configLoader = new ConfigurationLoader<TestConfig>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory());
new DefaultObjectFactory(),
validator);
var testYml = @"
fubar:

@ -1,67 +0,0 @@
using System;
using System.IO;
using System.IO.Abstractions;
using FluentAssertions;
using JetBrains.Annotations;
using NSubstitute;
using NUnit.Framework;
using Trash.Config;
using TrashLib.Config;
using YamlDotNet.Core;
using YamlDotNet.Serialization.ObjectFactories;
namespace Trash.Tests.Config
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ServiceConfigurationTest
{
// This test class must be public otherwise it cannot be deserialized by YamlDotNet
[UsedImplicitly]
private class TestServiceConfiguration : ServiceConfiguration
{
public const string ServiceName = "test_service";
public override bool IsValid(out string msg)
{
throw new NotImplementedException();
}
}
[Test]
public void Deserialize_ApiKeyMissing_Throw()
{
const string yaml = @"
test_service:
- base_url: a
";
var loader = new ConfigurationLoader<TestServiceConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory());
Action act = () => loader.LoadFromStream(new StringReader(yaml), TestServiceConfiguration.ServiceName);
act.Should().Throw<YamlException>()
.WithMessage("*Property 'api_key' is required");
}
[Test]
public void Deserialize_BaseUrlMissing_Throw()
{
const string yaml = @"
test_service:
- api_key: b
";
var loader = new ConfigurationLoader<TestServiceConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory());
Action act = () => loader.LoadFromStream(new StringReader(yaml), TestServiceConfiguration.ServiceName);
act.Should().Throw<YamlException>()
.WithMessage("*Property 'base_url' is required");
}
}
}

@ -5,7 +5,10 @@
<ItemGroup>
<ProjectReference Include="..\TestLibrary\TestLibrary.csproj" />
<ProjectReference Include="..\Trash.TestLibrary\Trash.TestLibrary.csproj" />
<ProjectReference Include="..\Trash\Trash.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="CreateConfig" />
</ItemGroup>
</Project>

@ -8,7 +8,7 @@ using Serilog;
using Serilog.Core;
using Trash.Command.Helpers;
using Trash.Config;
using TrashLib.Radarr;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat;
using TrashLib.Radarr.QualityDefinition;

@ -8,7 +8,7 @@ using Serilog;
using Serilog.Core;
using Trash.Command.Helpers;
using Trash.Config;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.QualityDefinition;
using TrashLib.Sonarr.ReleaseProfile;

@ -3,6 +3,7 @@ using System.IO;
using System.IO.Abstractions;
using System.Reflection;
using Autofac;
using Autofac.Core.Activators.Reflection;
using CliFx;
using Serilog;
using Serilog.Core;
@ -47,6 +48,7 @@ namespace Trash
.As<IObjectFactory>();
builder.RegisterGeneric(typeof(ConfigurationLoader<>))
.WithProperty(new AutowiringParameter())
.As(typeof(IConfigurationLoader<>));
// note: Do not allow consumers to resolve IServiceConfiguration directly; if this gets cached

@ -1,18 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using FluentValidation.Results;
namespace Trash.Config
{
public class ConfigurationException : Exception
{
public ConfigurationException(string propertyName, Type deserializableType, string msg)
: base($"An exception occurred while deserializing type '{deserializableType}' " +
$"for YML property '{propertyName}': {msg}")
private ConfigurationException(string propertyName, Type deserializableType, IEnumerable<string> messages)
{
PropertyName = propertyName;
DeserializableType = deserializableType;
ErrorMessages = messages.ToList();
}
public ConfigurationException(string propertyName, Type deserializableType, string message)
: this(propertyName, deserializableType, new[] {message})
{
}
public ConfigurationException(string propertyName, Type deserializableType,
IEnumerable<ValidationFailure> validationFailures)
: this(propertyName, deserializableType, validationFailures.Select(e => e.ToString()))
{
}
public IReadOnlyCollection<string> ErrorMessages { get; }
public string PropertyName { get; }
public Type DeserializableType { get; }
public override string Message => BuildMessage();
private string BuildMessage()
{
const string delim = "\n - ";
var builder = new StringBuilder(
$"An exception occurred while deserializing type '{DeserializableType}' for YML property '{PropertyName}'");
if (ErrorMessages.Count > 0)
{
builder.Append(":" + delim);
builder.Append(string.Join(delim, ErrorMessages));
}
return builder.ToString();
}
}
}

@ -3,6 +3,7 @@ using System.IO;
using System.IO.Abstractions;
using System.Linq;
using Common.YamlDotNet;
using FluentValidation;
using TrashLib.Config;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
@ -17,14 +18,19 @@ namespace Trash.Config
private readonly IConfigurationProvider _configProvider;
private readonly IDeserializer _deserializer;
private readonly IFileSystem _fileSystem;
private readonly IValidator<T> _validator;
public ConfigurationLoader(IConfigurationProvider configProvider, IFileSystem fileSystem,
IObjectFactory objectFactory)
public ConfigurationLoader(
IConfigurationProvider configProvider,
IFileSystem fileSystem,
IObjectFactory objectFactory,
IValidator<T> validator)
{
_configProvider = configProvider;
_fileSystem = fileSystem;
_validator = validator;
_deserializer = new DeserializerBuilder()
.WithRequiredPropertyValidation()
.IgnoreUnmatchedProperties()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new YamlNullableEnumTypeConverter())
.WithObjectFactory(objectFactory)
@ -54,9 +60,10 @@ namespace Trash.Config
{
foreach (var config in configs)
{
if (!config.IsValid(out var msg))
var result = _validator.Validate(config);
if (result is {IsValid: false})
{
throw new ConfigurationException(configSection, typeof(T), msg);
throw new ConfigurationException(configSection, typeof(T), result.Errors);
}
validConfigs.Add(config);

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Trash.TestLibrary</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TrashLib\TrashLib.csproj" />
</ItemGroup>
</Project>

@ -8,7 +8,7 @@ using NUnit.Framework;
using Serilog;
using TestLibrary.FluentAssertions;
using Trash.TestLibrary;
using TrashLib.Radarr;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Guide;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Processors;

@ -2,7 +2,7 @@ using System.Collections.Generic;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using TrashLib.Radarr;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache;
using TrashLib.Radarr.CustomFormat.Processors.GuideSteps;

@ -5,7 +5,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using TestLibrary.FluentAssertions;
using TrashLib.Radarr;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache;
using TrashLib.Radarr.CustomFormat.Processors.GuideSteps;

@ -3,7 +3,7 @@ using FluentAssertions;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Trash.TestLibrary;
using TrashLib.Radarr;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Processors.GuideSteps;

@ -5,7 +5,7 @@ using Newtonsoft.Json.Linq;
using NSubstitute;
using NUnit.Framework;
using TrashLib.Config;
using TrashLib.Radarr;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Api;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache;

@ -1,15 +1,13 @@
using System;
using System.Collections;
using System.IO;
using System.IO.Abstractions;
using System.Collections.Generic;
using System.Linq;
using Autofac;
using FluentAssertions;
using NSubstitute;
using FluentValidation;
using NUnit.Framework;
using Trash.Config;
using TrashLib.Config;
using TrashLib.Radarr;
using YamlDotNet.Core;
using YamlDotNet.Serialization.ObjectFactories;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.QualityDefinition;
namespace TrashLib.Tests.Radarr
{
@ -17,105 +15,96 @@ namespace TrashLib.Tests.Radarr
[Parallelizable(ParallelScope.All)]
public class RadarrConfigurationTest
{
public static IEnumerable GetTrashIdsOrNamesEmptyTestData()
{
yield return new TestCaseData(@"
radarr:
- api_key: abc
base_url: xyz
custom_formats:
- names: [foo]
quality_profiles:
- name: MyProfile
")
.SetName("{m} (without_trash_ids)");
private IContainer _container = default!;
yield return new TestCaseData(@"
radarr:
- api_key: abc
base_url: xyz
custom_formats:
- trash_ids: [abc123]
quality_profiles:
- name: MyProfile
")
.SetName("{m} (without_names)");
[OneTimeSetUp]
public void Setup()
{
var builder = new ContainerBuilder();
builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterModule<RadarrAutofacModule>();
_container = builder.Build();
}
[TestCaseSource(nameof(GetTrashIdsOrNamesEmptyTestData))]
public void Custom_format_either_names_or_trash_id_not_empty_is_ok(string testYaml)
private static readonly TestCaseData[] NameOrIdsTestData =
{
var configLoader = new ConfigurationLoader<RadarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(), new DefaultObjectFactory());
Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr");
act.Should().NotThrow();
}
new(new List<string> {"name"}, new List<string>()),
new(new List<string>(), new List<string> {"trash_id"})
};
[Test]
public void Custom_format_names_and_trash_ids_lists_must_not_both_be_empty()
[TestCaseSource(nameof(NameOrIdsTestData))]
public void Custom_format_is_valid_with_one_of_either_names_or_trash_id(List<string> namesList,
List<string> trashIdsList)
{
var testYaml = @"
radarr:
- api_key: abc
base_url: xyz
custom_formats:
- quality_profiles:
- name: MyProfile
";
var configLoader = new ConfigurationLoader<RadarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(), new DefaultObjectFactory());
var config = new RadarrConfiguration
{
ApiKey = "required value",
BaseUrl = "required value",
CustomFormats = new List<CustomFormatConfig>
{
new() {Names = namesList, TrashIds = trashIdsList}
}
};
Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr");
var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config);
act.Should().Throw<ConfigurationException>()
.WithMessage("*must contain at least one element in either 'names' or 'trash_ids'.");
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Test]
public void Quality_definition_type_is_required()
public void Validation_fails_for_all_missing_required_properties()
{
const string yaml = @"
radarr:
- base_url: a
api_key: b
quality_definition:
preferred_ratio: 0.5
";
var loader = new ConfigurationLoader<RadarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory());
// default construct which should yield default values (invalid) for all required properties
var config = new RadarrConfiguration();
var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
Action act = () => loader.LoadFromStream(new StringReader(yaml), "radarr");
var result = validator.Validate(config);
act.Should().Throw<YamlException>()
.WithMessage("*'type' is required for 'quality_definition'");
var expectedErrorMessageSubstrings = new[]
{
"Property 'base_url' is required",
"Property 'api_key' is required",
"'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'",
"'name' is required for elements under 'quality_profiles'",
"'type' is required for 'quality_definition'"
};
result.IsValid.Should().BeFalse();
result.Errors.Select(e => e.ErrorMessage).Should()
.OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains));
}
[Test]
public void Quality_profile_name_is_required()
public void Validation_succeeds_when_no_missing_required_properties()
{
const string testYaml = @"
radarr:
- api_key: abc
base_url: xyz
custom_formats:
- names: [one, two]
quality_profiles:
- score: 100
";
var configLoader = new ConfigurationLoader<RadarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(), new DefaultObjectFactory());
var config = new RadarrConfiguration
{
ApiKey = "required value",
BaseUrl = "required value",
CustomFormats = new List<CustomFormatConfig>
{
new()
{
Names = new List<string>{"required value"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "required value"}
}
}
},
QualityDefinition = new QualityDefinitionConfig
{
Type = RadarrQualityDefinitionType.Movie
}
};
Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr");
var validator = _container.Resolve<IValidator<RadarrConfiguration>>();
var result = validator.Validate(config);
act.Should().Throw<YamlException>();
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
}
}

@ -1,7 +1,7 @@
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile

@ -6,7 +6,7 @@ using NUnit.Framework;
using Serilog;
using Serilog.Sinks.TestCorrelator;
using TestLibrary;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile

@ -1,8 +1,8 @@
using NSubstitute;
using NUnit.Framework;
using Serilog;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr

@ -1,14 +1,13 @@
using System;
using System.IO;
using System.IO.Abstractions;
using System.Collections.Generic;
using System.Linq;
using Autofac;
using FluentAssertions;
using NSubstitute;
using FluentValidation;
using NUnit.Framework;
using Trash.Config;
using TrashLib.Config;
using TrashLib.Sonarr;
using YamlDotNet.Core;
using YamlDotNet.Serialization.ObjectFactories;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr
{
@ -16,25 +15,56 @@ namespace TrashLib.Tests.Sonarr
[Parallelizable(ParallelScope.All)]
public class SonarrConfigurationTest
{
private IContainer _container = default!;
[OneTimeSetUp]
public void Setup()
{
var builder = new ContainerBuilder();
builder.RegisterModule<ConfigAutofacModule>();
builder.RegisterModule<SonarrAutofacModule>();
_container = builder.Build();
}
[Test]
public void Deserialize_ReleaseProfileTypeMissing_Throw()
public void Validation_fails_for_all_missing_required_properties()
{
const string yaml = @"
sonarr:
- base_url: a
api_key: b
release_profiles:
- strict_negative_scores: true
";
var loader = new ConfigurationLoader<SonarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(),
new DefaultObjectFactory());
Action act = () => loader.LoadFromStream(new StringReader(yaml), "sonarr");
act.Should().Throw<YamlException>()
.WithMessage("*'type' is required for 'release_profiles' elements");
// default construct which should yield default values (invalid) for all required properties
var config = new SonarrConfiguration();
var validator = _container.Resolve<IValidator<SonarrConfiguration>>();
var result = validator.Validate(config);
var expectedErrorMessageSubstrings = new[]
{
"Property 'base_url' is required",
"Property 'api_key' is required",
"'type' is required for 'release_profiles' elements"
};
result.IsValid.Should().BeFalse();
result.Errors.Select(e => e.ErrorMessage).Should()
.OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains));
}
[Test]
public void Validation_succeeds_when_no_missing_required_properties()
{
var config = new SonarrConfiguration
{
ApiKey = "required value",
BaseUrl = "required value",
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new() {Type = ReleaseProfileType.Anime}
}
};
var validator = _container.Resolve<IValidator<SonarrConfiguration>>();
var result = validator.Validate(config);
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
}
}

@ -5,7 +5,7 @@
<ItemGroup>
<ProjectReference Include="..\TestLibrary\TestLibrary.csproj" />
<ProjectReference Include="..\Trash.TestLibrary\Trash.TestLibrary.csproj" />
<ProjectReference Include="..\Trash\Trash.csproj" />
<ProjectReference Include="..\TrashLib.TestLibrary\TrashLib.TestLibrary.csproj" />
<ProjectReference Include="..\TrashLib\TrashLib.csproj" />
</ItemGroup>
</Project>

@ -1,4 +1,7 @@
using System.Reflection;
using Autofac;
using FluentValidation;
using Module = Autofac.Module;
namespace TrashLib.Config
{
@ -9,6 +12,10 @@ namespace TrashLib.Config
builder.RegisterType<ConfigurationProvider>()
.As<IConfigurationProvider>()
.SingleInstance();
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.AsClosedTypesOf(typeof(IValidator<>))
.AsImplementedInterfaces();
}
}
}

@ -1,4 +1,4 @@
namespace TrashLib
namespace TrashLib.Config
{
public interface IServerInfo
{

@ -2,8 +2,7 @@ namespace TrashLib.Config
{
public interface IServiceConfiguration
{
string BaseUrl { get; init; }
string ApiKey { get; init; }
bool IsValid(out string msg);
string BaseUrl { get; }
string ApiKey { get; }
}
}

@ -1,6 +1,6 @@
using Flurl;
namespace TrashLib
namespace TrashLib.Config
{
internal class ServerInfo : IServerInfo
{

@ -1,15 +1,8 @@
using System.ComponentModel.DataAnnotations;
namespace TrashLib.Config
namespace TrashLib.Config
{
public abstract class ServiceConfiguration : IServiceConfiguration
{
[Required(ErrorMessage = "Property 'base_url' is required")]
public string BaseUrl { get; init; } = "";
[Required(ErrorMessage = "Property 'api_key' is required")]
public string ApiKey { get; init; } = "";
public abstract bool IsValid(out string msg);
}
}

@ -0,0 +1,11 @@
namespace TrashLib.Radarr.Config
{
public interface IRadarrValidationMessages
{
string BaseUrl { get; }
string ApiKey { get; }
string CustomFormatNamesAndIds { get; }
string QualityProfileName { get; }
string QualityDefinitionType { get; }
}
}

@ -1,11 +1,9 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using JetBrains.Annotations;
using TrashLib.Config;
using TrashLib.Radarr.QualityDefinition;
namespace TrashLib.Radarr
namespace TrashLib.Radarr.Config
{
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrConfiguration : ServiceConfiguration
@ -13,18 +11,6 @@ namespace TrashLib.Radarr
public QualityDefinitionConfig? QualityDefinition { get; init; }
public List<CustomFormatConfig> CustomFormats { get; init; } = new();
public bool DeleteOldCustomFormats { get; init; }
public override bool IsValid(out string msg)
{
if (CustomFormats.Any(cf => cf.TrashIds.Count + cf.Names.Count == 0))
{
msg = "'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'.";
return false;
}
msg = "";
return true;
}
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
@ -38,9 +24,7 @@ namespace TrashLib.Radarr
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityProfileConfig
{
[Required(ErrorMessage = "'name' is required for elements under 'quality_profiles'")]
public string Name { get; init; } = "";
public int? Score { get; init; }
public bool ResetUnmatchedScores { get; init; }
}
@ -48,11 +32,8 @@ namespace TrashLib.Radarr
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityDefinitionConfig
{
// -1 does not map to a valid enumerator. this is to force validation to fail if it is not set from YAML
// all of this craziness is to avoid making the enum type nullable which will make using the property
// frustrating.
[EnumDataType(typeof(RadarrQualityDefinitionType),
ErrorMessage = "'type' is required for 'quality_definition'")]
// -1 does not map to a valid enumerator. this is to force validation to fail if it is not set from YAML.
// All of this craziness is to avoid making the enum type nullable.
public RadarrQualityDefinitionType Type { get; init; } = (RadarrQualityDefinitionType) (-1);
public decimal PreferredRatio { get; set; } = 1.0m;

@ -0,0 +1,52 @@
using Common.Extensions;
using FluentValidation;
using JetBrains.Annotations;
namespace TrashLib.Radarr.Config
{
[UsedImplicitly]
internal class RadarrConfigurationValidator : AbstractValidator<RadarrConfiguration>
{
public RadarrConfigurationValidator(
IRadarrValidationMessages messages,
IValidator<QualityDefinitionConfig> qualityDefinitionConfigValidator,
IValidator<CustomFormatConfig> customFormatConfigValidator)
{
RuleFor(x => x.BaseUrl).NotEmpty().WithMessage(messages.BaseUrl);
RuleFor(x => x.ApiKey).NotEmpty().WithMessage(messages.ApiKey);
RuleFor(x => x.QualityDefinition).SetNonNullableValidator(qualityDefinitionConfigValidator);
RuleForEach(x => x.CustomFormats).SetValidator(customFormatConfigValidator);
}
}
[UsedImplicitly]
internal class CustomFormatConfigValidator : AbstractValidator<CustomFormatConfig>
{
public CustomFormatConfigValidator(
IRadarrValidationMessages messages,
IValidator<QualityProfileConfig> qualityProfileConfigValidator)
{
RuleFor(x => x.Names).NotEmpty().When(x => x.TrashIds.Count == 0)
.WithMessage(messages.CustomFormatNamesAndIds);
RuleForEach(x => x.QualityProfiles).SetValidator(qualityProfileConfigValidator);
}
}
[UsedImplicitly]
internal class QualityProfileConfigValidator : AbstractValidator<QualityProfileConfig>
{
public QualityProfileConfigValidator(IRadarrValidationMessages messages)
{
RuleFor(x => x.Name).NotEmpty().WithMessage(messages.QualityProfileName);
}
}
[UsedImplicitly]
internal class QualityDefinitionConfigValidator : AbstractValidator<QualityDefinitionConfig>
{
public QualityDefinitionConfigValidator(IRadarrValidationMessages messages)
{
RuleFor(x => x.Type).IsInEnum().WithMessage(messages.QualityDefinitionType);
}
}
}

@ -0,0 +1,20 @@
namespace TrashLib.Radarr.Config
{
internal class RadarrValidationMessages : IRadarrValidationMessages
{
public string BaseUrl =>
"Property 'base_url' is required";
public string ApiKey =>
"Property 'api_key' is required";
public string CustomFormatNamesAndIds =>
"'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'";
public string QualityProfileName =>
"'name' is required for elements under 'quality_profiles'";
public string QualityDefinitionType =>
"'type' is required for 'quality_definition'";
}
}

@ -3,6 +3,7 @@ using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using Newtonsoft.Json.Linq;
using TrashLib.Config;
using TrashLib.Radarr.CustomFormat.Models;
namespace TrashLib.Radarr.CustomFormat.Api

@ -3,6 +3,7 @@ using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using Newtonsoft.Json.Linq;
using TrashLib.Config;
namespace TrashLib.Radarr.CustomFormat.Api
{

@ -3,6 +3,7 @@ using System.Linq;
using System.Threading.Tasks;
using Common.Extensions;
using Serilog;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Processors;
using TrashLib.Radarr.CustomFormat.Processors.PersistenceSteps;

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using TrashLib.Radarr.Config;
namespace TrashLib.Radarr.CustomFormat
{

@ -1,4 +1,5 @@
using System.Collections.Generic;
using TrashLib.Radarr.Config;
namespace TrashLib.Radarr.CustomFormat.Models
{

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Serilog;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Guide;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache;

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Common.Extensions;
using MoreLinq.Extensions;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models;
namespace TrashLib.Radarr.CustomFormat.Processors.GuideSteps

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Common.Extensions;
using Newtonsoft.Json.Linq;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache;

@ -1,4 +1,5 @@
using System.Collections.Generic;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models;
namespace TrashLib.Radarr.CustomFormat.Processors.GuideSteps

@ -1,4 +1,5 @@
using System.Collections.Generic;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache;

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache;

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using TrashLib.Config;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Api;
using TrashLib.Radarr.CustomFormat.Models;
using TrashLib.Radarr.CustomFormat.Models.Cache;

@ -2,6 +2,7 @@
using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using TrashLib.Config;
using TrashLib.Radarr.QualityDefinition.Api.Objects;
namespace TrashLib.Radarr.QualityDefinition.Api

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using TrashLib.Radarr.Config;
namespace TrashLib.Radarr.QualityDefinition
{

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Serilog;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.QualityDefinition.Api;
using TrashLib.Radarr.QualityDefinition.Api.Objects;

@ -1,6 +1,7 @@
using Autofac;
using Autofac.Extras.AggregateService;
using TrashLib.Config;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat;
using TrashLib.Radarr.CustomFormat.Api;
using TrashLib.Radarr.CustomFormat.Guide;
@ -21,6 +22,8 @@ namespace TrashLib.Radarr
builder.RegisterType<CustomFormatService>().As<ICustomFormatService>();
builder.RegisterType<QualityProfileService>().As<IQualityProfileService>();
// Configuration
builder.RegisterType<RadarrValidationMessages>().As<IRadarrValidationMessages>();
builder.Register(c =>
{
var config = c.Resolve<IConfigurationProvider>().ActiveConfiguration;

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using TrashLib.Config;
using TrashLib.Sonarr.Api.Objects;
namespace TrashLib.Sonarr.Api

@ -0,0 +1,9 @@
namespace TrashLib.Sonarr.Config
{
public interface ISonarrValidationMessages
{
string BaseUrl { get; }
string ApiKey { get; }
string ReleaseProfileType { get; }
}
}

@ -1,33 +1,21 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using JetBrains.Annotations;
using TrashLib.Config;
using TrashLib.Sonarr.QualityDefinition;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Sonarr
namespace TrashLib.Sonarr.Config
{
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class SonarrConfiguration : ServiceConfiguration
{
public IList<ReleaseProfileConfig> ReleaseProfiles { get; set; } = new List<ReleaseProfileConfig>();
public SonarrQualityDefinitionType? QualityDefinition { get; init; }
public override bool IsValid(out string msg)
{
msg = "";
return true;
}
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class ReleaseProfileConfig
{
// -1 does not map to a valid enumerator. this is to force validation to fail if it is not set from YAML
// all of this craziness is to avoid making the enum type nullable which will make using the property
// frustrating.
[EnumDataType(typeof(ReleaseProfileType),
ErrorMessage = "'type' is required for 'release_profiles' elements")]
public ReleaseProfileType Type { get; init; } = (ReleaseProfileType) (-1);
public bool StrictNegativeScores { get; init; }
@ -35,7 +23,6 @@ namespace TrashLib.Sonarr
public ICollection<string> Tags { get; init; } = new List<string>();
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class SonarrProfileFilterConfig
{
public bool IncludeOptional { get; set; }

@ -0,0 +1,27 @@
using FluentValidation;
using JetBrains.Annotations;
namespace TrashLib.Sonarr.Config
{
[UsedImplicitly]
internal class SonarrConfigurationValidator : AbstractValidator<SonarrConfiguration>
{
public SonarrConfigurationValidator(
ISonarrValidationMessages messages,
IValidator<ReleaseProfileConfig> releaseProfileConfigValidator)
{
RuleFor(x => x.BaseUrl).NotEmpty().WithMessage(messages.BaseUrl);
RuleFor(x => x.ApiKey).NotEmpty().WithMessage(messages.ApiKey);
RuleForEach(x => x.ReleaseProfiles).SetValidator(releaseProfileConfigValidator);
}
}
[UsedImplicitly]
internal class ReleaseProfileConfigValidator : AbstractValidator<ReleaseProfileConfig>
{
public ReleaseProfileConfigValidator(ISonarrValidationMessages messages)
{
RuleFor(x => x.Type).IsInEnum().WithMessage(messages.ReleaseProfileType);
}
}
}

@ -0,0 +1,17 @@
using JetBrains.Annotations;
namespace TrashLib.Sonarr.Config
{
[UsedImplicitly]
internal class SonarrValidationMessages : ISonarrValidationMessages
{
public string BaseUrl =>
"Property 'base_url' is required";
public string ApiKey =>
"Property 'api_key' is required";
public string ReleaseProfileType =>
"'type' is required for 'release_profiles' elements";
}
}

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.QualityDefinition
{

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Serilog;
using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Api.Objects;
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.QualityDefinition
{

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile
{

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile
{

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile
{

@ -7,6 +7,7 @@ using Common.Extensions;
using Flurl;
using Flurl.Http;
using Serilog;
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile
{

@ -7,6 +7,7 @@ using Serilog;
using TrashLib.ExceptionTypes;
using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Api.Objects;
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile
{

@ -1,5 +1,6 @@
using Autofac;
using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.QualityDefinition;
using TrashLib.Sonarr.ReleaseProfile;
@ -10,6 +11,7 @@ namespace TrashLib.Sonarr
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<SonarrApi>().As<ISonarrApi>();
builder.RegisterType<SonarrValidationMessages>().As<ISonarrValidationMessages>();
// Release Profile Support
builder.RegisterType<ReleaseProfileUpdater>().As<IReleaseProfileUpdater>();

@ -1,13 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Autofac.Extras.AggregateService" />
<PackageReference Include="Autofac" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="Autofac.Extras.AggregateService" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="Flurl" />
<PackageReference Include="morelinq" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="Serilog" />
<PackageReference Include="System.Data.HashFunction.FNV" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="morelinq" />
</ItemGroup>
<ItemGroup>

@ -19,7 +19,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csp
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Tests", "Common.Tests\Common.Tests.csproj", "{0720939D-1CA6-43D7-BBED-F8F894C4F562}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Trash.TestLibrary", "Trash.TestLibrary\Trash.TestLibrary.csproj", "{33226068-65E3-4890-8671-59A56BA3F6F0}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrashLib.TestLibrary", "TrashLib.TestLibrary\TrashLib.TestLibrary.csproj", "{33226068-65E3-4890-8671-59A56BA3F6F0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrashLib", "TrashLib\TrashLib.csproj", "{4F6ACBA6-9A7D-487C-ACC1-787CCC90A381}"
EndProject

Loading…
Cancel
Save