1
0
mirror of https://github.com/Artemis-RGB/Artemis synced 2025-12-12 13:28:33 +00:00

Core - Replace EmbedIO with GenHTTP

Replace EmbedIO with GenHTTP
This commit is contained in:
RobertBeekman 2025-02-15 16:53:05 +01:00 committed by GitHub
commit 17e6c655ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 304 additions and 459 deletions

View File

@ -60,7 +60,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
dotnet-version: '9.0.x'
- name: Publish Artemis
run: dotnet publish --configuration Release -p:Version=${{ needs.version.outputs.version-number }} --runtime ${{ matrix.rid }} --output build/${{ matrix.rid }} --self-contained Artemis/src/Artemis.UI.${{ matrix.csproj }}/Artemis.UI.${{ matrix.csproj }}.csproj
- name: Publish Plugins

View File

@ -37,7 +37,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
dotnet-version: '9.0.x'
- name: Checkout
uses: actions/checkout@v4
- name: Pack Artemis.Core

View File

@ -37,7 +37,8 @@
<ItemGroup>
<PackageReference Include="DryIoc.dll" />
<PackageReference Include="EmbedIO" />
<PackageReference Include="GenHTTP.Core" />
<PackageReference Include="GenHTTP.Modules.Webservices" />
<PackageReference Include="HidSharp" />
<PackageReference Include="HPPH.SkiaSharp" />
<PackageReference Include="Humanizer.Core" />

View File

@ -1,84 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Artemis.Core.Modules;
using EmbedIO;
namespace Artemis.Core.Services;
/// <summary>
/// Represents a plugin web endpoint receiving an object of type <typeparamref name="T" /> and returning any
/// <see cref="object" /> or <see langword="null" />.
/// <para>Note: Both will be deserialized and serialized respectively using JSON.</para>
/// </summary>
public class DataModelJsonPluginEndPoint<T> : PluginEndPoint where T : DataModel, new()
{
private readonly Module<T> _module;
private readonly Action<T, T> _update;
internal DataModelJsonPluginEndPoint(Module<T> module, string name, PluginsModule pluginsModule) : base(module, name, pluginsModule)
{
_module = module ?? throw new ArgumentNullException(nameof(module));
_update = CreateUpdateAction();
ThrowOnFail = true;
Accepts = MimeType.Json;
}
/// <summary>
/// Whether or not the end point should throw an exception if deserializing the received JSON fails.
/// If set to <see langword="false" /> malformed JSON is silently ignored; if set to <see langword="true" /> malformed
/// JSON throws a <see cref="JsonException" />.
/// </summary>
public bool ThrowOnFail { get; set; }
/// <inheritdoc />
protected override async Task ProcessRequest(IHttpContext context)
{
if (context.Request.HttpVerb != HttpVerbs.Post && context.Request.HttpVerb != HttpVerbs.Put)
throw HttpException.MethodNotAllowed("This end point only accepts POST and PUT calls");
context.Response.ContentType = MimeType.Json;
using TextReader reader = context.OpenRequestText();
try
{
T? dataModel = CoreJson.Deserialize<T>(await reader.ReadToEndAsync());
if (dataModel != null)
_update(dataModel, _module.DataModel);
}
catch (JsonException)
{
if (ThrowOnFail)
throw;
}
}
private Action<T, T> CreateUpdateAction()
{
ParameterExpression sourceParameter = Expression.Parameter(typeof(T), "source");
ParameterExpression targetParameter = Expression.Parameter(typeof(T), "target");
IEnumerable<BinaryExpression> assignments = typeof(T)
.GetProperties()
.Where(prop => prop.CanWrite && prop.GetSetMethod() != null &&
prop.GetSetMethod()!.IsPublic &&
!prop.IsDefined(typeof(JsonIgnoreAttribute), false) &&
!prop.PropertyType.IsAssignableTo(typeof(IDataModelEvent)))
.Select(prop =>
{
MemberExpression sourceProperty = Expression.Property(sourceParameter, prop);
MemberExpression targetProperty = Expression.Property(targetParameter, prop);
return Expression.Assign(targetProperty, sourceProperty);
});
BlockExpression body = Expression.Block(assignments);
return Expression.Lambda<Action<T, T>>(body, sourceParameter, targetParameter).Compile();
}
}

View File

@ -1,5 +1,5 @@
using System;
using EmbedIO;
using GenHTTP.Api.Protocol;
namespace Artemis.Core.Services;
@ -8,13 +8,13 @@ namespace Artemis.Core.Services;
/// </summary>
public class EndpointRequestEventArgs : EventArgs
{
internal EndpointRequestEventArgs(IHttpContext context)
internal EndpointRequestEventArgs(IRequest request)
{
Context = context;
Request = request;
}
/// <summary>
/// Gets the HTTP context of the request
/// </summary>
public IHttpContext Context { get; }
public IRequest Request { get; }
}

View File

@ -1,8 +1,9 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using EmbedIO;
using GenHTTP.Api.Protocol;
using GenHTTP.Modules.Basics;
using GenHTTP.Modules.Conversion.Serializers.Json;
namespace Artemis.Core.Services;
@ -16,19 +17,19 @@ public class JsonPluginEndPoint<T> : PluginEndPoint
private readonly Action<T>? _requestHandler;
private readonly Func<T, object?>? _responseRequestHandler;
internal JsonPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Action<T> requestHandler) : base(pluginFeature, name, pluginsModule)
internal JsonPluginEndPoint(PluginFeature pluginFeature, string name, PluginsHandler pluginsHandler, Action<T> requestHandler) : base(pluginFeature, name, pluginsHandler)
{
_requestHandler = requestHandler;
ThrowOnFail = true;
Accepts = MimeType.Json;
Accepts = FlexibleContentType.Get(ContentType.ApplicationJson);
}
internal JsonPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func<T, object?> responseRequestHandler) : base(pluginFeature, name, pluginsModule)
internal JsonPluginEndPoint(PluginFeature pluginFeature, string name, PluginsHandler pluginsHandler, Func<T, object?> responseRequestHandler) : base(pluginFeature, name, pluginsHandler)
{
_responseRequestHandler = responseRequestHandler;
ThrowOnFail = true;
Accepts = MimeType.Json;
Returns = MimeType.Json;
Accepts = FlexibleContentType.Get(ContentType.ApplicationJson);
Returns = FlexibleContentType.Get(ContentType.ApplicationJson);
}
/// <summary>
@ -41,25 +42,25 @@ public class JsonPluginEndPoint<T> : PluginEndPoint
#region Overrides of PluginEndPoint
/// <inheritdoc />
protected override async Task ProcessRequest(IHttpContext context)
protected override async Task<IResponse> ProcessRequest(IRequest request)
{
if (context.Request.HttpVerb != HttpVerbs.Post && context.Request.HttpVerb != HttpVerbs.Put)
throw HttpException.MethodNotAllowed("This end point only accepts POST and PUT calls");
if (request.Method != RequestMethod.Post && request.Method != RequestMethod.Put)
return request.Respond().Status(ResponseStatus.MethodNotAllowed).Build();
context.Response.ContentType = MimeType.Json;
if (request.Content == null)
return request.Respond().Status(ResponseStatus.BadRequest).Build();
using TextReader reader = context.OpenRequestText();
object? response = null;
try
{
T? deserialized = JsonSerializer.Deserialize<T>(await reader.ReadToEndAsync());
T? deserialized = await JsonSerializer.DeserializeAsync<T>(request.Content, WebServerService.JsonOptions);
if (deserialized == null)
throw new JsonException("Deserialization returned null");
if (_requestHandler != null)
{
_requestHandler(deserialized);
return;
return request.Respond().Status(ResponseStatus.NoContent).Build();
}
if (_responseRequestHandler != null)
@ -73,8 +74,14 @@ public class JsonPluginEndPoint<T> : PluginEndPoint
throw;
}
await using TextWriter writer = context.OpenResponseText();
await writer.WriteAsync(JsonSerializer.Serialize(response));
// TODO: Cache options
if (response == null)
return request.Respond().Status(ResponseStatus.NoContent).Build();
return request.Respond()
.Status(ResponseStatus.Ok)
.Content(new JsonContent(response, WebServerService.JsonOptions))
.Type(ContentType.ApplicationJson)
.Build();
}
#endregion

View File

@ -1,20 +1,24 @@
using System;
using System.Net.Http;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using EmbedIO;
using GenHTTP.Api.Protocol;
using GenHTTP.Modules.Basics;
using GenHTTP.Modules.IO;
using StringContent = GenHTTP.Modules.IO.Strings.StringContent;
namespace Artemis.Core.Services;
/// <summary>
/// Represents a base type for plugin end points to be targeted by the <see cref="PluginsModule" />
/// Represents a base type for plugin end points to be targeted by the <see cref="PluginsHandler" />
/// </summary>
public abstract class PluginEndPoint
{
private readonly PluginsModule _pluginsModule;
private readonly PluginsHandler _pluginsHandler;
internal PluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule)
internal PluginEndPoint(PluginFeature pluginFeature, string name, PluginsHandler pluginsHandler)
{
_pluginsModule = pluginsModule;
_pluginsHandler = pluginsHandler;
PluginFeature = pluginFeature;
Name = name;
@ -29,7 +33,7 @@ public abstract class PluginEndPoint
/// <summary>
/// Gets the full URL of the end point
/// </summary>
public string Url => $"{_pluginsModule.ServerUrl?.TrimEnd('/')}{_pluginsModule.BaseRoute}{PluginFeature.Plugin.Guid}/{Name}";
public string Url => $"{_pluginsHandler.ServerUrl}{_pluginsHandler.BaseRoute}/{PluginFeature.Plugin.Guid}/{Name}";
/// <summary>
/// Gets the plugin the end point is associated with
@ -42,15 +46,15 @@ public abstract class PluginEndPoint
/// </summary>
public PluginInfo PluginInfo => PluginFeature.Plugin.Info;
/// <summary>
/// <summary><summary>
/// Gets the mime type of the input this end point accepts
/// </summary>
public string? Accepts { get; protected set; }
public FlexibleContentType Accepts { get; protected set; }
/// <summary>
/// Gets the mime type of the output this end point returns
/// </summary>
public string? Returns { get; protected set; }
public FlexibleContentType Returns { get; protected set; }
/// <summary>
/// Occurs whenever a request threw an unhandled exception
@ -70,8 +74,8 @@ public abstract class PluginEndPoint
/// <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);
/// <param name="request">The HTTP context of the request</param>
protected abstract Task<IResponse> ProcessRequest(IRequest request);
/// <summary>
/// Invokes the <see cref="RequestException" /> event
@ -85,37 +89,49 @@ public abstract class PluginEndPoint
/// <summary>
/// Invokes the <see cref="ProcessingRequest" /> event
/// </summary>
protected virtual void OnProcessingRequest(IHttpContext context)
protected virtual void OnProcessingRequest(IRequest request)
{
ProcessingRequest?.Invoke(this, new EndpointRequestEventArgs(context));
ProcessingRequest?.Invoke(this, new EndpointRequestEventArgs(request));
}
/// <summary>
/// Invokes the <see cref="ProcessedRequest" /> event
/// </summary>
protected virtual void OnProcessedRequest(IHttpContext context)
protected virtual void OnProcessedRequest(IRequest request)
{
ProcessedRequest?.Invoke(this, new EndpointRequestEventArgs(context));
ProcessedRequest?.Invoke(this, new EndpointRequestEventArgs(request));
}
internal async Task InternalProcessRequest(IHttpContext context)
internal async Task<IResponse> InternalProcessRequest(IRequest context)
{
try
{
OnProcessingRequest(context);
await ProcessRequest(context);
if (!Equals(context.ContentType, Accepts))
{
OnRequestException(new Exception("Unsupported media type"));
return context.Respond().Status(ResponseStatus.UnsupportedMediaType).Build();
}
IResponse response = await ProcessRequest(context);
OnProcessedRequest(context);
return response;
}
catch (Exception e)
{
OnRequestException(e);
throw;
return context.Respond()
.Status(ResponseStatus.InternalServerError)
.Content(new StringContent(e.ToString()))
.Type(ContentType.TextPlain)
.Build();
}
}
private void OnDisabled(object? sender, EventArgs e)
{
PluginFeature.Disabled -= OnDisabled;
_pluginsModule.RemovePluginEndPoint(this);
_pluginsHandler.RemovePluginEndPoint(this);
}
}

View File

@ -1,6 +1,6 @@
using System;
using System.Threading.Tasks;
using EmbedIO;
using GenHTTP.Api.Protocol;
namespace Artemis.Core.Services;
@ -14,7 +14,7 @@ namespace Artemis.Core.Services;
public class RawPluginEndPoint : PluginEndPoint
{
/// <inheritdoc />
internal RawPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func<IHttpContext, Task> requestHandler) : base(pluginFeature, name, pluginsModule)
internal RawPluginEndPoint(PluginFeature pluginFeature, string name, PluginsHandler pluginsHandler, Func<IRequest, Task<IResponse>> requestHandler) : base(pluginFeature, name, pluginsHandler)
{
RequestHandler = requestHandler;
}
@ -22,30 +22,30 @@ public class RawPluginEndPoint : PluginEndPoint
/// <summary>
/// Gets or sets the handler used to handle incoming requests to this endpoint
/// </summary>
public Func<IHttpContext, Task> RequestHandler { get; }
public Func<IRequest, Task<IResponse>> RequestHandler { get; }
/// <summary>
/// Sets the mime type this plugin end point accepts
/// </summary>
public void SetAcceptType(string type)
public void SetAcceptType(ContentType type)
{
Accepts = type;
Accepts = FlexibleContentType.Get(type);
}
/// <summary>
/// Sets the mime type this plugin end point returns
/// </summary>
public void SetReturnType(string type)
public void SetReturnType(ContentType type)
{
Returns = type;
Returns = FlexibleContentType.Get(type);
}
#region Overrides of PluginEndPoint
/// <inheritdoc />
protected override async Task ProcessRequest(IHttpContext context)
protected override async Task<IResponse> ProcessRequest(IRequest request)
{
await RequestHandler(context);
return await RequestHandler(request);
}
#endregion

View File

@ -1,7 +1,9 @@
using System;
using System.IO;
using System.Threading.Tasks;
using EmbedIO;
using GenHTTP.Api.Protocol;
using GenHTTP.Modules.Basics;
using GenHTTP.Modules.IO.Strings;
namespace Artemis.Core.Services;
@ -14,35 +16,37 @@ public class StringPluginEndPoint : PluginEndPoint
private readonly Action<string>? _requestHandler;
private readonly Func<string, string?>? _responseRequestHandler;
internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Action<string> requestHandler) : base(pluginFeature, name, pluginsModule)
internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsHandler pluginsHandler, Action<string> requestHandler) : base(pluginFeature, name, pluginsHandler)
{
_requestHandler = requestHandler;
Accepts = MimeType.PlainText;
Accepts = FlexibleContentType.Get(ContentType.TextPlain);
}
internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func<string, string?> requestHandler) : base(pluginFeature, name, pluginsModule)
internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsHandler pluginsHandler, Func<string, string?> requestHandler) : base(pluginFeature, name, pluginsHandler)
{
_responseRequestHandler = requestHandler;
Accepts = MimeType.PlainText;
Returns = MimeType.PlainText;
Accepts = FlexibleContentType.Get(ContentType.TextPlain);
Returns = FlexibleContentType.Get(ContentType.TextPlain);
}
#region Overrides of PluginEndPoint
/// <inheritdoc />
protected override async Task ProcessRequest(IHttpContext context)
protected override async Task<IResponse> ProcessRequest(IRequest request)
{
if (context.Request.HttpVerb != HttpVerbs.Post && context.Request.HttpVerb != HttpVerbs.Put)
throw HttpException.MethodNotAllowed("This end point only accepts POST and PUT calls");
if (request.Method != RequestMethod.Post && request.Method != RequestMethod.Put)
return request.Respond().Status(ResponseStatus.MethodNotAllowed).Build();
context.Response.ContentType = MimeType.PlainText;
if (request.Content == null)
return request.Respond().Status(ResponseStatus.BadRequest).Build();
using TextReader reader = context.OpenRequestText();
// Read the request as a string
using StreamReader reader = new(request.Content);
string? response;
if (_requestHandler != null)
{
_requestHandler(await reader.ReadToEndAsync());
return;
return request.Respond().Status(ResponseStatus.Ok).Build();
}
if (_responseRequestHandler != null)
@ -50,8 +54,13 @@ public class StringPluginEndPoint : PluginEndPoint
else
throw new ArtemisCoreException("String plugin end point has no request handler");
await using TextWriter writer = context.OpenResponseText();
await writer.WriteAsync(response);
if (response == null)
return request.Respond().Status(ResponseStatus.NoContent).Build();
return request.Respond()
.Status(ResponseStatus.Ok)
.Content(new StringContent(response))
.Type(ContentType.TextPlain)
.Build();
}
#endregion

View File

@ -1,8 +1,8 @@
using System;
using System.Threading.Tasks;
using Artemis.Core.Modules;
using EmbedIO;
using EmbedIO.WebApi;
using GenHTTP.Api.Infrastructure;
using GenHTTP.Api.Protocol;
namespace Artemis.Core.Services;
@ -14,12 +14,12 @@ public interface IWebServerService : IArtemisService
/// <summary>
/// Gets the current instance of the web server, replaced when <see cref="WebServerStarting" /> occurs.
/// </summary>
WebServer? Server { get; }
IServer? Server { get; }
/// <summary>
/// Gets the plugins module containing all plugin end points
/// </summary>
PluginsModule PluginsModule { get; }
PluginsHandler PluginsHandler { get; }
/// <summary>
/// Adds a new endpoint for the given plugin feature receiving an object of type <typeparamref name="T" />
@ -44,16 +44,6 @@ public interface IWebServerService : IArtemisService
/// <returns>The resulting end point</returns>
JsonPluginEndPoint<T> AddResponsiveJsonEndPoint<T>(PluginFeature feature, string endPointName, Func<T, object?> requestHandler);
/// <summary>
/// Adds a new endpoint that directly maps received JSON to the data model of the provided <paramref name="module" />.
/// </summary>
/// <typeparam name="T">The data model type of the module</typeparam>
/// <param name="module">The module whose datamodel to apply the received JSON to</param>
/// <param name="endPointName">The name of the end point, must be unique</param>
/// <returns>The resulting end point</returns>
[Obsolete("This way of updating is too unpredictable in combination with nested events, use AddJsonEndPoint<T> to update manually instead")]
DataModelJsonPluginEndPoint<T> AddDataModelJsonEndPoint<T>(Module<T> module, string endPointName) where T : DataModel, new();
/// <summary>
/// Adds a new endpoint for the given plugin feature receiving an a <see cref="string" />.
/// </summary>
@ -84,7 +74,7 @@ public interface IWebServerService : IArtemisService
/// <param name="endPointName">The name of the end point, must be unique</param>
/// <param name="requestHandler"></param>
/// <returns>The resulting end point</returns>
RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Func<IHttpContext, Task> requestHandler);
RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Func<IRequest, Task<IResponse>> requestHandler);
/// <summary>
/// Removes an existing endpoint
@ -95,31 +85,15 @@ public interface IWebServerService : IArtemisService
/// <summary>
/// Adds a new Web API controller and restarts the web server
/// </summary>
/// <typeparam name="T">The type of Web API controller to remove</typeparam>
WebApiControllerRegistration AddController<T>(PluginFeature feature) where T : WebApiController;
/// <typeparam name="T">The type of Web API controller to add</typeparam>
WebApiControllerRegistration AddController<T>(PluginFeature feature, string path) where T : class;
/// <summary>
/// Removes an existing Web API controller and restarts the web server
/// </summary>
/// <param name="registration">The registration of the controller to remove.</param>
void RemoveController(WebApiControllerRegistration registration);
/// <summary>
/// Adds a new EmbedIO module and restarts the web server
/// </summary>
WebModuleRegistration AddModule(PluginFeature feature, Func<IWebModule> create);
/// <summary>
/// Removes a EmbedIO module and restarts the web server
/// </summary>
void RemoveModule(WebModuleRegistration create);
/// <summary>
/// Adds a new EmbedIO module and restarts the web server
/// </summary>
/// <typeparam name="T">The type of module to add</typeparam>
WebModuleRegistration AddModule<T>(PluginFeature feature) where T : IWebModule;
/// <summary>
/// Occurs when the web server has been created and is about to start. This is the ideal place to add your own modules.
/// </summary>

View File

@ -1,24 +1,35 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using EmbedIO;
using GenHTTP.Api.Content;
using GenHTTP.Api.Protocol;
using GenHTTP.Modules.Basics;
using GenHTTP.Modules.Conversion.Serializers.Json;
using GenHTTP.Modules.IO.Strings;
namespace Artemis.Core.Services;
/// <summary>
/// Represents an EmbedIO web module used to process web requests and forward them to the right
/// Represents an GenHTTP handler used to process web requests and forward them to the right
/// <see cref="PluginEndPoint" />.
/// </summary>
public class PluginsModule : WebModuleBase
public class PluginsHandler : IHandler
{
private readonly Dictionary<string, Dictionary<string, PluginEndPoint>> _pluginEndPoints;
internal PluginsModule(string baseRoute) : base(baseRoute)
internal PluginsHandler(string baseRoute)
{
BaseRoute = baseRoute;
_pluginEndPoints = new Dictionary<string, Dictionary<string, PluginEndPoint>>(comparer: StringComparer.InvariantCultureIgnoreCase);
}
/// <summary>
/// Gets the base route of the module
/// </summary>
public string BaseRoute { get; }
internal void AddPluginEndPoint(PluginEndPoint registration)
{
string id = registration.PluginFeature.Plugin.Guid.ToString();
@ -42,44 +53,49 @@ public class PluginsModule : WebModuleBase
return;
registrations.Remove(registration.Name);
}
#region Overrides of WebModuleBase
/// <inheritdoc />
protected override async Task OnRequestAsync(IHttpContext context)
public ValueTask PrepareAsync()
{
if (context.Route.SubPath == null)
return;
// Split the sub path
string[] pathParts = context.Route.SubPath.Substring(1).Split('/');
// Expect a plugin ID and an endpoint
if (pathParts.Length != 2)
return;
// Find a matching plugin
if (!_pluginEndPoints.TryGetValue(pathParts[0], out Dictionary<string, PluginEndPoint>? endPoints))
throw HttpException.NotFound($"Found no plugin with ID {pathParts[0]}.");
// Find a matching endpoint
if (!endPoints.TryGetValue(pathParts[1], out PluginEndPoint? endPoint))
throw HttpException.NotFound($"Found no endpoint called {pathParts[1]} for plugin with ID {pathParts[0]}.");
// If Accept-Charset contains a wildcard, remove the header so we default to UTF8
// This is a workaround for an EmbedIO ehh issue
string? acceptCharset = context.Request.Headers["Accept-Charset"];
if (acceptCharset != null && acceptCharset.Contains("*"))
context.Request.Headers.Remove("Accept-Charset");
// It is up to the registration how the request is eventually handled, it might even set a response here
await endPoint.InternalProcessRequest(context);
// No need to return ourselves, assume the request is fully handled by the end point
context.SetHandled();
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public override bool IsFinalHandler => false;
public async ValueTask<IResponse?> HandleAsync(IRequest request)
{
// Used to be part of the RemoteController but moved here to avoid the /remote/ prefix enforced by GenHTTP
if (request.Target.Current?.Value != "plugins")
return null;
request.Target.Advance();
string? pluginId = request.Target.Current?.Value;
if (pluginId == null)
return null;
// Find a matching plugin, if none found let another handler have a go :)
if (!_pluginEndPoints.TryGetValue(pluginId, out Dictionary<string, PluginEndPoint>? endPoints))
return null;
request.Target.Advance();
string? endPointName = request.Target.Current?.Value;
if (endPointName == null)
return null;
// Find a matching endpoint
if (!endPoints.TryGetValue(endPointName, out PluginEndPoint? endPoint))
{
return request.Respond()
.Status(ResponseStatus.NotFound)
.Content(new StringContent($"Found no endpoint called {endPointName} for plugin with ID {pluginId}."))
.Type(ContentType.TextPlain)
.Build();
}
// It is up to the registration how the request is eventually handled
return await endPoint.InternalProcessRequest(request);
}
#region Overrides of WebModuleBase
internal string? ServerUrl { get; set; }

View File

@ -0,0 +1,27 @@
using System.Threading.Tasks;
using GenHTTP.Api.Content;
using GenHTTP.Api.Protocol;
namespace Artemis.Core.Services;
/// <summary>
/// Represents an GenHTTP handler used to process web requests and forward them to the right
/// <see cref="PluginEndPoint" />.
/// </summary>
public class StatusHandler : IHandler
{
/// <inheritdoc />
public ValueTask PrepareAsync()
{
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask<IResponse?> HandleAsync(IRequest request)
{
// Used to be part of the RemoteController but moved here to avoid the /remote/ prefix enforced by GenHTTP
return request.Target.Current?.Value == "status"
? ValueTask.FromResult<IResponse?>(request.Respond().Status(ResponseStatus.NoContent).Build())
: ValueTask.FromResult<IResponse?>(null);
}
}

View File

@ -1,5 +1,4 @@
using System;
using EmbedIO.WebApi;
namespace Artemis.Core.Services;
@ -7,15 +6,12 @@ namespace Artemis.Core.Services;
/// Represents a web API controller registration.
/// </summary>
/// <typeparam name="T">The type of the web API controller.</typeparam>
public class WebApiControllerRegistration<T> : WebApiControllerRegistration where T : WebApiController
public class WebApiControllerRegistration<T> : WebApiControllerRegistration where T : class
{
internal WebApiControllerRegistration(IWebServerService webServerService, PluginFeature feature) : base(webServerService, feature, typeof(T))
internal WebApiControllerRegistration(IWebServerService webServerService, PluginFeature feature, string path) : base(webServerService, feature, typeof(T), path)
{
Factory = () => feature.Plugin.Resolve<T>();
}
internal Func<T> Factory { get; set; }
internal override object UntypedFactory => Factory;
}
/// <summary>
@ -28,12 +24,13 @@ public abstract class WebApiControllerRegistration
/// <summary>
/// Creates a new instance of the <see cref="WebApiControllerRegistration"/> class.
/// </summary>
protected internal WebApiControllerRegistration(IWebServerService webServerService, PluginFeature feature, Type controllerType)
protected internal WebApiControllerRegistration(IWebServerService webServerService, PluginFeature feature, Type controllerType, string path)
{
_webServerService = webServerService;
Feature = feature;
ControllerType = controllerType;
Path = path;
Feature.Disabled += FeatureOnDisabled;
}
@ -43,15 +40,20 @@ public abstract class WebApiControllerRegistration
Feature.Disabled -= FeatureOnDisabled;
}
internal abstract object UntypedFactory { get; }
/// <summary>
/// Gets the type of the web API controller.
/// </summary>
public Type ControllerType { get; }
/// <summary>
/// Gets the plugin feature that provided the web API controller.
/// </summary>
public PluginFeature Feature { get; }
/// <summary>
/// Gets the path at which the controller is available.
/// </summary>
public string Path { get; }
internal Func<object> Factory { get; set; }
}

View File

@ -1,57 +0,0 @@
using System;
using EmbedIO;
namespace Artemis.Core.Services;
/// <summary>
/// Represents a registration for a web module.
/// </summary>
public class WebModuleRegistration
{
private readonly IWebServerService _webServerService;
internal WebModuleRegistration(IWebServerService webServerService, PluginFeature feature, Type webModuleType)
{
_webServerService = webServerService;
Feature = feature ?? throw new ArgumentNullException(nameof(feature));
WebModuleType = webModuleType ?? throw new ArgumentNullException(nameof(webModuleType));
Feature.Disabled += FeatureOnDisabled;
}
internal WebModuleRegistration(IWebServerService webServerService, PluginFeature feature, Func<IWebModule> create)
{
_webServerService = webServerService;
Feature = feature ?? throw new ArgumentNullException(nameof(feature));
Create = create ?? throw new ArgumentNullException(nameof(create));
Feature.Disabled += FeatureOnDisabled;
}
/// <summary>
/// The plugin feature that provided the web module.
/// </summary>
public PluginFeature Feature { get; }
/// <summary>
/// The type of the web module.
/// </summary>
public Type? WebModuleType { get; }
internal Func<IWebModule>? Create { get; }
internal IWebModule CreateInstance()
{
if (Create != null)
return Create();
if (WebModuleType != null)
return (IWebModule) Feature.Plugin.Resolve(WebModuleType);
throw new ArtemisCoreException("WebModuleRegistration doesn't have a create function nor a web module type :(");
}
private void FeatureOnDisabled(object? sender, EventArgs e)
{
_webServerService.RemoveModule(this);
Feature.Disabled -= FeatureOnDisabled;
}
}

View File

@ -2,14 +2,22 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Artemis.Core.Modules;
using EmbedIO;
using EmbedIO.WebApi;
using GenHTTP.Api.Infrastructure;
using GenHTTP.Api.Protocol;
using GenHTTP.Engine.Internal;
using GenHTTP.Modules.Conversion;
using GenHTTP.Modules.Conversion.Serializers;
using GenHTTP.Modules.ErrorHandling;
using GenHTTP.Modules.Layouting;
using GenHTTP.Modules.Layouting.Provider;
using GenHTTP.Modules.Practices;
using GenHTTP.Modules.Security;
using GenHTTP.Modules.Webservices;
using Serilog;
namespace Artemis.Core.Services;
@ -19,19 +27,22 @@ internal class WebServerService : IWebServerService, IDisposable
private readonly List<WebApiControllerRegistration> _controllers;
private readonly ILogger _logger;
private readonly ICoreService _coreService;
private readonly List<WebModuleRegistration> _modules;
private readonly PluginSetting<bool> _webServerEnabledSetting;
private readonly PluginSetting<int> _webServerPortSetting;
private readonly object _webserverLock = new();
private readonly JsonSerializerOptions _jsonOptions = new(CoreJson.GetJsonSerializerOptions()) {ReferenceHandler = ReferenceHandler.IgnoreCycles, WriteIndented = true};
private CancellationTokenSource? _cts;
private readonly SemaphoreSlim _webserverSemaphore = new(1, 1);
internal static readonly JsonSerializerOptions JsonOptions = new(CoreJson.GetJsonSerializerOptions())
{
ReferenceHandler = ReferenceHandler.IgnoreCycles,
WriteIndented = true,
Converters = {new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)}
};
public WebServerService(ILogger logger, ICoreService coreService, ISettingsService settingsService, IPluginManagementService pluginManagementService)
{
_logger = logger;
_coreService = coreService;
_controllers = new List<WebApiControllerRegistration>();
_modules = new List<WebModuleRegistration>();
_webServerEnabledSetting = settingsService.GetSetting("WebServer.Enabled", true);
_webServerPortSetting = settingsService.GetSetting("WebServer.Port", 9696);
@ -39,7 +50,7 @@ internal class WebServerService : IWebServerService, IDisposable
_webServerPortSetting.SettingChanged += WebServerPortSettingOnSettingChanged;
pluginManagementService.PluginFeatureDisabled += PluginManagementServiceOnPluginFeatureDisabled;
PluginsModule = new PluginsModule("/plugins");
PluginsHandler = new PluginsHandler("plugins");
if (coreService.IsInitialized)
AutoStartWebServer();
else
@ -66,12 +77,12 @@ internal class WebServerService : IWebServerService, IDisposable
private void WebServerEnabledSettingOnSettingChanged(object? sender, EventArgs e)
{
StartWebServer();
_ = StartWebServer();
}
private void WebServerPortSettingOnSettingChanged(object? sender, EventArgs e)
{
StartWebServer();
_ = StartWebServer();
}
private void PluginManagementServiceOnPluginFeatureDisabled(object? sender, PluginFeatureEventArgs e)
@ -83,76 +94,64 @@ internal class WebServerService : IWebServerService, IDisposable
_controllers.RemoveAll(c => c.Feature == e.PluginFeature);
}
if (_modules.Any(m => m.Feature == e.PluginFeature))
{
mustRestart = true;
_modules.RemoveAll(m => m.Feature == e.PluginFeature);
}
if (mustRestart)
StartWebServer();
_ = StartWebServer();
}
/// <inheritdoc />
public void Dispose()
{
Server?.Dispose();
Server?.DisposeAsync();
_webServerPortSetting.SettingChanged -= WebServerPortSettingOnSettingChanged;
}
public WebServer? Server { get; private set; }
public PluginsModule PluginsModule { get; }
public IServer? Server { get; private set; }
public PluginsHandler PluginsHandler { get; }
public event EventHandler? WebServerStarting;
#region Web server managament
private WebServer CreateWebServer()
private async Task<IServer> CreateWebServer()
{
if (Server != null)
{
if (_cts != null)
{
_cts.Cancel();
_cts = null;
}
Server.Dispose();
await Server.DisposeAsync();
OnWebServerStopped();
Server = null;
}
WebApiModule apiModule = new("/", SystemTextJsonSerializer);
PluginsModule.ServerUrl = $"http://localhost:{_webServerPortSetting.Value}/";
WebServer server = new WebServer(o => o.WithUrlPrefix($"http://*:{_webServerPortSetting.Value}/").WithMode(HttpListenerMode.EmbedIO))
.WithLocalSessionManager()
.WithModule(PluginsModule);
PluginsHandler.ServerUrl = $"http://localhost:{_webServerPortSetting.Value}/";
LayoutBuilder serverLayout = Layout.Create()
.Add(PluginsHandler)
.Add(new StatusHandler())
.Add(CorsPolicy.Permissive());
// Add registered modules
foreach (WebModuleRegistration? webModule in _modules)
server = server.WithModule(webModule.CreateInstance());
server = server
.WithModule(apiModule)
.HandleHttpException((context, exception) => HandleHttpExceptionJson(context, exception))
.HandleUnhandledException(JsonExceptionHandlerCallback);
// Add registered controllers to the API module
// Add registered controllers to the API module as services.
// GenHTTP also has controllers but services are more flexible and match EmbedIO's approach more closely.
SerializationBuilder serialization = Serialization.Default(JsonOptions);
foreach (WebApiControllerRegistration registration in _controllers)
apiModule.RegisterController(registration.ControllerType, (Func<WebApiController>) registration.UntypedFactory);
{
serverLayout = serverLayout.AddService(registration.Path, registration.Factory(), serializers: serialization);
}
// Listen for state changes.
server.StateChanged += (s, e) => _logger.Verbose("WebServer new state - {state}", e.NewState);
IServer server = Host.Create()
.Handler(serverLayout.Build())
.Bind(IPAddress.Loopback, (ushort) _webServerPortSetting.Value)
.Defaults()
.Build();
// Store the URL in a webserver.txt file so that remote applications can find it
File.WriteAllText(Path.Combine(Constants.DataFolder, "webserver.txt"), PluginsModule.ServerUrl);
await File.WriteAllTextAsync(Path.Combine(Constants.DataFolder, "webserver.txt"), PluginsHandler.ServerUrl);
return server;
}
private void StartWebServer()
private async Task StartWebServer()
{
lock (_webserverLock)
await _webserverSemaphore.WaitAsync();
try
{
// Don't create the webserver until after the core service is initialized, this avoids lots of useless re-creates during initialize
if (!_coreService.IsInitialized)
@ -161,7 +160,7 @@ internal class WebServerService : IWebServerService, IDisposable
if (!_webServerEnabledSetting.Value)
return;
Server = CreateWebServer();
Server = await CreateWebServer();
if (Constants.StartupArguments.Contains("--disable-webserver"))
{
@ -170,17 +169,29 @@ internal class WebServerService : IWebServerService, IDisposable
}
OnWebServerStarting();
_cts = new CancellationTokenSource();
Server.Start(_cts.Token);
try
{
await Server.StartAsync();
}
catch (Exception e)
{
_logger.Warning(e, "Failed to start webserver");
throw;
}
OnWebServerStarted();
}
finally
{
_webserverSemaphore.Release();
}
}
private void AutoStartWebServer()
private async Task AutoStartWebServer()
{
try
{
StartWebServer();
await StartWebServer();
}
catch (Exception exception)
{
@ -197,8 +208,8 @@ internal class WebServerService : IWebServerService, IDisposable
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);
JsonPluginEndPoint<T> endPoint = new(feature, endPointName, PluginsHandler, requestHandler);
PluginsHandler.AddPluginEndPoint(endPoint);
return endPoint;
}
@ -207,8 +218,8 @@ internal class WebServerService : IWebServerService, IDisposable
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);
JsonPluginEndPoint<T> endPoint = new(feature, endPointName, PluginsHandler, requestHandler);
PluginsHandler.AddPluginEndPoint(endPoint);
return endPoint;
}
@ -217,8 +228,8 @@ internal class WebServerService : IWebServerService, IDisposable
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);
StringPluginEndPoint endPoint = new(feature, endPointName, PluginsHandler, requestHandler);
PluginsHandler.AddPluginEndPoint(endPoint);
return endPoint;
}
@ -227,47 +238,37 @@ internal class WebServerService : IWebServerService, IDisposable
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);
StringPluginEndPoint endPoint = new(feature, endPointName, PluginsHandler, requestHandler);
PluginsHandler.AddPluginEndPoint(endPoint);
return endPoint;
}
public RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Func<IHttpContext, Task> requestHandler)
public RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Func<IRequest, Task<IResponse>> 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;
}
[Obsolete("Use AddJsonEndPoint<T>(PluginFeature feature, string endPointName, Action<T> requestHandler) instead")]
public DataModelJsonPluginEndPoint<T> AddDataModelJsonEndPoint<T>(Module<T> module, string endPointName) where T : DataModel, new()
{
if (module == null) throw new ArgumentNullException(nameof(module));
if (endPointName == null) throw new ArgumentNullException(nameof(endPointName));
DataModelJsonPluginEndPoint<T> endPoint = new(module, endPointName, PluginsModule);
PluginsModule.AddPluginEndPoint(endPoint);
RawPluginEndPoint endPoint = new(feature, endPointName, PluginsHandler, requestHandler);
PluginsHandler.AddPluginEndPoint(endPoint);
return endPoint;
}
public void RemovePluginEndPoint(PluginEndPoint endPoint)
{
PluginsModule.RemovePluginEndPoint(endPoint);
PluginsHandler.RemovePluginEndPoint(endPoint);
}
#endregion
#region Controller management
public WebApiControllerRegistration AddController<T>(PluginFeature feature) where T : WebApiController
public WebApiControllerRegistration AddController<T>(PluginFeature feature, string path) where T : class
{
if (feature == null) throw new ArgumentNullException(nameof(feature));
WebApiControllerRegistration<T> registration = new(this, feature);
WebApiControllerRegistration<T> registration = new(this, feature, path);
_controllers.Add(registration);
StartWebServer();
_ = StartWebServer();
return registration;
}
@ -275,75 +276,7 @@ internal class WebServerService : IWebServerService, IDisposable
public void RemoveController(WebApiControllerRegistration registration)
{
_controllers.Remove(registration);
StartWebServer();
}
#endregion
#region Module management
public WebModuleRegistration AddModule(PluginFeature feature, Func<IWebModule> create)
{
if (feature == null) throw new ArgumentNullException(nameof(feature));
WebModuleRegistration registration = new(this, feature, create);
_modules.Add(registration);
StartWebServer();
return registration;
}
public WebModuleRegistration AddModule<T>(PluginFeature feature) where T : IWebModule
{
if (feature == null) throw new ArgumentNullException(nameof(feature));
WebModuleRegistration registration = new(this, feature, typeof(T));
_modules.Add(registration);
StartWebServer();
return registration;
}
public void RemoveModule(WebModuleRegistration registration)
{
_modules.Remove(registration);
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 = CoreJson.Serialize(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 SystemTextJsonSerializer(IHttpContext context, object? data)
{
context.Response.ContentType = MimeType.Json;
await using TextWriter writer = context.OpenResponseText();
await writer.WriteAsync(JsonSerializer.Serialize(data, _jsonOptions));
}
private async Task HandleHttpExceptionJson(IHttpContext context, IHttpException httpException)
{
await context.SendStringAsync(JsonSerializer.Serialize(httpException, _jsonOptions), MimeType.Json, Encoding.UTF8);
_ = StartWebServer();
}
#endregion

View File

@ -1,17 +1,17 @@
using System;
using System.Collections.Generic;
using System.IO;
using Artemis.Core;
using Artemis.Core.Services;
using Artemis.UI.Shared.Routing;
using Artemis.UI.Shared.Services.MainWindow;
using Avalonia.Threading;
using EmbedIO;
using EmbedIO.Routing;
using EmbedIO.WebApi;
using GenHTTP.Api.Protocol;
using GenHTTP.Modules.Webservices;
namespace Artemis.UI.Controllers;
public class RemoteController : WebApiController
public class RemoteController
{
private readonly ICoreService _coreService;
private readonly IMainWindowService _mainWindowService;
@ -24,17 +24,17 @@ public class RemoteController : WebApiController
_router = router;
}
[Route(HttpVerbs.Any, "/status")]
public void GetStatus()
[ResourceMethod(RequestMethod.Post, "bring-to-foreground")]
public void BringToForeground(IRequest request)
{
HttpContext.Response.StatusCode = 200;
}
[Route(HttpVerbs.Post, "/remote/bring-to-foreground")]
public void PostBringToForeground()
{
using StreamReader reader = new(Request.InputStream);
string route = reader.ReadToEnd();
// Get the route from the request content stream
// TODO: Use [FromBody] attribute instead once GenHTTP allows omitting null values
string? route = null;
if (request.Content != null)
{
using StreamReader reader = new(request.Content);
route = reader.ReadToEnd();
}
Dispatcher.UIThread.InvokeAsync(async () =>
{
@ -44,14 +44,14 @@ public class RemoteController : WebApiController
});
}
[Route(HttpVerbs.Post, "/remote/restart")]
public void PostRestart([FormField] string[] args)
[ResourceMethod(RequestMethod.Post, "restart")]
public void Restart(List<string> args)
{
Utilities.Restart(_coreService.IsElevated, TimeSpan.FromMilliseconds(500), args);
Utilities.Restart(_coreService.IsElevated, TimeSpan.FromMilliseconds(500), args.ToArray());
}
[Route(HttpVerbs.Post, "/remote/shutdown")]
public void PostShutdown()
[ResourceMethod(RequestMethod.Post, "shutdown")]
public void Shutdown()
{
Utilities.Shutdown();
}

View File

@ -94,6 +94,6 @@ public class RegistrationService : IRegistrationService
public void RegisterControllers()
{
_webServerService.AddController<RemoteController>(Constants.CorePlugin.Features.First().Instance!);
_webServerService.AddController<RemoteController>(Constants.CorePlugin.Features.First().Instance!, "remote");
}
}

View File

@ -14,6 +14,8 @@
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.2.3" />
<PackageVersion Include="Avalonia.Skia.Lottie" Version="11.0.0" />
<PackageVersion Include="Avalonia.Win32" Version="11.2.3" />
<PackageVersion Include="GenHTTP.Core" Version="9.6.2" />
<PackageVersion Include="GenHTTP.Modules.Webservices" Version="9.6.2" />
<PackageVersion Include="HPPH.SkiaSharp" Version="1.0.0" />
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="9.0.1" />
<PackageVersion Include="Avalonia.Xaml.Behaviors" Version="11.2.0.8" />
@ -21,7 +23,6 @@
<PackageVersion Include="DryIoc.Microsoft.DependencyInjection" Version="6.2.0" />
<PackageVersion Include="DryIoc.dll" Version="5.4.3" />
<PackageVersion Include="DynamicData" Version="9.1.1" />
<PackageVersion Include="EmbedIO" Version="3.5.2" />
<PackageVersion Include="FluentAvalonia.ProgressRing" Version="1.69.2" />
<PackageVersion Include="FluentAvaloniaUI" Version="2.2.0" />
<PackageVersion Include="HidSharp" Version="2.1.0" />