refactor: Utilize DI for Flurl

The goal is to eliminate the need for a "global setup" step for HTTP
communication. This can instead be done in the composition root as part
of the factory to request FlurlClient objects.
pull/151/head
Robert Dailey 2 years ago
parent 2a79a50d50
commit d04b10f9d0

@ -5,7 +5,7 @@ using JetBrains.Annotations;
using Recyclarr.Config; using Recyclarr.Config;
using Serilog; using Serilog;
using TrashLib.Config.Services; using TrashLib.Config.Services;
using TrashLib.Extensions; using TrashLib.Http;
using TrashLib.Services.CustomFormat; using TrashLib.Services.CustomFormat;
using TrashLib.Services.Radarr; using TrashLib.Services.Radarr;
using TrashLib.Services.Radarr.Config; using TrashLib.Services.Radarr.Config;

@ -3,15 +3,11 @@ using Autofac;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using Common.Networking;
using Flurl.Http; using Flurl.Http;
using Flurl.Http.Configuration;
using JetBrains.Annotations; using JetBrains.Annotations;
using Newtonsoft.Json;
using Recyclarr.Migration; using Recyclarr.Migration;
using Serilog; using Serilog;
using TrashLib.Config.Settings; using TrashLib.Http;
using TrashLib.Extensions;
using TrashLib.Repo; using TrashLib.Repo;
using TrashLib.Repo.VersionControl; using TrashLib.Repo.VersionControl;
using YamlDotNet.Core; using YamlDotNet.Core;
@ -76,7 +72,6 @@ public abstract class ServiceCommand : BaseCommand, IServiceCommand
public override async Task Process(ILifetimeScope container) public override async Task Process(ILifetimeScope container)
{ {
var log = container.Resolve<ILogger>(); var log = container.Resolve<ILogger>();
var settingsProvider = container.Resolve<ISettingsProvider>();
var repoUpdater = container.Resolve<IRepoUpdater>(); var repoUpdater = container.Resolve<IRepoUpdater>();
var migration = container.Resolve<IMigrationExecutor>(); var migration = container.Resolve<IMigrationExecutor>();
@ -85,32 +80,6 @@ public abstract class ServiceCommand : BaseCommand, IServiceCommand
// Will throw if migration is required, otherwise just a warning is issued. // Will throw if migration is required, otherwise just a warning is issued.
migration.CheckNeededMigrations(); migration.CheckNeededMigrations();
SetupHttp(log, settingsProvider);
await repoUpdater.UpdateRepo(); await repoUpdater.UpdateRepo();
} }
private static void SetupHttp(ILogger log, ISettingsProvider settingsProvider)
{
FlurlHttp.Configure(settings =>
{
var jsonSettings = new JsonSerializerSettings
{
// 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
};
settings.JsonSerializer = new NewtonsoftJsonSerializer(jsonSettings);
FlurlLogging.SetupLogging(settings, log);
// ReSharper disable once InvertIf
if (!settingsProvider.Settings.EnableSslCertificateValidation)
{
log.Warning(
"Security Risk: Certificate validation is being DISABLED because setting " +
"`enable_ssl_certificate_validation` is set to `false`");
settings.HttpClientFactory = new UntrustedCertClientFactory();
}
});
}
} }

@ -5,7 +5,7 @@ using JetBrains.Annotations;
using Recyclarr.Config; using Recyclarr.Config;
using Serilog; using Serilog;
using TrashLib.Config.Services; using TrashLib.Config.Services;
using TrashLib.Extensions; using TrashLib.Http;
using TrashLib.Services.CustomFormat; using TrashLib.Services.CustomFormat;
using TrashLib.Services.Sonarr; using TrashLib.Services.Sonarr;
using TrashLib.Services.Sonarr.Config; using TrashLib.Services.Sonarr.Config;

@ -13,6 +13,7 @@ using Recyclarr.Migration;
using TrashLib.Cache; using TrashLib.Cache;
using TrashLib.Config; using TrashLib.Config;
using TrashLib.Config.Services; using TrashLib.Config.Services;
using TrashLib.Http;
using TrashLib.Repo; using TrashLib.Repo;
using TrashLib.Repo.VersionControl; using TrashLib.Repo.VersionControl;
using TrashLib.Services.Common; using TrashLib.Services.Common;
@ -52,7 +53,7 @@ public static class CompositionRoot
builder.RegisterModule<CacheAutofacModule>(); builder.RegisterModule<CacheAutofacModule>();
builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>(); builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>();
builder.RegisterType<ServerInfo>().As<IServerInfo>(); builder.RegisterType<ServiceRequestBuilder>().As<IServiceRequestBuilder>();
builder.RegisterType<ProgressBar>(); builder.RegisterType<ProgressBar>();
ConfigurationRegistrations(builder); ConfigurationRegistrations(builder);
@ -60,6 +61,8 @@ public static class CompositionRoot
builder.Register(_ => AutoMapperConfig.Setup()).SingleInstance(); builder.Register(_ => AutoMapperConfig.Setup()).SingleInstance();
builder.RegisterType<FlurlClientFactory>().As<IFlurlClientFactory>().SingleInstance();
extraRegistrations?.Invoke(builder); extraRegistrations?.Invoke(builder);
return builder.Build(); return builder.Build();

@ -1,9 +0,0 @@
using Flurl;
namespace TrashLib.Config.Services;
public interface IServerInfo
{
Url BuildRequest();
string SanitizedBaseUrl { get; }
}

@ -0,0 +1,9 @@
using Flurl.Http;
namespace TrashLib.Config.Services;
public interface IServiceRequestBuilder
{
string SanitizedBaseUrl { get; }
IFlurlRequest Request(params object[] path);
}

@ -1,26 +0,0 @@
using Flurl;
using TrashLib.Extensions;
namespace TrashLib.Config.Services;
public class ServerInfo : IServerInfo
{
private readonly IServiceConfiguration _config;
public ServerInfo(IServiceConfiguration config)
{
_config = config;
}
public Url BuildRequest()
{
var apiKey = _config.ApiKey;
var baseUrl = _config.BaseUrl;
return baseUrl
.AppendPathSegment("api/v3")
.SetQueryParams(new {apikey = apiKey});
}
public string SanitizedBaseUrl => FlurlLogging.SanitizeUrl(_config.BaseUrl);
}

@ -0,0 +1,25 @@
using Flurl.Http;
using TrashLib.Http;
namespace TrashLib.Config.Services;
public class ServiceRequestBuilder : IServiceRequestBuilder
{
private readonly IServiceConfiguration _config;
private readonly IFlurlClientFactory _clientFactory;
public ServiceRequestBuilder(IServiceConfiguration config, IFlurlClientFactory clientFactory)
{
_config = config;
_clientFactory = clientFactory;
}
public IFlurlRequest Request(params object[] path)
{
var client = _clientFactory.Get(_config.BaseUrl);
return client.Request(new[] {"api", "v3"}.Concat(path).ToArray())
.SetQueryParams(new {apikey = _config.ApiKey});
}
public string SanitizedBaseUrl => FlurlLogging.SanitizeUrl(_config.BaseUrl);
}

@ -0,0 +1,55 @@
using Common.Networking;
using Flurl.Http;
using Flurl.Http.Configuration;
using Newtonsoft.Json;
using Serilog;
using TrashLib.Config.Settings;
namespace TrashLib.Http;
public class FlurlClientFactory : IFlurlClientFactory
{
private readonly ILogger _log;
private readonly ISettingsProvider _settingsProvider;
private readonly PerBaseUrlFlurlClientFactory _factory;
public FlurlClientFactory(ILogger log, ISettingsProvider settingsProvider)
{
_log = log;
_settingsProvider = settingsProvider;
_factory = new PerBaseUrlFlurlClientFactory();
}
public IFlurlClient Get(string baseUrl)
{
var client = _factory.Get(baseUrl);
client.Settings = GetClientSettings();
return client;
}
private ClientFlurlHttpSettings GetClientSettings()
{
var settings = new ClientFlurlHttpSettings
{
JsonSerializer = new NewtonsoftJsonSerializer(new JsonSerializerSettings
{
// 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
})
};
FlurlLogging.SetupLogging(settings, _log);
// ReSharper disable once InvertIf
if (!_settingsProvider.Settings.EnableSslCertificateValidation)
{
_log.Warning(
"Security Risk: Certificate validation is being DISABLED because setting " +
"`enable_ssl_certificate_validation` is set to `false`");
settings.HttpClientFactory = new UntrustedCertClientFactory();
}
return settings;
}
}

@ -1,7 +1,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Flurl.Http; using Flurl.Http;
namespace TrashLib.Extensions; namespace TrashLib.Http;
public static class FlurlExtensions public static class FlurlExtensions
{ {

@ -3,7 +3,7 @@ using Flurl.Http.Configuration;
using Newtonsoft.Json; using Newtonsoft.Json;
using Serilog; using Serilog;
namespace TrashLib.Extensions; namespace TrashLib.Http;
public static class FlurlLogging public static class FlurlLogging
{ {

@ -0,0 +1,8 @@
using Flurl.Http;
namespace TrashLib.Http;
public interface IFlurlClientFactory
{
IFlurlClient Get(string baseUrl);
}

@ -1,4 +1,3 @@
using Flurl;
using Flurl.Http; using Flurl.Http;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using TrashLib.Config.Services; using TrashLib.Config.Services;
@ -8,24 +7,22 @@ namespace TrashLib.Services.CustomFormat.Api;
internal class CustomFormatService : ICustomFormatService internal class CustomFormatService : ICustomFormatService
{ {
private readonly IServerInfo _serverInfo; private readonly IServiceRequestBuilder _service;
public CustomFormatService(IServerInfo serverInfo) public CustomFormatService(IServiceRequestBuilder service)
{ {
_serverInfo = serverInfo; _service = service;
} }
public async Task<List<JObject>> GetCustomFormats() public async Task<List<JObject>> GetCustomFormats()
{ {
return await BuildRequest() return await _service.Request("customformat")
.AppendPathSegment("customformat")
.GetJsonAsync<List<JObject>>(); .GetJsonAsync<List<JObject>>();
} }
public async Task CreateCustomFormat(ProcessedCustomFormatData cf) public async Task CreateCustomFormat(ProcessedCustomFormatData cf)
{ {
var response = await BuildRequest() var response = await _service.Request("customformat")
.AppendPathSegment("customformat")
.PostJsonAsync(cf.Json) .PostJsonAsync(cf.Json)
.ReceiveJson<JObject>(); .ReceiveJson<JObject>();
@ -37,18 +34,14 @@ internal class CustomFormatService : ICustomFormatService
public async Task UpdateCustomFormat(ProcessedCustomFormatData cf) public async Task UpdateCustomFormat(ProcessedCustomFormatData cf)
{ {
await BuildRequest() await _service.Request("customformat", cf.GetCustomFormatId())
.AppendPathSegment($"customformat/{cf.GetCustomFormatId()}")
.PutJsonAsync(cf.Json) .PutJsonAsync(cf.Json)
.ReceiveJson<JObject>(); .ReceiveJson<JObject>();
} }
public async Task DeleteCustomFormat(int customFormatId) public async Task DeleteCustomFormat(int customFormatId)
{ {
await BuildRequest() await _service.Request("customformat", customFormatId)
.AppendPathSegment($"customformat/{customFormatId}")
.DeleteAsync(); .DeleteAsync();
} }
private Url BuildRequest() => _serverInfo.BuildRequest();
} }

@ -1,4 +1,3 @@
using Flurl;
using Flurl.Http; using Flurl.Http;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using TrashLib.Config.Services; using TrashLib.Config.Services;
@ -7,27 +6,23 @@ namespace TrashLib.Services.CustomFormat.Api;
internal class QualityProfileService : IQualityProfileService internal class QualityProfileService : IQualityProfileService
{ {
private readonly IServerInfo _serverInfo; private readonly IServiceRequestBuilder _service;
public QualityProfileService(IServerInfo serverInfo) public QualityProfileService(IServiceRequestBuilder service)
{ {
_serverInfo = serverInfo; _service = service;
} }
public async Task<List<JObject>> GetQualityProfiles() public async Task<List<JObject>> GetQualityProfiles()
{ {
return await BuildRequest() return await _service.Request("qualityprofile")
.AppendPathSegment("qualityprofile")
.GetJsonAsync<List<JObject>>(); .GetJsonAsync<List<JObject>>();
} }
public async Task<JObject> UpdateQualityProfile(JObject profileJson, int id) public async Task<JObject> UpdateQualityProfile(JObject profileJson, int id)
{ {
return await BuildRequest() return await _service.Request("qualityprofile", id)
.AppendPathSegment($"qualityprofile/{id}")
.PutJsonAsync(profileJson) .PutJsonAsync(profileJson)
.ReceiveJson<JObject>(); .ReceiveJson<JObject>();
} }
private Url BuildRequest() => _serverInfo.BuildRequest();
} }

@ -14,7 +14,7 @@ internal class CustomFormatUpdater : ICustomFormatUpdater
private readonly IGuideProcessor _guideProcessor; private readonly IGuideProcessor _guideProcessor;
private readonly IPersistenceProcessor _persistenceProcessor; private readonly IPersistenceProcessor _persistenceProcessor;
private readonly IConsole _console; private readonly IConsole _console;
private readonly IServerInfo _serverInfo; private readonly IServiceRequestBuilder _service;
private readonly ILogger _log; private readonly ILogger _log;
public CustomFormatUpdater( public CustomFormatUpdater(
@ -23,14 +23,14 @@ internal class CustomFormatUpdater : ICustomFormatUpdater
IGuideProcessor guideProcessor, IGuideProcessor guideProcessor,
IPersistenceProcessor persistenceProcessor, IPersistenceProcessor persistenceProcessor,
IConsole console, IConsole console,
IServerInfo serverInfo) IServiceRequestBuilder service)
{ {
_log = log; _log = log;
_cache = cache; _cache = cache;
_guideProcessor = guideProcessor; _guideProcessor = guideProcessor;
_persistenceProcessor = persistenceProcessor; _persistenceProcessor = persistenceProcessor;
_console = console; _console = console;
_serverInfo = serverInfo; _service = service;
} }
public async Task Process(bool isPreview, IEnumerable<CustomFormatConfig> configs, IGuideService guideService) public async Task Process(bool isPreview, IEnumerable<CustomFormatConfig> configs, IGuideService guideService)
@ -141,8 +141,8 @@ internal class CustomFormatUpdater : ICustomFormatUpdater
if (_guideProcessor.CustomFormatsNotInGuide.Count > 0) if (_guideProcessor.CustomFormatsNotInGuide.Count > 0)
{ {
_log.Warning("The Custom Formats below do not exist in the guide and will " + _log.Warning("The Custom Formats below do not exist in the guide and will " +
"be skipped. Trash IDs must match what is listed in the output when using the " + "be skipped. Trash IDs must match what is listed in the output when using the " +
"`--list-custom-formats` option"); "`--list-custom-formats` option");
_log.Warning("{CfList}", _guideProcessor.CustomFormatsNotInGuide); _log.Warning("{CfList}", _guideProcessor.CustomFormatsNotInGuide);
_console.Output.WriteLine(""); _console.Output.WriteLine("");
@ -165,7 +165,7 @@ internal class CustomFormatUpdater : ICustomFormatUpdater
if (_guideProcessor.ConfigData.Count == 0) if (_guideProcessor.ConfigData.Count == 0)
{ {
_log.Error("Guide processing yielded no custom formats for configured instance host {BaseUrl}", _log.Error("Guide processing yielded no custom formats for configured instance host {BaseUrl}",
_serverInfo.SanitizedBaseUrl); _service.SanitizedBaseUrl);
return false; return false;
} }

@ -1,4 +1,3 @@
using Flurl;
using Flurl.Http; using Flurl.Http;
using TrashLib.Config.Services; using TrashLib.Config.Services;
using TrashLib.Services.Radarr.QualityDefinition.Api.Objects; using TrashLib.Services.Radarr.QualityDefinition.Api.Objects;
@ -7,28 +6,24 @@ namespace TrashLib.Services.Radarr.QualityDefinition.Api;
internal class QualityDefinitionService : IQualityDefinitionService internal class QualityDefinitionService : IQualityDefinitionService
{ {
private readonly IServerInfo _serverInfo; private readonly IServiceRequestBuilder _service;
public QualityDefinitionService(IServerInfo serverInfo) public QualityDefinitionService(IServiceRequestBuilder service)
{ {
_serverInfo = serverInfo; _service = service;
} }
public async Task<List<RadarrQualityDefinitionItem>> GetQualityDefinition() public async Task<List<RadarrQualityDefinitionItem>> GetQualityDefinition()
{ {
return await BuildRequest() return await _service.Request("qualitydefinition")
.AppendPathSegment("qualitydefinition")
.GetJsonAsync<List<RadarrQualityDefinitionItem>>(); .GetJsonAsync<List<RadarrQualityDefinitionItem>>();
} }
public async Task<IList<RadarrQualityDefinitionItem>> UpdateQualityDefinition( public async Task<IList<RadarrQualityDefinitionItem>> UpdateQualityDefinition(
IList<RadarrQualityDefinitionItem> newQuality) IList<RadarrQualityDefinitionItem> newQuality)
{ {
return await BuildRequest() return await _service.Request("qualityDefinition", "update")
.AppendPathSegment("qualityDefinition/update")
.PutJsonAsync(newQuality) .PutJsonAsync(newQuality)
.ReceiveJson<List<RadarrQualityDefinitionItem>>(); .ReceiveJson<List<RadarrQualityDefinitionItem>>();
} }
private Url BuildRequest() => _serverInfo.BuildRequest();
} }

@ -8,21 +8,20 @@ namespace TrashLib.Services.Sonarr.Api;
public class ReleaseProfileApiService : IReleaseProfileApiService public class ReleaseProfileApiService : IReleaseProfileApiService
{ {
private readonly ISonarrReleaseProfileCompatibilityHandler _profileHandler; private readonly ISonarrReleaseProfileCompatibilityHandler _profileHandler;
private readonly IServerInfo _serverInfo; private readonly IServiceRequestBuilder _service;
public ReleaseProfileApiService( public ReleaseProfileApiService(
ISonarrReleaseProfileCompatibilityHandler profileHandler, ISonarrReleaseProfileCompatibilityHandler profileHandler,
IServerInfo serverInfo) IServiceRequestBuilder service)
{ {
_profileHandler = profileHandler; _profileHandler = profileHandler;
_serverInfo = serverInfo; _service = service;
} }
public async Task UpdateReleaseProfile(SonarrReleaseProfile profile) public async Task UpdateReleaseProfile(SonarrReleaseProfile profile)
{ {
var profileToSend = await _profileHandler.CompatibleReleaseProfileForSendingAsync(profile); var profileToSend = await _profileHandler.CompatibleReleaseProfileForSendingAsync(profile);
await _serverInfo.BuildRequest() await _service.Request("releaseprofile", profile.Id)
.AppendPathSegment($"releaseprofile/{profile.Id}")
.PutJsonAsync(profileToSend); .PutJsonAsync(profileToSend);
} }
@ -30,8 +29,7 @@ public class ReleaseProfileApiService : IReleaseProfileApiService
{ {
var profileToSend = await _profileHandler.CompatibleReleaseProfileForSendingAsync(profile); var profileToSend = await _profileHandler.CompatibleReleaseProfileForSendingAsync(profile);
var response = await _serverInfo.BuildRequest() var response = await _service.Request("releaseprofile")
.AppendPathSegment("releaseprofile")
.PostJsonAsync(profileToSend) .PostJsonAsync(profileToSend)
.ReceiveJson<JObject>(); .ReceiveJson<JObject>();
@ -40,8 +38,7 @@ public class ReleaseProfileApiService : IReleaseProfileApiService
public async Task<IList<SonarrReleaseProfile>> GetReleaseProfiles() public async Task<IList<SonarrReleaseProfile>> GetReleaseProfiles()
{ {
var response = await _serverInfo.BuildRequest() var response = await _service.Request("releaseprofile")
.AppendPathSegment("releaseprofile")
.GetJsonAsync<List<JObject>>(); .GetJsonAsync<List<JObject>>();
return response return response
@ -51,8 +48,7 @@ public class ReleaseProfileApiService : IReleaseProfileApiService
public async Task DeleteReleaseProfile(int releaseProfileId) public async Task DeleteReleaseProfile(int releaseProfileId)
{ {
await _serverInfo.BuildRequest() await _service.Request("releaseprofile", releaseProfileId)
.AppendPathSegment($"releaseprofile/{releaseProfileId}")
.DeleteAsync(); .DeleteAsync();
} }
} }

@ -1,4 +1,3 @@
using Flurl;
using Flurl.Http; using Flurl.Http;
using TrashLib.Config.Services; using TrashLib.Config.Services;
using TrashLib.Services.Sonarr.Api.Objects; using TrashLib.Services.Sonarr.Api.Objects;
@ -7,43 +6,37 @@ namespace TrashLib.Services.Sonarr.Api;
public class SonarrApi : ISonarrApi public class SonarrApi : ISonarrApi
{ {
private readonly IServerInfo _serverInfo; private readonly IServiceRequestBuilder _service;
public SonarrApi(IServerInfo serverInfo) public SonarrApi(IServiceRequestBuilder service)
{ {
_serverInfo = serverInfo; _service = service;
} }
public async Task<IList<SonarrTag>> GetTags() public async Task<IList<SonarrTag>> GetTags()
{ {
return await BaseUrl() return await _service.Request("tag")
.AppendPathSegment("tag")
.GetJsonAsync<List<SonarrTag>>(); .GetJsonAsync<List<SonarrTag>>();
} }
public async Task<SonarrTag> CreateTag(string tag) public async Task<SonarrTag> CreateTag(string tag)
{ {
return await BaseUrl() return await _service.Request("tag")
.AppendPathSegment("tag")
.PostJsonAsync(new {label = tag}) .PostJsonAsync(new {label = tag})
.ReceiveJson<SonarrTag>(); .ReceiveJson<SonarrTag>();
} }
public async Task<IReadOnlyCollection<SonarrQualityDefinitionItem>> GetQualityDefinition() public async Task<IReadOnlyCollection<SonarrQualityDefinitionItem>> GetQualityDefinition()
{ {
return await BaseUrl() return await _service.Request("qualitydefinition")
.AppendPathSegment("qualitydefinition")
.GetJsonAsync<List<SonarrQualityDefinitionItem>>(); .GetJsonAsync<List<SonarrQualityDefinitionItem>>();
} }
public async Task<IList<SonarrQualityDefinitionItem>> UpdateQualityDefinition( public async Task<IList<SonarrQualityDefinitionItem>> UpdateQualityDefinition(
IReadOnlyCollection<SonarrQualityDefinitionItem> newQuality) IReadOnlyCollection<SonarrQualityDefinitionItem> newQuality)
{ {
return await BaseUrl() return await _service.Request("qualityDefinition", "update")
.AppendPathSegment("qualityDefinition/update")
.PutJsonAsync(newQuality) .PutJsonAsync(newQuality)
.ReceiveJson<List<SonarrQualityDefinitionItem>>(); .ReceiveJson<List<SonarrQualityDefinitionItem>>();
} }
private Url BaseUrl() => _serverInfo.BuildRequest();
} }

@ -1,4 +1,3 @@
using Flurl;
using Flurl.Http; using Flurl.Http;
using TrashLib.Config.Services; using TrashLib.Config.Services;
using TrashLib.Services.System.Dto; using TrashLib.Services.System.Dto;
@ -7,19 +6,16 @@ namespace TrashLib.Services.System;
public class SystemApiService : ISystemApiService public class SystemApiService : ISystemApiService
{ {
private readonly IServerInfo _serverInfo; private readonly IServiceRequestBuilder _service;
public SystemApiService(IServerInfo serverInfo) public SystemApiService(IServiceRequestBuilder service)
{ {
_serverInfo = serverInfo; _service = service;
} }
public async Task<SystemStatus> GetStatus() public async Task<SystemStatus> GetStatus()
{ {
return await BaseUrl() return await _service.Request("system", "status")
.AppendPathSegment("system/status")
.GetJsonAsync<SystemStatus>(); .GetJsonAsync<SystemStatus>();
} }
private Url BaseUrl() => _serverInfo.BuildRequest();
} }

Loading…
Cancel
Save