mirror of
https://github.com/Artemis-RGB/Artemis
synced 2026-01-01 18:23:32 +00:00
Web API - Added end points API
Web API - Return unhandled exceptions in JSON Plugin end points - Made built-in end points async
This commit is contained in:
parent
fe847ad8f4
commit
46958338b9
@ -65,6 +65,7 @@
|
|||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cstorage/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cstorage/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cstorage_005Cinterfaces/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cstorage_005Cinterfaces/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cwebserver/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cwebserver/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cwebserver_005Ccontrollers/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cwebserver_005Cendpoints/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cwebserver_005Cendpoints/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cwebserver_005Cinterfaces/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services_005Cwebserver_005Cinterfaces/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=stores/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=stores/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using EmbedIO;
|
||||||
|
using EmbedIO.Routing;
|
||||||
|
using EmbedIO.WebApi;
|
||||||
|
|
||||||
|
namespace Artemis.Core.Services
|
||||||
|
{
|
||||||
|
internal class PluginsController : WebApiController
|
||||||
|
{
|
||||||
|
private readonly IWebServerService _webServerService;
|
||||||
|
|
||||||
|
public PluginsController(IWebServerService webServerService)
|
||||||
|
{
|
||||||
|
_webServerService = webServerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route(HttpVerbs.Get, "/plugins/endpoints")]
|
||||||
|
public IReadOnlyCollection<PluginEndPoint> GetPluginEndPoints()
|
||||||
|
{
|
||||||
|
return _webServerService.PluginsModule.PluginEndPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Route(HttpVerbs.Get, "/plugins/endpoints/{plugin}/{endPoint}")]
|
||||||
|
public PluginEndPoint GetPluginEndPoint(Guid plugin, string endPoint)
|
||||||
|
{
|
||||||
|
PluginEndPoint? pluginEndPoint = _webServerService.PluginsModule.PluginEndPoints.FirstOrDefault(e => e.PluginFeature.Plugin.Guid == plugin && e.Name == endPoint);
|
||||||
|
if (pluginEndPoint == null)
|
||||||
|
throw HttpException.NotFound();
|
||||||
|
|
||||||
|
return pluginEndPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using EmbedIO;
|
using EmbedIO;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
@ -19,12 +20,15 @@ namespace Artemis.Core.Services
|
|||||||
{
|
{
|
||||||
_requestHandler = requestHandler;
|
_requestHandler = requestHandler;
|
||||||
ThrowOnFail = true;
|
ThrowOnFail = true;
|
||||||
|
Accepts = MimeType.Json;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal JsonPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func<T, object?> responseRequestHandler) : base(pluginFeature, name, pluginsModule)
|
internal JsonPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func<T, object?> responseRequestHandler) : base(pluginFeature, name, pluginsModule)
|
||||||
{
|
{
|
||||||
_responseRequestHandler = responseRequestHandler;
|
_responseRequestHandler = responseRequestHandler;
|
||||||
ThrowOnFail = true;
|
ThrowOnFail = true;
|
||||||
|
Accepts = MimeType.Json;
|
||||||
|
Returns = MimeType.Json;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -37,7 +41,7 @@ namespace Artemis.Core.Services
|
|||||||
#region Overrides of PluginEndPoint
|
#region Overrides of PluginEndPoint
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
internal override void ProcessRequest(IHttpContext context)
|
protected override async Task ProcessRequest(IHttpContext context)
|
||||||
{
|
{
|
||||||
if (context.Request.HttpVerb != HttpVerbs.Post)
|
if (context.Request.HttpVerb != HttpVerbs.Post)
|
||||||
throw HttpException.MethodNotAllowed("This end point only accepts POST calls");
|
throw HttpException.MethodNotAllowed("This end point only accepts POST calls");
|
||||||
@ -48,7 +52,7 @@ namespace Artemis.Core.Services
|
|||||||
object? response = null;
|
object? response = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
T deserialized = JsonConvert.DeserializeObject<T>(reader.ReadToEnd());
|
T deserialized = JsonConvert.DeserializeObject<T>(await reader.ReadToEndAsync());
|
||||||
|
|
||||||
if (_requestHandler != null)
|
if (_requestHandler != null)
|
||||||
{
|
{
|
||||||
@ -67,8 +71,8 @@ namespace Artemis.Core.Services
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
using TextWriter writer = context.OpenResponseText();
|
await using TextWriter writer = context.OpenResponseText();
|
||||||
writer.Write(JsonConvert.SerializeObject(response));
|
await writer.WriteAsync(JsonConvert.SerializeObject(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using EmbedIO;
|
using EmbedIO;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Artemis.Core.Services
|
namespace Artemis.Core.Services
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a base type for plugin end points to be targeted by the <see cref="PluginsModule" />
|
||||||
|
/// </summary>
|
||||||
public abstract class PluginEndPoint
|
public abstract class PluginEndPoint
|
||||||
{
|
{
|
||||||
private readonly PluginsModule _pluginsModule;
|
private readonly PluginsModule _pluginsModule;
|
||||||
@ -16,11 +21,6 @@ namespace Artemis.Core.Services
|
|||||||
PluginFeature.Disabled += OnDisabled;
|
PluginFeature.Disabled += OnDisabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the plugin the data model is associated with
|
|
||||||
/// </summary>
|
|
||||||
public PluginFeature PluginFeature { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the name of the end point
|
/// Gets the name of the end point
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -29,9 +29,39 @@ namespace Artemis.Core.Services
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the full URL of the end point
|
/// Gets the full URL of the end point
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Url => $"{_pluginsModule.BaseRoute}{PluginFeature.Plugin.Guid}/{Name}";
|
public string Url => $"{_pluginsModule.ServerUrl.TrimEnd('/')}{_pluginsModule.BaseRoute}{PluginFeature.Plugin.Guid}/{Name}";
|
||||||
|
|
||||||
internal abstract void ProcessRequest(IHttpContext context);
|
/// <summary>
|
||||||
|
/// Gets the plugin the end point is associated with
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public PluginFeature PluginFeature { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the plugin info of the plugin the end point is associated with
|
||||||
|
/// </summary>
|
||||||
|
public PluginInfo PluginInfo => PluginFeature.Plugin.Info;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the mime type of the input this end point accepts
|
||||||
|
/// </summary>
|
||||||
|
public string Accepts { get; protected set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the mime type of the output this end point returns
|
||||||
|
/// </summary>
|
||||||
|
public string Returns { get; protected set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called whenever the end point has to process a request
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The HTTP context of the request</param>
|
||||||
|
protected abstract Task ProcessRequest(IHttpContext context);
|
||||||
|
|
||||||
|
internal async Task InternalProcessRequest(IHttpContext context)
|
||||||
|
{
|
||||||
|
await ProcessRequest(context);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDisabled(object? sender, EventArgs e)
|
private void OnDisabled(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using EmbedIO;
|
using EmbedIO;
|
||||||
|
|
||||||
namespace Artemis.Core.Services
|
namespace Artemis.Core.Services
|
||||||
@ -13,7 +14,7 @@ namespace Artemis.Core.Services
|
|||||||
public class RawPluginEndPoint : PluginEndPoint
|
public class RawPluginEndPoint : PluginEndPoint
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
internal RawPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Action<IHttpContext> requestHandler) : base(pluginFeature, name, pluginsModule)
|
internal RawPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func<IHttpContext, Task> requestHandler) : base(pluginFeature, name, pluginsModule)
|
||||||
{
|
{
|
||||||
RequestHandler = requestHandler;
|
RequestHandler = requestHandler;
|
||||||
}
|
}
|
||||||
@ -21,14 +22,30 @@ namespace Artemis.Core.Services
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the handler used to handle incoming requests to this endpoint
|
/// Gets or sets the handler used to handle incoming requests to this endpoint
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Action<IHttpContext> RequestHandler { get; }
|
public Func<IHttpContext, Task> RequestHandler { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the mime type this plugin end point accepts
|
||||||
|
/// </summary>
|
||||||
|
public void SetAcceptType(string type)
|
||||||
|
{
|
||||||
|
Accepts = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the mime type this plugin end point returns
|
||||||
|
/// </summary>
|
||||||
|
public void SetReturnType(string type)
|
||||||
|
{
|
||||||
|
Returns = type;
|
||||||
|
}
|
||||||
|
|
||||||
#region Overrides of PluginEndPoint
|
#region Overrides of PluginEndPoint
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
internal override void ProcessRequest(IHttpContext context)
|
protected override async Task ProcessRequest(IHttpContext context)
|
||||||
{
|
{
|
||||||
RequestHandler(context);
|
await RequestHandler(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using EmbedIO;
|
using EmbedIO;
|
||||||
|
|
||||||
namespace Artemis.Core.Services
|
namespace Artemis.Core.Services
|
||||||
@ -16,17 +17,20 @@ namespace Artemis.Core.Services
|
|||||||
internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Action<string> requestHandler) : base(pluginFeature, name, pluginsModule)
|
internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Action<string> requestHandler) : base(pluginFeature, name, pluginsModule)
|
||||||
{
|
{
|
||||||
_requestHandler = requestHandler;
|
_requestHandler = requestHandler;
|
||||||
|
Accepts = MimeType.PlainText;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func<string, string?> requestHandler) : base(pluginFeature, name, pluginsModule)
|
internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func<string, string?> requestHandler) : base(pluginFeature, name, pluginsModule)
|
||||||
{
|
{
|
||||||
_responseRequestHandler = requestHandler;
|
_responseRequestHandler = requestHandler;
|
||||||
|
Accepts = MimeType.PlainText;
|
||||||
|
Returns = MimeType.PlainText;
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Overrides of PluginEndPoint
|
#region Overrides of PluginEndPoint
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
internal override void ProcessRequest(IHttpContext context)
|
protected override async Task ProcessRequest(IHttpContext context)
|
||||||
{
|
{
|
||||||
if (context.Request.HttpVerb != HttpVerbs.Post)
|
if (context.Request.HttpVerb != HttpVerbs.Post)
|
||||||
throw HttpException.MethodNotAllowed("This end point only accepts POST calls");
|
throw HttpException.MethodNotAllowed("This end point only accepts POST calls");
|
||||||
@ -37,17 +41,17 @@ namespace Artemis.Core.Services
|
|||||||
string? response;
|
string? response;
|
||||||
if (_requestHandler != null)
|
if (_requestHandler != null)
|
||||||
{
|
{
|
||||||
_requestHandler(reader.ReadToEnd());
|
_requestHandler(await reader.ReadToEndAsync());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (_responseRequestHandler != null)
|
if (_responseRequestHandler != null)
|
||||||
response = _responseRequestHandler(reader.ReadToEnd());
|
response = _responseRequestHandler(await reader.ReadToEndAsync());
|
||||||
else
|
else
|
||||||
throw new ArtemisCoreException("String plugin end point has no request handler");
|
throw new ArtemisCoreException("String plugin end point has no request handler");
|
||||||
|
|
||||||
using TextWriter writer = context.OpenResponseText();
|
await using TextWriter writer = context.OpenResponseText();
|
||||||
writer.Write(response);
|
await writer.WriteAsync(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using EmbedIO;
|
using EmbedIO;
|
||||||
using EmbedIO.WebApi;
|
using EmbedIO.WebApi;
|
||||||
|
|
||||||
@ -10,10 +11,15 @@ namespace Artemis.Core.Services
|
|||||||
public interface IWebServerService : IArtemisService
|
public interface IWebServerService : IArtemisService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the currently active instance of the web server
|
/// Gets the current instance of the web server, replaced when <see cref="WebServerStarting" /> occurs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
WebServer? Server { get; }
|
WebServer? Server { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the plugins module containing all plugin end points
|
||||||
|
/// </summary>
|
||||||
|
PluginsModule PluginsModule { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a new endpoint for the given plugin feature receiving an object of type <typeparamref name="T" />
|
/// Adds a new endpoint for the given plugin feature receiving an object of type <typeparamref name="T" />
|
||||||
/// <para>Note: Object will be deserialized using JSON.</para>
|
/// <para>Note: Object will be deserialized using JSON.</para>
|
||||||
@ -67,7 +73,7 @@ namespace Artemis.Core.Services
|
|||||||
/// <param name="endPointName">The name of the end point, must be unique</param>
|
/// <param name="endPointName">The name of the end point, must be unique</param>
|
||||||
/// <param name="requestHandler"></param>
|
/// <param name="requestHandler"></param>
|
||||||
/// <returns>The resulting end point</returns>
|
/// <returns>The resulting end point</returns>
|
||||||
RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Action<IHttpContext> requestHandler);
|
RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Func<IHttpContext, Task> requestHandler);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes an existing endpoint
|
/// Removes an existing endpoint
|
||||||
@ -88,8 +94,8 @@ namespace Artemis.Core.Services
|
|||||||
void RemoveController<T>() where T : WebApiController;
|
void RemoveController<T>() where T : WebApiController;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Occurs when a new instance of the web server was been created
|
/// Occurs when the web server has been created and is about to start. This is the ideal place to add your own modules.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
event EventHandler? WebServerCreated;
|
event EventHandler? WebServerStarting;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using EmbedIO;
|
using EmbedIO;
|
||||||
@ -7,18 +8,20 @@ using Newtonsoft.Json;
|
|||||||
|
|
||||||
namespace Artemis.Core.Services
|
namespace Artemis.Core.Services
|
||||||
{
|
{
|
||||||
internal class PluginsModule : WebModuleBase
|
/// <summary>
|
||||||
|
/// Represents an EmbedIO web module used to process web requests and forward them to the right
|
||||||
|
/// <see cref="PluginEndPoint" />.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginsModule : WebModuleBase
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, Dictionary<string, PluginEndPoint>> _pluginEndPoints;
|
private readonly Dictionary<string, Dictionary<string, PluginEndPoint>> _pluginEndPoints;
|
||||||
|
|
||||||
/// <inheritdoc />
|
internal PluginsModule(string baseRoute) : base(baseRoute)
|
||||||
public PluginsModule(string baseRoute) : base(baseRoute)
|
|
||||||
{
|
{
|
||||||
_pluginEndPoints = new Dictionary<string, Dictionary<string, PluginEndPoint>>();
|
_pluginEndPoints = new Dictionary<string, Dictionary<string, PluginEndPoint>>();
|
||||||
OnUnhandledException += HandleUnhandledExceptionJson;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddPluginEndPoint(PluginEndPoint registration)
|
internal void AddPluginEndPoint(PluginEndPoint registration)
|
||||||
{
|
{
|
||||||
string id = registration.PluginFeature.Plugin.Guid.ToString();
|
string id = registration.PluginFeature.Plugin.Guid.ToString();
|
||||||
if (!_pluginEndPoints.TryGetValue(id, out Dictionary<string, PluginEndPoint>? registrations))
|
if (!_pluginEndPoints.TryGetValue(id, out Dictionary<string, PluginEndPoint>? registrations))
|
||||||
@ -32,7 +35,7 @@ namespace Artemis.Core.Services
|
|||||||
registrations.Add(registration.Name, registration);
|
registrations.Add(registration.Name, registration);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RemovePluginEndPoint(PluginEndPoint registration)
|
internal void RemovePluginEndPoint(PluginEndPoint registration)
|
||||||
{
|
{
|
||||||
string id = registration.PluginFeature.Plugin.Guid.ToString();
|
string id = registration.PluginFeature.Plugin.Guid.ToString();
|
||||||
if (!_pluginEndPoints.TryGetValue(id, out Dictionary<string, PluginEndPoint>? registrations))
|
if (!_pluginEndPoints.TryGetValue(id, out Dictionary<string, PluginEndPoint>? registrations))
|
||||||
@ -42,15 +45,6 @@ namespace Artemis.Core.Services
|
|||||||
registrations.Remove(registration.Name);
|
registrations.Remove(registration.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleUnhandledExceptionJson(IHttpContext context, Exception exception)
|
|
||||||
{
|
|
||||||
await context.SendStringAsync(
|
|
||||||
JsonConvert.SerializeObject(new ArtemisPluginException("The plugin failed to process the request", exception), Formatting.Indented),
|
|
||||||
MimeType.Json,
|
|
||||||
Encoding.UTF8
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Overrides of WebModuleBase
|
#region Overrides of WebModuleBase
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@ -74,7 +68,7 @@ namespace Artemis.Core.Services
|
|||||||
throw HttpException.NotFound($"Found no endpoint called {pathParts[1]} for plugin with ID {pathParts[0]}.");
|
throw HttpException.NotFound($"Found no endpoint called {pathParts[1]} for plugin with ID {pathParts[0]}.");
|
||||||
|
|
||||||
// It is up to the registration how the request is eventually handled, it might even set a response here
|
// It is up to the registration how the request is eventually handled, it might even set a response here
|
||||||
endPoint.ProcessRequest(context);
|
await endPoint.InternalProcessRequest(context);
|
||||||
|
|
||||||
// No need to return ourselves, assume the request is fully handled by the end point
|
// No need to return ourselves, assume the request is fully handled by the end point
|
||||||
context.SetHandled();
|
context.SetHandled();
|
||||||
@ -83,6 +77,13 @@ namespace Artemis.Core.Services
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override bool IsFinalHandler => true;
|
public override bool IsFinalHandler => true;
|
||||||
|
|
||||||
|
internal string? ServerUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a read only collection containing all current plugin end points
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<PluginEndPoint> PluginEndPoints => new List<PluginEndPoint>(_pluginEndPoints.SelectMany(p => p.Value.Values));
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -16,7 +16,6 @@ namespace Artemis.Core.Services
|
|||||||
private readonly List<WebApiControllerRegistration> _controllers;
|
private readonly List<WebApiControllerRegistration> _controllers;
|
||||||
private readonly IKernel _kernel;
|
private readonly IKernel _kernel;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly PluginsModule _pluginModule;
|
|
||||||
private readonly PluginSetting<int> _webServerPortSetting;
|
private readonly PluginSetting<int> _webServerPortSetting;
|
||||||
|
|
||||||
public WebServerService(IKernel kernel, ILogger logger, ISettingsService settingsService)
|
public WebServerService(IKernel kernel, ILogger logger, ISettingsService settingsService)
|
||||||
@ -28,17 +27,182 @@ namespace Artemis.Core.Services
|
|||||||
_webServerPortSetting = settingsService.GetSetting("WebServer.Port", 9696);
|
_webServerPortSetting = settingsService.GetSetting("WebServer.Port", 9696);
|
||||||
_webServerPortSetting.SettingChanged += WebServerPortSettingOnSettingChanged;
|
_webServerPortSetting.SettingChanged += WebServerPortSettingOnSettingChanged;
|
||||||
|
|
||||||
_pluginModule = new PluginsModule("/plugin");
|
PluginsModule = new PluginsModule("/plugins");
|
||||||
|
|
||||||
|
StartWebServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebServer? Server { get; private set; }
|
||||||
|
public PluginsModule PluginsModule { get; }
|
||||||
|
|
||||||
|
#region Web server managament
|
||||||
|
|
||||||
|
private WebServer CreateWebServer()
|
||||||
|
{
|
||||||
|
Server?.Dispose();
|
||||||
|
Server = null;
|
||||||
|
|
||||||
|
string url = $"http://localhost:{_webServerPortSetting.Value}/";
|
||||||
|
WebApiModule apiModule = new("/api/", JsonNetSerializer);
|
||||||
|
PluginsModule.ServerUrl = url;
|
||||||
|
WebServer server = new WebServer(o => o.WithUrlPrefix(url).WithMode(HttpListenerMode.EmbedIO))
|
||||||
|
.WithLocalSessionManager()
|
||||||
|
.WithModule(apiModule)
|
||||||
|
.WithModule(PluginsModule)
|
||||||
|
.HandleHttpException((context, exception) => HandleHttpExceptionJson(context, exception))
|
||||||
|
.HandleUnhandledException(JsonExceptionHandlerCallback);
|
||||||
|
|
||||||
|
// Add built-in core controllers to the API module
|
||||||
|
apiModule.RegisterController(() => _kernel.Get<PluginsController>());
|
||||||
|
// Add registered controllers to the API module
|
||||||
|
foreach (WebApiControllerRegistration registration in _controllers)
|
||||||
|
apiModule.RegisterController(registration.ControllerType, (Func<WebApiController>) registration.UntypedFactory);
|
||||||
|
|
||||||
|
// Listen for state changes.
|
||||||
|
server.StateChanged += (s, e) => _logger.Verbose("WebServer new state - {state}", e.NewState);
|
||||||
|
|
||||||
|
// Store the URL in a webserver.txt file so that remote applications can find it
|
||||||
|
File.WriteAllText(Path.Combine(Constants.DataFolder, "webserver.txt"), url);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartWebServer()
|
||||||
|
{
|
||||||
Server = CreateWebServer();
|
Server = CreateWebServer();
|
||||||
|
OnWebServerStarting();
|
||||||
Server.Start();
|
Server.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Plugin endpoint management
|
||||||
|
|
||||||
|
public JsonPluginEndPoint<T> AddJsonEndPoint<T>(PluginFeature feature, string endPointName, Action<T> requestHandler)
|
||||||
|
{
|
||||||
|
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
||||||
|
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
||||||
|
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
||||||
|
JsonPluginEndPoint<T> endPoint = new(feature, endPointName, PluginsModule, requestHandler);
|
||||||
|
PluginsModule.AddPluginEndPoint(endPoint);
|
||||||
|
return endPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonPluginEndPoint<T> AddResponsiveJsonEndPoint<T>(PluginFeature feature, string endPointName, Func<T, object?> requestHandler)
|
||||||
|
{
|
||||||
|
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
||||||
|
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
||||||
|
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
||||||
|
JsonPluginEndPoint<T> endPoint = new(feature, endPointName, PluginsModule, requestHandler);
|
||||||
|
PluginsModule.AddPluginEndPoint(endPoint);
|
||||||
|
return endPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StringPluginEndPoint AddStringEndPoint(PluginFeature feature, string endPointName, Action<string> requestHandler)
|
||||||
|
{
|
||||||
|
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
||||||
|
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
||||||
|
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
||||||
|
StringPluginEndPoint endPoint = new(feature, endPointName, PluginsModule, requestHandler);
|
||||||
|
PluginsModule.AddPluginEndPoint(endPoint);
|
||||||
|
return endPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StringPluginEndPoint AddResponsiveStringEndPoint(PluginFeature feature, string endPointName, Func<string, string?> requestHandler)
|
||||||
|
{
|
||||||
|
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
||||||
|
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
||||||
|
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
||||||
|
StringPluginEndPoint endPoint = new(feature, endPointName, PluginsModule, requestHandler);
|
||||||
|
PluginsModule.AddPluginEndPoint(endPoint);
|
||||||
|
return endPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Func<IHttpContext, Task> requestHandler)
|
||||||
|
{
|
||||||
|
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
||||||
|
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
||||||
|
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
||||||
|
RawPluginEndPoint endPoint = new(feature, endPointName, PluginsModule, requestHandler);
|
||||||
|
PluginsModule.AddPluginEndPoint(endPoint);
|
||||||
|
return endPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemovePluginEndPoint(PluginEndPoint endPoint)
|
||||||
|
{
|
||||||
|
PluginsModule.RemovePluginEndPoint(endPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Controller management
|
||||||
|
|
||||||
|
public void AddController<T>() where T : WebApiController
|
||||||
|
{
|
||||||
|
_controllers.Add(new WebApiControllerRegistration<T>(_kernel));
|
||||||
|
StartWebServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveController<T>() where T : WebApiController
|
||||||
|
{
|
||||||
|
_controllers.RemoveAll(r => r.ControllerType == typeof(T));
|
||||||
|
StartWebServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Handlers
|
||||||
|
|
||||||
|
private async Task JsonExceptionHandlerCallback(IHttpContext context, Exception exception)
|
||||||
|
{
|
||||||
|
context.Response.ContentType = MimeType.Json;
|
||||||
|
await using TextWriter writer = context.OpenResponseText();
|
||||||
|
|
||||||
|
string response = JsonConvert.SerializeObject(new Dictionary<string, object?>()
|
||||||
|
{
|
||||||
|
{"StatusCode", context.Response.StatusCode},
|
||||||
|
{"StackTrace", exception.StackTrace},
|
||||||
|
{"Type", exception.GetType().FullName},
|
||||||
|
{"Message", exception.Message},
|
||||||
|
{"Data", exception.Data},
|
||||||
|
{"InnerException", exception.InnerException},
|
||||||
|
{"HelpLink", exception.HelpLink},
|
||||||
|
{"Source", exception.Source},
|
||||||
|
{"HResult", exception.HResult}
|
||||||
|
});
|
||||||
|
await writer.WriteAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task JsonNetSerializer(IHttpContext context, object? data)
|
||||||
|
{
|
||||||
|
context.Response.ContentType = MimeType.Json;
|
||||||
|
await using TextWriter writer = context.OpenResponseText();
|
||||||
|
await writer.WriteAsync(JsonConvert.SerializeObject(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleHttpExceptionJson(IHttpContext context, IHttpException httpException)
|
||||||
|
{
|
||||||
|
await context.SendStringAsync(JsonConvert.SerializeObject(httpException, Formatting.Indented), MimeType.Json, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Events
|
||||||
|
|
||||||
|
public event EventHandler? WebServerStarting;
|
||||||
|
|
||||||
|
protected virtual void OnWebServerStarting()
|
||||||
|
{
|
||||||
|
WebServerStarting?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Event handlers
|
#region Event handlers
|
||||||
|
|
||||||
private void WebServerPortSettingOnSettingChanged(object? sender, EventArgs e)
|
private void WebServerPortSettingOnSettingChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
Server = CreateWebServer();
|
StartWebServer();
|
||||||
Server.Start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@ -53,131 +217,5 @@ namespace Artemis.Core.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
public WebServer? Server { get; private set; }
|
|
||||||
|
|
||||||
#region Web server managament
|
|
||||||
|
|
||||||
private WebServer CreateWebServer()
|
|
||||||
{
|
|
||||||
Server?.Dispose();
|
|
||||||
Server = null;
|
|
||||||
|
|
||||||
string url = $"http://localhost:{_webServerPortSetting.Value}/";
|
|
||||||
WebApiModule apiModule = new("/api/");
|
|
||||||
WebServer server = new WebServer(o => o.WithUrlPrefix(url).WithMode(HttpListenerMode.EmbedIO))
|
|
||||||
.WithLocalSessionManager()
|
|
||||||
.WithModule(apiModule)
|
|
||||||
.WithModule(_pluginModule)
|
|
||||||
.HandleHttpException((context, exception) => HandleHttpExceptionJson(context, exception));
|
|
||||||
|
|
||||||
// Add controllers to the API module
|
|
||||||
foreach (WebApiControllerRegistration registration in _controllers)
|
|
||||||
apiModule.RegisterController(registration.ControllerType, (Func<WebApiController>) registration.UntypedFactory);
|
|
||||||
|
|
||||||
// Listen for state changes.
|
|
||||||
server.StateChanged += (s, e) => _logger.Verbose("WebServer new state - {state}", e.NewState);
|
|
||||||
|
|
||||||
// Store the URL in a webserver.txt file so that remote applications can find it
|
|
||||||
File.WriteAllText(Path.Combine(Constants.DataFolder, "webserver.txt"), url);
|
|
||||||
OnWebServerCreated();
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleHttpExceptionJson(IHttpContext context, IHttpException httpException)
|
|
||||||
{
|
|
||||||
await context.SendStringAsync(JsonConvert.SerializeObject(httpException, Formatting.Indented), MimeType.Json, Encoding.UTF8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Plugin endpoint management
|
|
||||||
|
|
||||||
public JsonPluginEndPoint<T> AddJsonEndPoint<T>(PluginFeature feature, string endPointName, Action<T> requestHandler)
|
|
||||||
{
|
|
||||||
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
|
||||||
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
|
||||||
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
|
||||||
JsonPluginEndPoint<T> endPoint = new(feature, endPointName, _pluginModule, requestHandler);
|
|
||||||
_pluginModule.AddPluginEndPoint(endPoint);
|
|
||||||
return endPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public JsonPluginEndPoint<T> AddResponsiveJsonEndPoint<T>(PluginFeature feature, string endPointName, Func<T, object?> requestHandler)
|
|
||||||
{
|
|
||||||
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
|
||||||
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
|
||||||
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
|
||||||
JsonPluginEndPoint<T> endPoint = new(feature, endPointName, _pluginModule, requestHandler);
|
|
||||||
_pluginModule.AddPluginEndPoint(endPoint);
|
|
||||||
return endPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StringPluginEndPoint AddStringEndPoint(PluginFeature feature, string endPointName, Action<string> requestHandler)
|
|
||||||
{
|
|
||||||
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
|
||||||
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
|
||||||
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
|
||||||
StringPluginEndPoint endPoint = new(feature, endPointName, _pluginModule, requestHandler);
|
|
||||||
_pluginModule.AddPluginEndPoint(endPoint);
|
|
||||||
return endPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StringPluginEndPoint AddResponsiveStringEndPoint(PluginFeature feature, string endPointName, Func<string, string?> requestHandler)
|
|
||||||
{
|
|
||||||
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
|
||||||
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
|
||||||
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
|
||||||
StringPluginEndPoint endPoint = new(feature, endPointName, _pluginModule, requestHandler);
|
|
||||||
_pluginModule.AddPluginEndPoint(endPoint);
|
|
||||||
return endPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Action<IHttpContext> requestHandler)
|
|
||||||
{
|
|
||||||
if (feature == null) throw new ArgumentNullException(nameof(feature));
|
|
||||||
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
|
|
||||||
if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler));
|
|
||||||
RawPluginEndPoint endPoint = new(feature, endPointName, _pluginModule, requestHandler);
|
|
||||||
_pluginModule.AddPluginEndPoint(endPoint);
|
|
||||||
return endPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemovePluginEndPoint(PluginEndPoint endPoint)
|
|
||||||
{
|
|
||||||
_pluginModule.RemovePluginEndPoint(endPoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Controller management
|
|
||||||
|
|
||||||
public void AddController<T>() where T : WebApiController
|
|
||||||
{
|
|
||||||
_controllers.Add(new WebApiControllerRegistration<T>(_kernel));
|
|
||||||
Server = CreateWebServer();
|
|
||||||
Server.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveController<T>() where T : WebApiController
|
|
||||||
{
|
|
||||||
_controllers.RemoveAll(r => r.ControllerType == typeof(T));
|
|
||||||
Server = CreateWebServer();
|
|
||||||
Server.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Events
|
|
||||||
|
|
||||||
public event EventHandler? WebServerCreated;
|
|
||||||
|
|
||||||
protected virtual void OnWebServerCreated()
|
|
||||||
{
|
|
||||||
WebServerCreated?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,6 +195,33 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</materialDesign:Card>
|
</materialDesign:Card>
|
||||||
|
|
||||||
|
<!-- Web server settings -->
|
||||||
|
<TextBlock Style="{StaticResource MaterialDesignHeadline5TextBlock}" Margin="0 15">Web server</TextBlock>
|
||||||
|
<materialDesign:Card materialDesign:ShadowAssist.ShadowDepth="Depth1" VerticalAlignment="Stretch" Margin="0,0,5,0">
|
||||||
|
<StackPanel Margin="15">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition />
|
||||||
|
<RowDefinition />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Style="{StaticResource MaterialDesignTextBlock}">Web server port</TextBlock>
|
||||||
|
<TextBlock Style="{StaticResource MaterialDesignTextBlock}" Foreground="{DynamicResource MaterialDesignNavigationItemSubheader}" TextWrapping="Wrap">
|
||||||
|
Artemis runs a local web server that can be used to externally interact with the application. <LineBreak />
|
||||||
|
This web server can only be accessed by applications running on your own computer, e.g. supported games.
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<TextBox Text="{Binding WebServerPortSetting.Value}" Width="80" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</materialDesign:Card>
|
||||||
|
|
||||||
<!-- Update settings -->
|
<!-- Update settings -->
|
||||||
<TextBlock Style="{StaticResource MaterialDesignHeadline5TextBlock}" Margin="0 15">Updating</TextBlock>
|
<TextBlock Style="{StaticResource MaterialDesignHeadline5TextBlock}" Margin="0 15">Updating</TextBlock>
|
||||||
<materialDesign:Card materialDesign:ShadowAssist.ShadowDepth="Depth1" VerticalAlignment="Stretch" Margin="0,0,5,0">
|
<materialDesign:Card materialDesign:ShadowAssist.ShadowDepth="Depth1" VerticalAlignment="Stretch" Margin="0,0,5,0">
|
||||||
|
|||||||
@ -79,6 +79,9 @@ namespace Artemis.UI.Screens.Settings.Tabs.General
|
|||||||
LayerBrushProviderId = "Artemis.Plugins.LayerBrushes.Color.ColorBrushProvider-92a9d6ba",
|
LayerBrushProviderId = "Artemis.Plugins.LayerBrushes.Color.ColorBrushProvider-92a9d6ba",
|
||||||
BrushType = "ColorBrush"
|
BrushType = "ColorBrush"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
WebServerPortSetting = _settingsService.GetSetting("WebServer.Port", 9696);
|
||||||
|
WebServerPortSetting.AutoSave = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BindableCollection<LayerBrushDescriptor> LayerBrushDescriptors { get; }
|
public BindableCollection<LayerBrushDescriptor> LayerBrushDescriptors { get; }
|
||||||
@ -234,6 +237,8 @@ namespace Artemis.UI.Screens.Settings.Tabs.General
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PluginSetting<int> WebServerPortSetting { get; }
|
||||||
|
|
||||||
public bool CanOfferUpdatesIfFound
|
public bool CanOfferUpdatesIfFound
|
||||||
{
|
{
|
||||||
get => _canOfferUpdatesIfFound;
|
get => _canOfferUpdatesIfFound;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user