refactor: Replace Newtonsoft.Json with System.Text.Json

json-serializing-nullable-fields-issue
Robert Dailey 8 months ago
parent 5c7cc8d829
commit ec7516d6a6

@ -12,11 +12,11 @@
<PackageVersion Include="CliWrap" Version="3.6.4" />
<PackageVersion Include="FluentValidation" Version="11.7.1" />
<PackageVersion Include="Flurl" Version="3.0.7" />
<PackageVersion Include="Flurl.Http" Version="3.2.4" />
<PackageVersion Include="Flurl.Http" Version="4.0.0-pre3" />
<PackageVersion Include="GitVersion.MsBuild" Version="5.12.0" />
<PackageVersion Include="JetBrains.Annotations" Version="2023.2.0" />
<PackageVersion Include="JorgeSerrano.Json.JsonSnakeCaseNamingPolicy" Version="0.9.0" />
<PackageVersion Include="MudBlazor" Version="6.9.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="ReactiveUI.Blazor" Version="19.4.1" />
<PackageVersion Include="Serilog" Version="3.0.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="7.0.0" />
@ -30,6 +30,8 @@
<PackageVersion Include="System.Data.HashFunction.FNV" Version="2.0.0" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Reactive" Version="6.0.0" />
<PackageVersion Include="System.Text.Json" Version="7.0.3" />
<PackageVersion Include="SystemTextJson.JsonDiffPatch" Version="1.3.1" />
<PackageVersion Include="TestableIO.System.IO.Abstractions" Version="19.2.69" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.Extensions" Version="2.0.5" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.69" />
@ -63,4 +65,4 @@
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
</ItemGroup>
</Project>
</Project>

@ -1,4 +1,4 @@
using Newtonsoft.Json;
using System.Text.Json;
using Recyclarr.TrashLib.Config;
namespace Recyclarr.Cli.Cache;
@ -37,7 +37,7 @@ public class CachePersister : ICachePersister
public void Save(IServiceConfiguration config, CustomFormatCache cache)
{
_log.Debug("Saving Cache with {Mappings}", JsonConvert.SerializeObject(cache.TrashIdMappings));
_log.Debug("Saving Cache with {Mappings}", JsonSerializer.Serialize(cache.TrashIdMappings));
_cache.Save(cache, config);
}

@ -1,18 +1,18 @@
using System.IO.Abstractions;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Recyclarr.Common.Extensions;
using Recyclarr.Json;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Interfaces;
using Recyclarr.TrashLib.Json;
namespace Recyclarr.Cli.Cache;
public partial class ServiceCache : IServiceCache
{
private readonly ICacheStoragePath _storagePath;
private readonly JsonSerializerSettings _jsonSettings;
private readonly JsonSerializerOptions _jsonSettings;
private readonly ILogger _log;
public ServiceCache(ICacheStoragePath storagePath, ILogger log)
@ -37,7 +37,7 @@ public partial class ServiceCache : IServiceCache
try
{
return JsonConvert.DeserializeObject<T>(json, _jsonSettings);
return JsonSerializer.Deserialize<T>(json, _jsonSettings);
}
catch (JsonException e)
{
@ -53,10 +53,8 @@ public partial class ServiceCache : IServiceCache
_log.Debug("Saving cache to path: {Path}", path.FullName);
path.CreateParentDirectory();
var serializer = JsonSerializer.Create(_jsonSettings);
using var stream = new JsonTextWriter(path.CreateText());
serializer.Serialize(stream, obj);
using var stream = path.Create();
JsonSerializer.Serialize(stream, obj, _jsonSettings);
}
private static string GetCacheObjectNameAttribute<T>()

@ -39,6 +39,6 @@ public class CustomFormatService : ICustomFormatService
CancellationToken cancellationToken = default)
{
await _service.Request(config, "customformat", customFormatId)
.DeleteAsync(cancellationToken);
.DeleteAsync(cancellationToken: cancellationToken);
}
}

@ -1,5 +1,5 @@
using System.Text.Json.Serialization;
using JetBrains.Annotations;
using Newtonsoft.Json;
namespace Recyclarr.Cli.Pipelines.QualityProfile.Api;

@ -1,4 +1,5 @@
using Newtonsoft.Json.Linq;
using System.Text.Json;
using System.Text.Json.JsonDiffPatch;
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
@ -55,8 +56,9 @@ public class QualityProfileStatCalculator
private static void QualityUpdates(ProfileWithStats stats, QualityProfileDto oldDto, QualityProfileDto newDto)
{
stats.QualitiesChanged = !JToken.DeepEquals(
JToken.FromObject(oldDto.Items), JToken.FromObject(newDto.Items));
using var oldJson = JsonSerializer.SerializeToDocument(oldDto.Items);
using var newJson = JsonSerializer.SerializeToDocument(newDto.Items);
stats.QualitiesChanged = !oldJson.DeepEquals(newJson);
}
private void ScoreUpdates(

@ -1,5 +1,5 @@
using System.Text.Json.Serialization;
using JetBrains.Annotations;
using Newtonsoft.Json;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Api.Objects;
@ -12,10 +12,10 @@ public class SonarrPreferredTerm
Score = score;
}
[JsonProperty("key")]
[JsonPropertyName("key")]
public string Term { get; set; }
[JsonProperty("value")]
[JsonPropertyName("value")]
public int Score { get; set; }
}

@ -1,29 +1,30 @@
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using Recyclarr.TrashLib.Json;
using System.Text;
using System.Text.Json;
using Recyclarr.Json;
namespace Recyclarr.Cli.Processors.ErrorHandling;
public sealed class ErrorResponseParser
{
private readonly ILogger _log;
private readonly Func<JsonTextReader> _streamFactory;
private readonly JsonSerializer _serializer;
private readonly Func<Stream> _streamFactory;
private readonly JsonSerializerOptions _jsonSettings;
public ErrorResponseParser(ILogger log, string responseBody)
{
_log = log;
_streamFactory = () => new JsonTextReader(new StringReader(responseBody));
_serializer = JsonSerializer.Create(GlobalJsonSerializerSettings.Services);
_streamFactory = () => new MemoryStream(Encoding.UTF8.GetBytes(responseBody));
_jsonSettings = GlobalJsonSerializerSettings.Services;
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public bool DeserializeList(Func<IEnumerable<dynamic>, IEnumerable<object>> expr)
public bool DeserializeList(Func<IEnumerable<JsonElement>, IEnumerable<string>> expr)
{
try
{
using var stream = _streamFactory();
var value = _serializer.Deserialize<List<dynamic>>(stream);
var value = JsonSerializer.Deserialize<List<JsonElement>>(stream, _jsonSettings);
if (value is null)
{
return false;
@ -32,7 +33,7 @@ public sealed class ErrorResponseParser
var parsed = expr(value);
foreach (var s in parsed)
{
_log.Error("Error message from remote service: {Message:l}", (string) s);
_log.Error("Error message from remote service: {Message:l}", s);
}
return true;
@ -44,18 +45,18 @@ public sealed class ErrorResponseParser
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public bool Deserialize(Func<dynamic, object> expr)
public bool Deserialize(Func<JsonElement, string?> expr)
{
try
{
using var stream = _streamFactory();
var value = _serializer.Deserialize<dynamic>(stream);
var value = expr(JsonSerializer.Deserialize<JsonElement>(stream, _jsonSettings));
if (value is null)
{
return false;
}
_log.Error("Error message from remote service: {Message:l}", (string) expr(value));
_log.Error("Error message from remote service: {Message:l}", value);
return true;
}
catch

@ -34,13 +34,13 @@ public class FlurlHttpExceptionHandler : IFlurlHttpExceptionHandler
var parser = new ErrorResponseParser(_log, responseBody);
if (parser.DeserializeList(s => s
.Select(x => (string) x.errorMessage)
.Select(x => x.GetProperty("errorMessage").GetString())
.NotNull(x => !string.IsNullOrEmpty(x))))
{
return;
}
if (parser.Deserialize(s => s.message))
if (parser.Deserialize(s => s.GetProperty("message").GetString()))
{
return;
}

@ -15,6 +15,7 @@
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Spectre.Console.Analyzer" PrivateAssets="All" />
<PackageReference Include="Spectre.Console.Cli" />
<PackageReference Include="SystemTextJson.JsonDiffPatch" />
<PackageReference Include="TestableIO.System.IO.Abstractions" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
<PackageReference Include="YamlDotNet" />

@ -4,7 +4,6 @@
<PackageReference Include="FluentValidation" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Serilog" />
<PackageReference Include="Spectre.Console" />
<PackageReference Include="System.Reactive" />

@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Recyclarr.Json;
public class CollectionJsonConverter : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
return !IsExcludedType(typeToConvert) &&
typeToConvert.IsGenericType &&
IsAssignableToGenericType(typeToConvert, typeof(IEnumerable<>));
}
private static bool IsExcludedType(Type type)
{
return type.IsPrimitive ||
type == typeof(string);
}
private static bool IsAssignableToGenericType(Type givenType, Type genericType)
{
var interfaceTypes = givenType.GetInterfaces()
.Where(x => x.IsGenericType)
.Select(x => x.GetGenericTypeDefinition());
return interfaceTypes.Contains(genericType);
}
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
var elementType = typeToConvert.GetGenericArguments()[0];
Type converterType;
if (typeToConvert.GetGenericTypeDefinition() == typeof(IReadOnlyCollection<>))
{
converterType = typeof(ReadOnlyCollectionJsonConverter<>).MakeGenericType(elementType);
}
else
{
throw new JsonException();
}
var instance = Activator.CreateInstance(converterType);
if (instance is null)
{
throw new JsonException();
}
return (JsonConverter) instance;
}
}

@ -0,0 +1,43 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using JorgeSerrano.Json;
namespace Recyclarr.Json;
public static class GlobalJsonSerializerSettings
{
/// <summary>
/// JSON settings used for starr service API payloads.
/// </summary>
public static JsonSerializerOptions Services { get; } = new()
{
// This makes sure that null properties, such as maxSize and preferredSize in Radarr
// Quality Definitions, do not get written out to JSON request bodies.
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = {JsonSerializationModifiers.IgnoreNoSerializeAttribute}
}
};
/// <summary>
/// JSON settings used by cache and other Recyclarr-owned JSON files.
/// </summary>
public static JsonSerializerOptions Recyclarr { get; } = new()
{
PropertyNamingPolicy = new JsonSnakeCaseNamingPolicy(),
WriteIndented = true
};
/// <summary>
/// JSON settings used by Trash Guides JSON files.
/// </summary>
public static JsonSerializerOptions Guide { get; } = new()
{
PropertyNamingPolicy = new JsonSnakeCaseNamingPolicy(),
NumberHandling = JsonNumberHandling.AllowReadingFromString
};
}

@ -0,0 +1,2 @@
global using Serilog;
global using JetBrains.Annotations;

@ -1,14 +1,15 @@
using System.Text.Json;
using Autofac;
using Newtonsoft.Json;
using Recyclarr.Json.Loading;
namespace Recyclarr.TrashLib.Json;
namespace Recyclarr.Json;
public class JsonAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.Register<Func<JsonSerializerSettings, IBulkJsonLoader>>(c =>
builder.Register<Func<JsonSerializerOptions, IBulkJsonLoader>>(c =>
{
return settings => new BulkJsonLoader(c.Resolve<ILogger>(), settings);
});

@ -0,0 +1,21 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Recyclarr.Json;
public static class JsonExtensions
{
public static JsonSerializerOptions CopyOptionsWithout<T>(this JsonSerializerOptions options)
where T : JsonConverter
{
var jsonSerializerOptions = new JsonSerializerOptions(options);
var jsonConverter = jsonSerializerOptions.Converters.FirstOrDefault(x => x.GetType() == typeof(T));
if (jsonConverter is not null)
{
jsonSerializerOptions.Converters.Remove(jsonConverter);
}
return jsonSerializerOptions;
}
}

@ -0,0 +1,41 @@
using System.Text.Json.Serialization.Metadata;
namespace Recyclarr.Json;
[AttributeUsage(AttributeTargets.Property)]
public sealed class JsonNoSerializeAttribute : Attribute
{
}
public static class JsonSerializationModifiers
{
private static bool HasAttribute<T>(JsonPropertyInfo? prop, IReadOnlyDictionary<string, IEnumerable<Type>> allAttrs)
where T : Attribute
{
if (prop is null)
{
return false;
}
if (!allAttrs.TryGetValue(prop.Name, out var attrs))
{
return false;
}
return attrs.Any(x => x.IsAssignableTo(typeof(T)));
}
public static void IgnoreNoSerializeAttribute(JsonTypeInfo type)
{
var attrs = type.Properties
.Select(x => (Property: x, Attributes: x.PropertyType.GetCustomAttributes(false).Select(y => y.GetType())))
.Where(x => x.Attributes.Any())
.ToDictionary(x => x.Property.Name, x => x.Attributes);
var props = type.Properties;
foreach (var prop in props)
{
prop.ShouldSerialize = (_, _) => !HasAttribute<JsonNoSerializeAttribute>(prop, attrs);
}
}
}

@ -1,7 +1,7 @@
using System.IO.Abstractions;
using Recyclarr.Common.Extensions;
namespace Recyclarr.Common;
namespace Recyclarr.Json;
public static class JsonUtils
{

@ -1,19 +1,18 @@
using System.IO.Abstractions;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using Newtonsoft.Json;
using Recyclarr.Common;
using System.Text.Json;
namespace Recyclarr.TrashLib.Json;
namespace Recyclarr.Json.Loading;
public record LoadedJsonObject<T>(IFileInfo File, T Obj);
public class BulkJsonLoader : IBulkJsonLoader
{
private readonly ILogger _log;
private readonly JsonSerializerSettings _serializerSettings;
private readonly JsonSerializerOptions _serializerSettings;
public BulkJsonLoader(ILogger log, JsonSerializerSettings serializerSettings)
public BulkJsonLoader(ILogger log, JsonSerializerOptions serializerSettings)
{
_log = log;
_serializerSettings = serializerSettings;
@ -35,10 +34,10 @@ public class BulkJsonLoader : IBulkJsonLoader
private T ParseJson<T>(string guideData, string fileName)
{
var obj = JsonConvert.DeserializeObject<T>(guideData, _serializerSettings);
var obj = JsonSerializer.Deserialize<T>(guideData, _serializerSettings);
if (obj is null)
{
throw new JsonSerializationException($"Unable to parse JSON at file {fileName}");
throw new JsonException($"Unable to parse JSON at file {fileName}");
}
return obj;

@ -1,13 +1,13 @@
using System.IO.Abstractions;
using Newtonsoft.Json;
using System.Text.Json;
namespace Recyclarr.TrashLib.Json;
namespace Recyclarr.Json.Loading;
public class GuideJsonLoader : IBulkJsonLoader
{
private readonly IBulkJsonLoader _loader;
public GuideJsonLoader(Func<JsonSerializerSettings, IBulkJsonLoader> jsonLoaderFactory)
public GuideJsonLoader(Func<JsonSerializerOptions, IBulkJsonLoader> jsonLoaderFactory)
{
_loader = jsonLoaderFactory(GlobalJsonSerializerSettings.Guide);
}

@ -1,6 +1,6 @@
using System.IO.Abstractions;
namespace Recyclarr.TrashLib.Json;
namespace Recyclarr.Json.Loading;
public interface IBulkJsonLoader
{

@ -1,13 +1,13 @@
using System.IO.Abstractions;
using Newtonsoft.Json;
using System.Text.Json;
namespace Recyclarr.TrashLib.Json;
namespace Recyclarr.Json.Loading;
public class ServiceJsonLoader : IBulkJsonLoader
{
private readonly IBulkJsonLoader _loader;
public ServiceJsonLoader(Func<JsonSerializerSettings, IBulkJsonLoader> jsonLoaderFactory)
public ServiceJsonLoader(Func<JsonSerializerOptions, IBulkJsonLoader> jsonLoaderFactory)
{
_loader = jsonLoaderFactory(GlobalJsonSerializerSettings.Services);
}

@ -0,0 +1,51 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Recyclarr.Json;
[UsedImplicitly]
public sealed class ReadOnlyCollectionJsonConverter<TElement> : JsonConverter<IReadOnlyCollection<TElement>>
{
public override IReadOnlyCollection<TElement> Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartArray)
{
throw new JsonException();
}
var list = new List<TElement>();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray)
{
break;
}
var elementValue = JsonSerializer.Deserialize<TElement>(ref reader, options);
if (elementValue is not null)
{
list.Add(elementValue);
}
}
return list;
}
public override void Write(
Utf8JsonWriter writer,
IReadOnlyCollection<TElement> value,
JsonSerializerOptions options)
{
writer.WriteStartArray();
foreach (var element in value)
{
JsonSerializer.Serialize(writer, element, options);
}
writer.WriteEndArray();
}
}

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Autofac" />
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="JorgeSerrano.Json.JsonSnakeCaseNamingPolicy" />
<PackageReference Include="Serilog" />
<PackageReference Include="System.Text.Json" />
<PackageReference Include="SystemTextJson.JsonDiffPatch" />
<PackageReference Include="TestableIO.System.IO.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Recyclarr.Common\Recyclarr.Common.csproj" />
</ItemGroup>
</Project>

@ -1,9 +1,8 @@
using System.Collections.ObjectModel;
using System.IO.Abstractions;
using System.Text.Json;
using JetBrains.Annotations;
using Newtonsoft.Json;
using Recyclarr.Json;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Json;
using Recyclarr.TrashLib.Repo;
namespace Recyclarr.TrashLib.Guide;
@ -13,8 +12,8 @@ public record TemplateEntry(string Id, string Template, bool Hidden = false);
public record TemplatesData
{
public ReadOnlyCollection<TemplateEntry> Radarr { get; [UsedImplicitly] init; } = new(Array.Empty<TemplateEntry>());
public ReadOnlyCollection<TemplateEntry> Sonarr { get; [UsedImplicitly] init; } = new(Array.Empty<TemplateEntry>());
public IReadOnlyCollection<TemplateEntry> Radarr { get; [UsedImplicitly] init; } = Array.Empty<TemplateEntry>();
public IReadOnlyCollection<TemplateEntry> Sonarr { get; [UsedImplicitly] init; } = Array.Empty<TemplateEntry>();
}
public record TemplatePath
@ -76,11 +75,8 @@ public class ConfigTemplateGuideService : IConfigTemplateGuideService
private static TemplatesData Deserialize(IFileInfo jsonFile)
{
var serializer = JsonSerializer.Create(GlobalJsonSerializerSettings.Recyclarr);
using var stream = new JsonTextReader(jsonFile.OpenText());
var obj = serializer.Deserialize<TemplatesData>(stream);
using var stream = jsonFile.OpenRead();
var obj = JsonSerializer.Deserialize<TemplatesData>(stream, GlobalJsonSerializerSettings.Recyclarr);
if (obj is null)
{
throw new InvalidDataException($"Unable to deserialize {jsonFile}");

@ -1,7 +1,7 @@
using System.IO.Abstractions;
using System.Reactive.Linq;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Json;
using Recyclarr.Json.Loading;
using Recyclarr.TrashLib.Models;
namespace Recyclarr.TrashLib.Guide.CustomFormat;

@ -1,9 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Recyclarr.Common;
using System.Text.Json;
using Recyclarr.Common.Extensions;
using Recyclarr.Json;
namespace Recyclarr.TrashLib.Guide.QualitySize;
@ -28,21 +27,13 @@ public class QualitySizeGuideParser
"Exceptions not rethrown so we can continue processing other files")]
private QualitySizeData? ParseQuality(IFileInfo jsonFile)
{
var serializer = JsonSerializer.Create(new JsonSerializerSettings
{
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy()
}
});
QualitySizeData? quality = null;
Exception? exception = null;
using var json = new JsonTextReader(jsonFile.OpenText());
try
{
quality = serializer.Deserialize<QualitySizeData>(json);
using var stream = jsonFile.OpenRead();
quality = JsonSerializer.Deserialize<QualitySizeData>(stream, GlobalJsonSerializerSettings.Guide);
}
catch (Exception e)
{

@ -1,12 +1,12 @@
using System.Text.Json.Serialization;
using JetBrains.Annotations;
using Newtonsoft.Json;
namespace Recyclarr.TrashLib.Guide.ReleaseProfile;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record TermData
{
[JsonProperty("trash_id")]
[JsonPropertyName("trash_id")]
public string TrashId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
@ -39,7 +39,7 @@ public record PreferredTermData
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record ReleaseProfileData
{
[JsonProperty("trash_id")]
[JsonPropertyName("trash_id")]
public string TrashId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;

@ -1,25 +1,33 @@
using System.IO.Abstractions;
using Newtonsoft.Json;
using Recyclarr.Common;
using System.Text.Json;
using Recyclarr.Json;
namespace Recyclarr.TrashLib.Guide.ReleaseProfile;
public class ReleaseProfileGuideParser
{
private readonly ILogger _log;
private readonly JsonSerializerOptions _jsonSettings;
public ReleaseProfileGuideParser(ILogger log)
{
_log = log;
_jsonSettings = new JsonSerializerOptions(GlobalJsonSerializerSettings.Services)
{
Converters =
{
new CollectionJsonConverter(),
new TermDataConverter()
}
};
}
private async Task<ReleaseProfileData?> LoadAndParseFile(IFileInfo file, params JsonConverter[] converters)
private async Task<ReleaseProfileData?> LoadAndParseFile(IFileInfo file)
{
try
{
using var stream = file.OpenText();
var json = await stream.ReadToEndAsync();
return JsonConvert.DeserializeObject<ReleaseProfileData>(json, converters);
await using var stream = file.OpenRead();
return await JsonSerializer.DeserializeAsync<ReleaseProfileData>(stream, _jsonSettings);
}
catch (JsonException e)
{
@ -42,10 +50,7 @@ public class ReleaseProfileGuideParser
public IEnumerable<ReleaseProfileData> GetReleaseProfileData(IEnumerable<IDirectoryInfo> paths)
{
var converter = new TermDataConverter();
var tasks = JsonUtils.GetJsonFilesInDirectories(paths, _log)
.Select(x => LoadAndParseFile(x, converter));
var tasks = JsonUtils.GetJsonFilesInDirectories(paths, _log).Select(LoadAndParseFile);
var data = Task.WhenAll(tasks).Result
// Make non-nullable type and filter out null values
.Choose(x => x is not null ? (true, x) : default);

@ -1,33 +1,24 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Recyclarr.Json;
namespace Recyclarr.TrashLib.Guide.ReleaseProfile;
internal class TermDataConverter : JsonConverter
public class TermDataConverter : JsonConverter<TermData>
{
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
public override TermData? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Not to be used for serialization
throw new NotImplementedException();
}
public override object? ReadJson(
JsonReader reader,
Type objectType,
object? existingValue,
JsonSerializer serializer)
{
var token = JToken.Load(reader);
return token.Type switch
if (reader.TokenType is JsonTokenType.String)
{
JTokenType.Object => token.ToObject<TermData>(),
JTokenType.String => new TermData {Term = token.ToString()},
_ => null
};
var str = reader.GetString();
return str is not null ? new TermData {Term = str} : null;
}
return JsonSerializer.Deserialize<TermData>(ref reader, options.CopyOptionsWithout<TermDataConverter>());
}
public override bool CanConvert(Type objectType)
public override void Write(Utf8JsonWriter writer, TermData value, JsonSerializerOptions options)
{
return objectType == typeof(TermData);
JsonSerializer.Serialize(writer, value, options.CopyOptionsWithout<TermDataConverter>());
}
}

@ -1,7 +1,7 @@
using Flurl.Http;
using Flurl.Http.Configuration;
using Recyclarr.Common.Networking;
using Recyclarr.TrashLib.Json;
using Recyclarr.Json;
using Recyclarr.TrashLib.Settings;
namespace Recyclarr.TrashLib.Http;
@ -30,7 +30,7 @@ public class FlurlClientFactory : IFlurlClientFactory
{
var settings = new ClientFlurlHttpSettings
{
JsonSerializer = new NewtonsoftJsonSerializer(GlobalJsonSerializerSettings.Services)
JsonSerializer = new DefaultJsonSerializer(GlobalJsonSerializerSettings.Services)
};
FlurlLogging.SetupLogging(settings, _log);

@ -1,6 +1,6 @@
using System.Text.Json;
using Flurl;
using Flurl.Http.Configuration;
using Newtonsoft.Json;
namespace Recyclarr.TrashLib.Http;
@ -51,7 +51,7 @@ public static class FlurlLogging
try
{
body = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(body));
body = JsonSerializer.Serialize(JsonSerializer.Deserialize<object>(body));
}
catch (JsonException)
{

@ -1,38 +0,0 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Recyclarr.TrashLib.Json;
public static class GlobalJsonSerializerSettings
{
/// <summary>
/// JSON settings used for starr service API payloads.
/// </summary>
public static JsonSerializerSettings Services { get; } = new()
{
// This makes sure that null properties, such as maxSize and preferredSize in Radarr
// Quality Definitions, do not get written out to JSON request bodies.
NullValueHandling = NullValueHandling.Ignore,
ContractResolver = new ServiceContractResolver
{
NamingStrategy = new CamelCaseNamingStrategy()
}
};
/// <summary>
/// JSON settings used by cache and other Recyclarr-owned JSON files.
/// </summary>
public static JsonSerializerSettings Recyclarr { get; } = new()
{
Formatting = Formatting.Indented,
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy()
}
};
/// <summary>
/// JSON settings used by Trash Guides JSON files.
/// </summary>
public static JsonSerializerSettings Guide => Recyclarr;
}

@ -1,51 +0,0 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Recyclarr.TrashLib.Json;
[AttributeUsage(AttributeTargets.Property)]
public sealed class JsonNoSerializeAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Property)]
public sealed class JsonNoDeserializeAttribute : Attribute
{
}
public class ServiceContractResolver : DefaultContractResolver
{
private static bool HasAttribute<T>(JsonProperty prop, Dictionary<string, IEnumerable<Type>> allAttrs)
where T : Attribute
{
var name = prop.UnderlyingName;
if (name is null)
{
return false;
}
if (!allAttrs.TryGetValue(name, out var attrs))
{
return false;
}
return attrs.Any(x => x.IsAssignableTo(typeof(T)));
}
protected override IList<JsonProperty> CreateProperties(Type type, MemberSerialization memberSerialization)
{
var attrs = type.GetProperties()
.Select(x => (Property: x, Attributes: x.GetCustomAttributes(false).Select(y => y.GetType())))
.Where(x => x.Attributes.Any())
.ToDictionary(x => x.Property.Name, x => x.Attributes);
var props = base.CreateProperties(type, memberSerialization);
foreach (var prop in props)
{
prop.ShouldSerialize = _ => !HasAttribute<JsonNoSerializeAttribute>(prop, attrs);
prop.ShouldDeserialize = _ => !HasAttribute<JsonNoDeserializeAttribute>(prop, attrs);
}
return props;
}
}

@ -1,12 +1,14 @@
using Newtonsoft.Json;
using System.Text.Json.Serialization;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Json;
using Recyclarr.Json;
namespace Recyclarr.TrashLib.Models;
public record CustomFormatFieldData
{
public string Name { get; } = nameof(Value).ToCamelCase();
[JsonConverter(typeof(FieldValueConverter))]
public object? Value { get; init; }
}
@ -28,11 +30,11 @@ public record CustomFormatData
[JsonIgnore]
public string? Category { get; init; }
[JsonProperty("trash_id")]
[JsonPropertyName("trash_id")]
[JsonNoSerialize]
public string TrashId { get; init; } = "";
[JsonProperty("trash_scores")]
[JsonPropertyName("trash_scores")]
[JsonNoSerialize]
public Dictionary<string, int> TrashScores { get; init; } = new(StringComparer.InvariantCultureIgnoreCase);

@ -0,0 +1,22 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Recyclarr.TrashLib.Models;
public class FieldValueConverter : JsonConverter<object>
{
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.Number => reader.GetInt32(),
JsonTokenType.String => reader.GetString(),
_ => throw new JsonException($"CF field of type {reader.TokenType} is not supported")
};
}
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, options);
}
}

@ -1,34 +1,29 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Recyclarr.TrashLib.Models;
public class FieldsArrayJsonConverter : JsonConverter
public class FieldsArrayJsonConverter : JsonConverter<object>
{
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
public override bool CanConvert(Type typeToConvert)
{
serializer.Serialize(writer, value);
return typeToConvert == typeof(CustomFormatFieldData) ||
Array.Exists(typeToConvert.GetInterfaces(),
x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>));
}
public override object? ReadJson(
JsonReader reader,
Type objectType,
object? existingValue,
JsonSerializer serializer)
public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var token = JToken.Load(reader);
// ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
return token.Type switch
if (reader.TokenType is JsonTokenType.StartObject)
{
JTokenType.Object => new[] {token.ToObject<CustomFormatFieldData>()},
JTokenType.Array => token.ToObject<CustomFormatFieldData[]>(),
_ => throw new InvalidOperationException("Unsupported token type for CustomFormatFieldData")
};
return new[] {JsonSerializer.Deserialize<CustomFormatFieldData>(ref reader, options)!};
}
return JsonSerializer.Deserialize<CustomFormatFieldData[]>(ref reader, options)!;
}
public override bool CanConvert(Type objectType)
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
{
return objectType.IsArray;
JsonSerializer.Serialize(writer, value, options);
}
}

@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\Recyclarr.Common\Recyclarr.Common.csproj" />
<ProjectReference Include="..\Recyclarr.Json\Recyclarr.Json.csproj" />
<ProjectReference Include="..\Recyclarr.Yaml\Recyclarr.Yaml.csproj" />
</ItemGroup>
</Project>

@ -1,6 +1,6 @@
using System.IO.Abstractions;
using Newtonsoft.Json;
using Recyclarr.TrashLib.Json;
using System.Text.Json;
using Recyclarr.Json;
namespace Recyclarr.TrashLib.Repo;
@ -17,11 +17,9 @@ public class TrashRepoMetadataBuilder : IRepoMetadataBuilder
private static RepoMetadata Deserialize(IFileInfo jsonFile)
{
var serializer = JsonSerializer.Create(GlobalJsonSerializerSettings.Guide);
using var stream = jsonFile.OpenRead();
using var stream = new JsonTextReader(jsonFile.OpenText());
var obj = serializer.Deserialize<RepoMetadata>(stream);
var obj = JsonSerializer.Deserialize<RepoMetadata>(stream, GlobalJsonSerializerSettings.Guide);
if (obj is null)
{
throw new InvalidDataException($"Unable to deserialize {jsonFile}");

@ -2,10 +2,10 @@ using Autofac;
using Autofac.Extras.Ordering;
using Recyclarr.Common;
using Recyclarr.Common.FluentValidation;
using Recyclarr.Json;
using Recyclarr.TrashLib.ApiServices;
using Recyclarr.TrashLib.Compatibility;
using Recyclarr.TrashLib.Http;
using Recyclarr.TrashLib.Json;
using Recyclarr.TrashLib.Repo;
using Recyclarr.TrashLib.Repo.VersionControl;
using Recyclarr.TrashLib.Settings;

@ -54,6 +54,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.TrashLib.Config.T
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.TrashLib.Guide.TestLibrary", "tests\Recyclarr.TrashLib.Guide.TestLibrary\Recyclarr.TrashLib.Guide.TestLibrary.csproj", "{B3FC265C-36EF-4815-9EB1-CB878FBEE6D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Json", "Recyclarr.Json\Recyclarr.Json.csproj", "{A9E2F11E-73F8-48CC-8770-0AFD41E80141}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Json.Tests", "tests\Recyclarr.Json.Tests\Recyclarr.Json.Tests.csproj", "{737A1B22-F3A5-4920-AED7-77E756D14113}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recyclarr.Json.TestLibrary", "tests\Recyclarr.Json.TestLibrary\Recyclarr.Json.TestLibrary.csproj", "{59B75C08-F144-4190-A7D0-C027044147D2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -136,6 +142,18 @@ Global
{B3FC265C-36EF-4815-9EB1-CB878FBEE6D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3FC265C-36EF-4815-9EB1-CB878FBEE6D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3FC265C-36EF-4815-9EB1-CB878FBEE6D6}.Release|Any CPU.Build.0 = Release|Any CPU
{A9E2F11E-73F8-48CC-8770-0AFD41E80141}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9E2F11E-73F8-48CC-8770-0AFD41E80141}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9E2F11E-73F8-48CC-8770-0AFD41E80141}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9E2F11E-73F8-48CC-8770-0AFD41E80141}.Release|Any CPU.Build.0 = Release|Any CPU
{737A1B22-F3A5-4920-AED7-77E756D14113}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{737A1B22-F3A5-4920-AED7-77E756D14113}.Debug|Any CPU.Build.0 = Debug|Any CPU
{737A1B22-F3A5-4920-AED7-77E756D14113}.Release|Any CPU.ActiveCfg = Release|Any CPU
{737A1B22-F3A5-4920-AED7-77E756D14113}.Release|Any CPU.Build.0 = Release|Any CPU
{59B75C08-F144-4190-A7D0-C027044147D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59B75C08-F144-4190-A7D0-C027044147D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59B75C08-F144-4190-A7D0-C027044147D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59B75C08-F144-4190-A7D0-C027044147D2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -156,5 +174,7 @@ Global
{2B4CAFB7-02D7-4909-98C4-7393C1366C39} = {18E17C53-F600-40AE-82C1-3CD1E547C307}
{533299C5-71A9-4461-90BB-05C3404F2143} = {18E17C53-F600-40AE-82C1-3CD1E547C307}
{B3FC265C-36EF-4815-9EB1-CB878FBEE6D6} = {18E17C53-F600-40AE-82C1-3CD1E547C307}
{737A1B22-F3A5-4920-AED7-77E756D14113} = {18E17C53-F600-40AE-82C1-3CD1E547C307}
{59B75C08-F144-4190-A7D0-C027044147D2} = {18E17C53-F600-40AE-82C1-3CD1E547C307}
EndGlobalSection
EndGlobal

@ -46,7 +46,10 @@ public class ServiceCacheTest
IServiceConfiguration config,
ServiceCache sut)
{
const string testJson = @"{'test_value': 'Foo'}";
const string testJson =
"""
{"test_value": "Foo"}
""";
const string testJsonPath = "cacheFile.json";
fs.AddFile(testJsonPath, new MockFileData(testJson));
@ -193,12 +196,12 @@ public class ServiceCacheTest
const string cacheJson =
"""
{
'version': 1,
'trash_id_mappings': [
"version": 1,
"trash_id_mappings": [
{
'custom_format_name': '4K Remaster',
'trash_id': 'eca37840c13c6ef2dd0262b141a5482f',
'custom_format_id': 4
"custom_format_name": "4K Remaster",
"trash_id": "eca37840c13c6ef2dd0262b141a5482f",
"custom_format_id": 4
}
]
}

@ -1,5 +1,5 @@
using Flurl.Http.Configuration;
using Recyclarr.TrashLib.Json;
using System.Text.Json;
using Recyclarr.Json;
using Recyclarr.TrashLib.Models;
namespace Recyclarr.Cli.Tests.Pipelines.CustomFormat.Models;
@ -11,8 +11,6 @@ public class FieldsArrayJsonConverterTest
[Test]
public void Read_multiple_as_array()
{
var serializer = new NewtonsoftJsonSerializer(GlobalJsonSerializerSettings.Services);
const string json =
"""
{
@ -40,26 +38,20 @@ public class FieldsArrayJsonConverterTest
]
}
""";
var result = serializer.Deserialize<CustomFormatSpecificationData>(json);
result.Fields.Should().BeEquivalentTo(new[]
var result =
JsonSerializer.Deserialize<CustomFormatSpecificationData>(json, GlobalJsonSerializerSettings.Services);
result!.Fields.Should().BeEquivalentTo(new[]
{
new CustomFormatFieldData
{
Value = 25
},
new CustomFormatFieldData
{
Value = 40
}
new CustomFormatFieldData {Value = 25},
new CustomFormatFieldData {Value = 40}
});
}
[Test]
public void Read_single_as_array()
{
var serializer = new NewtonsoftJsonSerializer(GlobalJsonSerializerSettings.Services);
const string json =
"""
{
@ -69,36 +61,34 @@ public class FieldsArrayJsonConverterTest
"label": "Minimum Size",
"unit": "GB",
"helpText": "Release must be greater than this size",
"value": 25,
"value": "25",
"type": "number",
"advanced": false
}
}
""";
var result = serializer.Deserialize<CustomFormatSpecificationData>(json);
var result =
JsonSerializer.Deserialize<CustomFormatSpecificationData>(json, GlobalJsonSerializerSettings.Services);
result.Fields.Should().BeEquivalentTo(new[]
result!.Fields.Should().BeEquivalentTo(new[]
{
new CustomFormatFieldData
{
Value = 25
}
new CustomFormatFieldData {Value = "25"}
});
}
[Test]
public void Read_throws_on_unsupported_token_type()
{
var serializer = new NewtonsoftJsonSerializer(GlobalJsonSerializerSettings.Services);
const string json =
"""
{
"fields": 0
}
""";
var act = () => serializer.Deserialize<CustomFormatSpecificationData>(json);
act.Should().Throw<InvalidOperationException>();
var act = () => JsonSerializer.Deserialize<CustomFormatSpecificationData>(
json, GlobalJsonSerializerSettings.Services);
act.Should().Throw<JsonException>();
}
}

@ -0,0 +1,14 @@
using Autofac;
using Recyclarr.TestLibrary;
namespace Recyclarr.Json.TestLibrary;
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public abstract class JsonIntegrationFixture : IntegrationTestFixture
{
protected override void RegisterTypes(ContainerBuilder builder)
{
base.RegisterTypes(builder);
builder.RegisterModule<JsonAutofacModule>();
}
}

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Recyclarr.Json\Recyclarr.Json.csproj" />
<ProjectReference Include="..\Recyclarr.TestLibrary\Recyclarr.TestLibrary.csproj" />
</ItemGroup>
</Project>

@ -1,17 +1,19 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using Recyclarr.TrashLib.Json;
using Recyclarr.TrashLib.Models;
using Recyclarr.TrashLib.TestLibrary;
using Recyclarr.Json.Loading;
using Recyclarr.Json.TestLibrary;
namespace Recyclarr.TrashLib.Tests.Json;
namespace Recyclarr.Json.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class BulkJsonLoaderIntegrationTest : TrashLibIntegrationFixture
public class BulkJsonLoaderIntegrationTest : JsonIntegrationFixture
{
[SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Local")]
private sealed record TestJsonObject(string TrashId, int TrashScore, string Name);
private sealed record TestGuideObject(string TrashId, int TrashScore, string Name);
[SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Local")]
private sealed record TestServiceObject(int Id, string Name, bool IncludeCustomFormatWhenRenaming);
[Test]
public void Guide_deserialize_works()
@ -29,11 +31,11 @@ public class BulkJsonLoaderIntegrationTest : TrashLibIntegrationFixture
Fs.AddFile(Fs.CurrentDirectory().File("file.json"), new MockFileData(jsonData));
var result = sut.LoadAllFilesAtPaths<TestJsonObject>(new[] {Fs.CurrentDirectory()});
var result = sut.LoadAllFilesAtPaths<TestGuideObject>(new[] {Fs.CurrentDirectory()});
result.Should().BeEquivalentTo(new[]
{
new TestJsonObject("90cedc1fea7ea5d11298bebd3d1d3223", -10000, "TheName")
new TestGuideObject("90cedc1fea7ea5d11298bebd3d1d3223", -10000, "TheName")
});
}
@ -53,16 +55,11 @@ public class BulkJsonLoaderIntegrationTest : TrashLibIntegrationFixture
Fs.AddFile(Fs.CurrentDirectory().File("file.json"), new MockFileData(jsonData));
var result = sut.LoadAllFilesAtPaths<CustomFormatData>(new[] {Fs.CurrentDirectory()});
var result = sut.LoadAllFilesAtPaths<TestServiceObject>(new[] {Fs.CurrentDirectory()});
result.Should().BeEquivalentTo(new[]
{
new CustomFormatData
{
Id = 22,
Name = "FUNi",
IncludeCustomFormatWhenRenaming = true
}
new TestServiceObject(22, "FUNi", true)
});
}
}

@ -1,7 +1,7 @@
using System.IO.Abstractions;
using Recyclarr.TestLibrary;
namespace Recyclarr.Common.Tests;
namespace Recyclarr.Json.Tests;
[TestFixture]
[Parallelizable(ParallelScope.All)]

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\Recyclarr.Json\Recyclarr.Json.csproj" />
<ProjectReference Include="..\Recyclarr.Json.TestLibrary\Recyclarr.Json.TestLibrary.csproj" />
<ProjectReference Include="..\Recyclarr.TestLibrary\Recyclarr.TestLibrary.csproj" />
</ItemGroup>
</Project>

@ -1,27 +0,0 @@
using FluentAssertions.Equivalency;
using FluentAssertions.Json;
using Newtonsoft.Json.Linq;
namespace Recyclarr.TestLibrary.FluentAssertions;
public class JsonEquivalencyStep : IEquivalencyStep
{
public EquivalencyResult Handle(
Comparands comparands,
IEquivalencyValidationContext context,
IEquivalencyValidator nestedValidator)
{
var canHandle = comparands.Subject?.GetType().IsAssignableTo(typeof(JToken)) ?? false;
if (!canHandle)
{
return EquivalencyResult.ContinueWithNext;
}
((JToken) comparands.Subject!).Should().BeEquivalentTo(
(JToken) comparands.Expectation,
context.Reason.FormattedMessage,
context.Reason.Arguments);
return EquivalencyResult.AssertionCompleted;
}
}

@ -0,0 +1,86 @@
using System.IO.Abstractions;
using Autofac;
using Autofac.Features.ResolveAnything;
using Recyclarr.Common;
using Recyclarr.TestLibrary.Autofac;
using Spectre.Console;
using Spectre.Console.Testing;
namespace Recyclarr.TestLibrary;
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public abstract class IntegrationTestFixture : IDisposable
{
private readonly Lazy<ILifetimeScope> _container;
protected ILifetimeScope Container => _container.Value;
protected MockFileSystem Fs { get; }
protected TestConsole Console { get; } = new();
protected TestableLogger Logger { get; } = new();
protected IntegrationTestFixture()
{
Fs = new MockFileSystem(new MockFileSystemOptions
{
CreateDefaultTempDir = false
});
// Use Lazy because we shouldn't invoke virtual methods at construction time
_container = new Lazy<ILifetimeScope>(() =>
{
var builder = new ContainerBuilder();
RegisterTypes(builder);
RegisterStubsAndMocks(builder);
builder.RegisterSource<AnyConcreteTypeNotAlreadyRegisteredSource>();
return builder.Build();
});
}
/// <summary>
/// Register "real" types (usually Module-derived classes from other projects). This call happens
/// before
/// RegisterStubsAndMocks().
/// </summary>
protected virtual void RegisterTypes(ContainerBuilder builder)
{
}
/// <summary>
/// Override registrations made in the RegisterTypes() method. This method is called after
/// RegisterTypes().
/// </summary>
protected virtual void RegisterStubsAndMocks(ContainerBuilder builder)
{
builder.RegisterInstance(Fs).As<IFileSystem>();
builder.RegisterInstance(Console).As<IAnsiConsole>();
builder.RegisterInstance(Logger).As<ILogger>();
builder.RegisterMockFor<IEnvironment>();
}
protected T Resolve<T>() where T : notnull
{
return Container.Resolve<T>();
}
// ReSharper disable once VirtualMemberNeverOverridden.Global
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
if (!_container.IsValueCreated)
{
return;
}
_container.Value.Dispose();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}

@ -1,12 +1,12 @@
using Newtonsoft.Json;
using System.Text.Json;
namespace Recyclarr.TestLibrary;
public static class MockData
{
public static MockFileData FromJson(object json)
public static MockFileData FromJson(object json, JsonSerializerOptions options)
{
return new MockFileData(JsonConvert.SerializeObject(json));
return new MockFileData(JsonSerializer.Serialize(json, options));
}
public static MockFileData FromString(string data)

@ -21,9 +21,9 @@ public class ConfigurationLoaderTest : ConfigIntegrationFixture
return () => new StringReader(testData.ReadData(file));
}
protected override void RegisterTypes(ContainerBuilder builder)
protected override void RegisterStubsAndMocks(ContainerBuilder builder)
{
base.RegisterTypes(builder);
base.RegisterStubsAndMocks(builder);
builder.RegisterMockFor<IValidator<RadarrConfigYaml>>();
builder.RegisterMockFor<IValidator<SonarrConfigYaml>>();
}

@ -1,5 +1,5 @@
using System.IO.Abstractions;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using Recyclarr.TrashLib.Guide.CustomFormat;
using Recyclarr.TrashLib.Guide.TestLibrary;
using Recyclarr.TrashLib.TestLibrary;
@ -14,8 +14,8 @@ public class CustomFormatLoaderIntegrationTest : GuideIntegrationFixture
public void Get_custom_format_json_works()
{
var sut = Resolve<CustomFormatLoader>();
Fs.AddFile("first.json", new MockFileData("{'name':'first','trash_id':'1'}"));
Fs.AddFile("second.json", new MockFileData("{'name':'second','trash_id':'2'}"));
Fs.AddFile("first.json", new MockFileData("""{"name":"first","trash_id":"1"}"""));
Fs.AddFile("second.json", new MockFileData("""{"name":"second","trash_id":"2"}"""));
Fs.AddFile("collection_of_cfs.md", new MockFileData(""));
var dir = Fs.CurrentDirectory();
@ -25,6 +25,6 @@ public class CustomFormatLoaderIntegrationTest : GuideIntegrationFixture
{
NewCf.Data("first", "1"),
NewCf.Data("second", "2")
}, o => o.Excluding(x => x.Type == typeof(JObject)));
}, o => o.Excluding(x => x.Type == typeof(JsonElement)));
}
}

@ -1,5 +1,5 @@
using System.IO.Abstractions;
using Newtonsoft.Json;
using Recyclarr.Json;
using Recyclarr.TestLibrary;
using Recyclarr.TestLibrary.AutoFixture;
using Recyclarr.TrashLib.Guide.ReleaseProfile;
@ -30,18 +30,16 @@ public class ReleaseProfileGuideServiceTest
};
}
static MockFileData MockFileData(dynamic obj)
{
return new MockFileData(JsonConvert.SerializeObject(obj));
}
var mockData1 = MakeMockObject("first");
var mockData2 = MakeMockObject("second");
var baseDir = fs.CurrentDirectory().SubDirectory("files");
baseDir.Create();
fs.AddFile(baseDir.File("first.json").FullName, MockFileData(mockData1));
fs.AddFile(baseDir.File("second.json").FullName, MockFileData(mockData2));
fs.AddFile(baseDir.File("first.json").FullName,
MockData.FromJson(mockData1, GlobalJsonSerializerSettings.Services));
fs.AddFile(baseDir.File("second.json").FullName,
MockData.FromJson(mockData2, GlobalJsonSerializerSettings.Services));
metadataBuilder.ToDirectoryInfoList(default!).ReturnsForAnyArgs(new[] {baseDir});
@ -74,8 +72,11 @@ public class ReleaseProfileGuideServiceTest
}
};
fs.AddFile(rootPath.File("0_bad_data.json").FullName, MockData.FromString(badData));
fs.AddFile(rootPath.File("1_good_data.json").FullName, MockData.FromJson(goodData));
fs.AddFile(rootPath.File("0_bad_data.json").FullName,
MockData.FromString(badData));
fs.AddFile(rootPath.File("1_good_data.json").FullName,
MockData.FromJson(goodData, GlobalJsonSerializerSettings.Services));
metadataBuilder.ToDirectoryInfoList(default!).ReturnsForAnyArgs(new[] {rootPath});

@ -1,96 +1,41 @@
using System.IO.Abstractions;
using Autofac;
using Autofac.Features.ResolveAnything;
using Recyclarr.Common;
using Recyclarr.TestLibrary;
using Recyclarr.TestLibrary.Autofac;
using Recyclarr.TrashLib.ApiServices.System;
using Recyclarr.TrashLib.Repo.VersionControl;
using Recyclarr.TrashLib.Startup;
using Spectre.Console;
using Spectre.Console.Testing;
namespace Recyclarr.TrashLib.TestLibrary;
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public abstract class TrashLibIntegrationFixture : IDisposable
public abstract class TrashLibIntegrationFixture : IntegrationTestFixture
{
protected IAppPaths Paths { get; }
protected TrashLibIntegrationFixture()
{
Fs = new MockFileSystem(new MockFileSystemOptions
{
CreateDefaultTempDir = false
});
Paths = new AppPaths(Fs.CurrentDirectory().SubDirectory("test").SubDirectory("recyclarr"));
_container = new Lazy<IContainer>(() =>
{
var builder = new ContainerBuilder();
RegisterTypes(builder);
builder.RegisterInstance(Fs).As<IFileSystem>();
builder.RegisterInstance(Paths).As<IAppPaths>();
builder.RegisterInstance(Console).As<IAnsiConsole>();
builder.RegisterInstance(Logger).As<ILogger>();
builder.RegisterMockFor<IGitRepository>();
builder.RegisterMockFor<IGitRepositoryFactory>();
builder.RegisterMockFor<IEnvironment>();
builder.RegisterMockFor<IServiceInformation>(m =>
{
// By default, choose some extremely high number so that all the newest features are enabled.
m.GetVersion(default!).ReturnsForAnyArgs(_ => new Version("99.0.0.0"));
});
builder.RegisterSource<AnyConcreteTypeNotAlreadyRegisteredSource>();
return builder.Build();
});
}
// ReSharper disable once VirtualMemberNeverOverridden.Global
// ReSharper disable once UnusedParameter.Global
protected virtual void RegisterTypes(ContainerBuilder builder)
protected override void RegisterTypes(ContainerBuilder builder)
{
base.RegisterTypes(builder);
builder.RegisterModule<TrashLibAutofacModule>();
}
// ReSharper disable MemberCanBePrivate.Global
private readonly Lazy<IContainer> _container;
protected ILifetimeScope Container => _container.Value;
protected MockFileSystem Fs { get; }
protected TestConsole Console { get; } = new();
protected IAppPaths Paths { get; }
protected TestableLogger Logger { get; } = new();
// ReSharper restore MemberCanBePrivate.Global
protected T Resolve<T>() where T : notnull
protected override void RegisterStubsAndMocks(ContainerBuilder builder)
{
return Container.Resolve<T>();
}
base.RegisterStubsAndMocks(builder);
// ReSharper disable once VirtualMemberNeverOverridden.Global
protected virtual void Dispose(bool disposing)
{
if (!disposing)
{
return;
}
builder.RegisterInstance(Paths).As<IAppPaths>();
if (_container.IsValueCreated)
builder.RegisterMockFor<IGitRepository>();
builder.RegisterMockFor<IGitRepositoryFactory>();
builder.RegisterMockFor<IServiceInformation>(m =>
{
_container.Value.Dispose();
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
// By default, choose some extremely high number so that all the newest features are enabled.
m.GetVersion(default!).ReturnsForAnyArgs(_ => new Version("99.0.0.0"));
});
}
}

Loading…
Cancel
Save