Huge refactor; Passing tests
This commit is contained in:
parent
a03fafebcf
commit
7bc6086509
17
SimpleHttpServer/GlobalUsings.cs
Normal file
17
SimpleHttpServer/GlobalUsings.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
global using static SimpleHttpServer.GlobalUsings;
|
||||||
|
using SimpleHttpServer.Types.Exceptions;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace SimpleHttpServer;
|
||||||
|
internal static class GlobalUsings {
|
||||||
|
internal static void Assert([DoesNotReturnIf(false)] bool b, string? message = null) {
|
||||||
|
if (!b) {
|
||||||
|
if (message == null)
|
||||||
|
throw new AssertionFailedException("An assertion has failed!");
|
||||||
|
else
|
||||||
|
throw new AssertionFailedException($"An assertion has failed: {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void AssertImplies(bool x, bool y, string? message = null) => Assert(!x || y, message);
|
||||||
|
}
|
||||||
22
SimpleHttpServer/HttpEndpointAttribute.cs
Normal file
22
SimpleHttpServer/HttpEndpointAttribute.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
using SimpleHttpServer.Internal;
|
||||||
|
|
||||||
|
namespace SimpleHttpServer;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
||||||
|
public class HttpEndpointAttribute<T> : Attribute where T : IAuthorizer {
|
||||||
|
|
||||||
|
public HttpRequestType RequestMethod { get; private set; }
|
||||||
|
public string[] Locations { get; private set; }
|
||||||
|
public Type Authorizer { get; private set; }
|
||||||
|
|
||||||
|
public HttpEndpointAttribute(HttpRequestType requestMethod, params string[] locations) {
|
||||||
|
RequestMethod = requestMethod;
|
||||||
|
Locations = locations;
|
||||||
|
Authorizer = typeof(T);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Method)]
|
||||||
|
public class HttpEndpointAttribute : HttpEndpointAttribute<DefaultAuthorizer> {
|
||||||
|
public HttpEndpointAttribute(HttpRequestType type, params string[] locations) : base(type, locations) { }
|
||||||
|
}
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
using SimpleHttpServer.Internal;
|
|
||||||
|
|
||||||
namespace SimpleHttpServer;
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
|
|
||||||
public class HttpRoute<T> : Attribute where T : IAuthorizer {
|
|
||||||
|
|
||||||
public HttpRequestType RequestMethod { get; private set; }
|
|
||||||
public string Location { get; private set; }
|
|
||||||
public Type Authorizer { get; private set; }
|
|
||||||
|
|
||||||
public HttpRoute(HttpRequestType requestMethod, string location) {
|
|
||||||
RequestMethod = requestMethod;
|
|
||||||
Location = location;
|
|
||||||
Authorizer = typeof(T);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Method)]
|
|
||||||
public class HttpEndpoint : HttpRoute<DefaultAuthorizer> {
|
|
||||||
public HttpEndpoint(HttpRequestType type, string location) : base(type, location) { }
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
using SimpleHttpServer.Internal;
|
using SimpleHttpServer.Types;
|
||||||
|
using SimpleHttpServer.Types.Exceptions;
|
||||||
|
using SimpleHttpServer.Types.ParameterConverters;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Numerics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace SimpleHttpServer;
|
namespace SimpleHttpServer;
|
||||||
|
|
@ -8,188 +11,200 @@ public sealed class HttpServer {
|
||||||
|
|
||||||
public int Port { get; }
|
public int Port { get; }
|
||||||
|
|
||||||
private readonly CancellationTokenSource ctokSrc;
|
|
||||||
|
|
||||||
private readonly HttpListener listener;
|
private readonly HttpListener listener;
|
||||||
private Task listenerTask;
|
private Task? listenerTask;
|
||||||
private Logger logger;
|
private readonly Logger logger;
|
||||||
|
private readonly SimpleHttpServerConfiguration conf;
|
||||||
|
private bool shutdown = false;
|
||||||
|
|
||||||
public HttpServer(int port, TextWriter? logRedirect = null) {
|
public HttpServer(int port, SimpleHttpServerConfiguration configuration) {
|
||||||
ctokSrc = new();
|
|
||||||
Port = port;
|
Port = port;
|
||||||
|
conf = configuration;
|
||||||
listener = new HttpListener();
|
listener = new HttpListener();
|
||||||
listener.Prefixes.Add($"http://localhost:{port}/");
|
listener.Prefixes.Add($"http://localhost:{port}/");
|
||||||
logger = new("HttpServer", logRedirect);
|
logger = new(LogOutputTopic.Main, conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StartAsync() {
|
public void Start() {
|
||||||
logger.Information($"Starting on port {Port}...");
|
logger.Information($"Starting on port {Port}...");
|
||||||
|
Assert(listenerTask == null, "Server was already started!");
|
||||||
listener.Start();
|
listener.Start();
|
||||||
listenerTask = Task.Run(GetContextLoop);
|
listenerTask = Task.Run(GetContextLoopAsync);
|
||||||
logger.Information($"Ready to handle requests!");
|
logger.Information($"Ready to handle requests!");
|
||||||
|
|
||||||
await Task.Yield();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task GetContextLoop() {
|
public async Task StopAsync(CancellationToken ctok) {
|
||||||
while (true) {
|
logger.Information("Stopping server...");
|
||||||
|
Assert(listenerTask != null, "Server was not started!");
|
||||||
|
shutdown = true;
|
||||||
|
listener.Stop();
|
||||||
|
await listenerTask.WaitAsync(ctok);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task GetContextLoopAsync() {
|
||||||
|
while (!shutdown) {
|
||||||
try {
|
try {
|
||||||
var ctx = await listener.GetContextAsync();
|
var ctx = await listener.GetContextAsync();
|
||||||
_ = ProcessRequestAsync(ctx);
|
_ = ProcessRequestAsync(ctx);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
|
logger.Fatal($"Caught otherwise uncaught exception in GetContextLoop:\n{ex}");
|
||||||
} finally {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ProcessRequestAsync(HttpListenerContext ctx) {
|
private void RegisterDefaultConverters() {
|
||||||
|
void RegisterConverter<T>() where T : IParsable<T> {
|
||||||
|
stringToTypeParameterConverters.Add(typeof(T), new ParsableParameterConverter<T>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stringToTypeParameterConverters.Add(typeof(bool), new BoolParsableParameterConverter());
|
||||||
|
RegisterConverter<char>();
|
||||||
|
RegisterConverter<byte>();
|
||||||
|
RegisterConverter<short>();
|
||||||
|
RegisterConverter<int>();
|
||||||
|
RegisterConverter<long>();
|
||||||
|
RegisterConverter<Int128>();
|
||||||
|
RegisterConverter<UInt128>();
|
||||||
|
RegisterConverter<BigInteger>();
|
||||||
|
|
||||||
|
RegisterConverter<sbyte>();
|
||||||
|
RegisterConverter<ushort>();
|
||||||
|
RegisterConverter<uint>();
|
||||||
|
RegisterConverter<ulong>();
|
||||||
|
|
||||||
|
RegisterConverter<Half>();
|
||||||
|
RegisterConverter<float>();
|
||||||
|
RegisterConverter<double>();
|
||||||
|
RegisterConverter<decimal>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<(string path, string rType), EndpointInvocationInfo> simpleEndpointMethodInfos = new();
|
||||||
|
private static readonly Type[] expectedEndpointParameterTypes = new[] { typeof(RequestContext) };
|
||||||
|
public void RegisterEndpointsFromType<T>() {
|
||||||
|
if (simpleEndpointMethodInfos.Count == 0)
|
||||||
|
RegisterDefaultConverters();
|
||||||
|
|
||||||
private readonly Dictionary<(string path, HttpRequestType rType), Action<RequestContext>> simpleEndpoints = new();
|
|
||||||
public void RegisterRoutesFromType<T>() {
|
|
||||||
var t = typeof(T);
|
var t = typeof(T);
|
||||||
foreach (var (mi, attrib) in t.GetMethods()
|
foreach (var (mi, attrib) in t.GetMethods()
|
||||||
.ToDictionary(x => x, x => x.GetCustomAttributes(typeof(HttpRoute<>)))
|
.ToDictionary(x => x, x => x.GetCustomAttributes(typeof(HttpEndpointAttribute<>)))
|
||||||
.Where(x => x.Value.Any()).ToDictionary(x => x.Key, x => x.Value.Single() as HttpRoute<IAuthorizer> ?? throw new InvalidCastException()))
|
.Where(x => x.Value.Any()).ToDictionary(x => x.Key, x => (HttpEndpointAttribute) x.Value.Single())) {
|
||||||
{
|
|
||||||
simpleEndpoints.Add((attrib.Location, attrib.RequestMethod), mi.CreateDelegate<Action<RequestContext>>());
|
string GetFancyMethodName() => mi.DeclaringType!.FullName + "#" + mi.Name;
|
||||||
|
|
||||||
|
Assert(mi.IsStatic, $"Method tagged with HttpEndpointAttribute must be static! ({GetFancyMethodName()})");
|
||||||
|
Assert(mi.IsPublic, $"Method tagged with HttpEndpointAttribute must be public! ({GetFancyMethodName()})");
|
||||||
|
|
||||||
|
var methodParams = mi.GetParameters();
|
||||||
|
Assert(methodParams.Length >= expectedEndpointParameterTypes.Length);
|
||||||
|
for (int i = 0; i < expectedEndpointParameterTypes.Length; i++) {
|
||||||
|
Assert(methodParams[i].ParameterType.IsAssignableFrom(expectedEndpointParameterTypes[i]),
|
||||||
|
$"Parameter at index {i} of {GetFancyMethodName()} is of a type that cannot contain the expected type {expectedEndpointParameterTypes[i].FullName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert(mi.ReturnType == typeof(Task), $"Return type of {GetFancyMethodName()} is not {typeof(Task)}!");
|
||||||
|
|
||||||
|
|
||||||
|
var qparams = new List<(string, (Type type, bool isOptional))>();
|
||||||
|
for (int i = expectedEndpointParameterTypes.Length; i < methodParams.Length; i++) {
|
||||||
|
var par = methodParams[i];
|
||||||
|
var attr = par.GetCustomAttribute<ParameterAttribute>(false);
|
||||||
|
qparams.Add((attr?.Name ?? par.Name ?? throw new ArgumentException($"C# variable name of parameter at index {i} of method {GetFancyMethodName()} is null!"),
|
||||||
|
(par.GetType(), attr?.IsOptional ?? false)));
|
||||||
|
|
||||||
|
if (!stringToTypeParameterConverters.ContainsKey(par.ParameterType)) {
|
||||||
|
throw new MissingParameterConverterException($"Parameter converter for type {par.ParameterType} has not been registered (yet)!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly HttpListener _listener;
|
foreach (var location in attrib.Locations) {
|
||||||
private readonly Dictionary<(string path, HttpRequestType rType), HttpEndpointHandler> _plainEndpoints = new();
|
int idx = location.IndexOf('{');
|
||||||
private readonly Dictionary<(string path, HttpRequestType rType), HttpEndpointHandler> _pparamEndpoints = new();
|
|
||||||
|
|
||||||
public string Url { get; private set; }
|
|
||||||
public Func<HttpListenerContext, HttpResponseBuilder> Default404 { get; private set; }
|
|
||||||
|
|
||||||
public static HttpServer Create(int port, string url, params Type[] apiDefinitions) => Create(Console.Error, port, url, false, apiDefinitions);
|
|
||||||
|
|
||||||
public static HttpServer Create(TextWriter error, int port, string url, bool throwOnInvalidEndpoint, params Type[] apiDefinitions) {
|
|
||||||
var epDict = new Dictionary<(string, HttpRequestType), HttpEndpointHandler>();
|
|
||||||
|
|
||||||
foreach (var definition in apiDefinitions) {
|
|
||||||
foreach (var endpoint in definition.GetMethods()) {
|
|
||||||
var attrib = endpoint.GetCustomAttributes()
|
|
||||||
.Where(x => x.GetType().IsAssignableTo(typeof(HttpRoute<>)))
|
|
||||||
.Select(x => (HttpRoute<IAuthorizer>) x)
|
|
||||||
.SingleOrDefault();
|
|
||||||
|
|
||||||
if (attrib == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanity checks
|
|
||||||
if (!endpoint.IsStatic) {
|
|
||||||
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, "HttpEndpointAttribute is only valid on static methods!");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!endpoint.IsPublic) {
|
|
||||||
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to be public!");
|
|
||||||
}
|
|
||||||
var myParams = endpoint.GetParameters();
|
|
||||||
if (myParams.Length <= 0 || !myParams[0].GetType().IsAssignableFrom(typeof(HttpListenerContext))) {
|
|
||||||
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to have a HttpListenerContext as its first argument!");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!endpoint.ReturnParameter.ParameterType.IsAssignableTo(typeof(HttpResponseBuilder))) {
|
|
||||||
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to have a HttpResponseBuilder as the return type!");
|
|
||||||
}
|
|
||||||
|
|
||||||
var path = attrib.Location;
|
|
||||||
int idx = path.IndexOf('{');
|
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
// this path contains path parameters
|
// this path contains path parameters
|
||||||
throw new NotImplementedException("Implement path parameters!");
|
throw new NotImplementedException("Path parameters are not yet implemented!");
|
||||||
}
|
|
||||||
var qparams = new List<(string, Type)>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var reqMethod = Enum.GetName(attrib.RequestMethod) ?? throw new ArgumentException("Request method was undefined");
|
||||||
public void Shutdown() {
|
simpleEndpointMethodInfos.Add((location, reqMethod), new EndpointInvocationInfo(mi, qparams));
|
||||||
Shutdown(-1);
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Shutdown() {
|
private readonly Dictionary<Type, IParameterConverter> stringToTypeParameterConverters = new();
|
||||||
if (_listenerThread == null)
|
|
||||||
throw new InvalidOperationException("Cannot shut down HttpServer that has not been started");
|
|
||||||
|
|
||||||
|
|
||||||
|
private async Task ProcessRequestAsync(HttpListenerContext ctx) {
|
||||||
}
|
|
||||||
|
|
||||||
public bool Shutdown(int timeout) {
|
|
||||||
if (_listenerThread == null) {
|
|
||||||
throw new InvalidOperationException("Cannot shutdown HttpServer that has not been started");
|
|
||||||
}
|
|
||||||
_listenerThread.Interrupt();
|
|
||||||
bool exited = true;
|
|
||||||
if (timeout < 0) {
|
|
||||||
_listenerThread.Join();
|
|
||||||
} else {
|
|
||||||
exited = _listenerThread.Join(timeout);
|
|
||||||
}
|
|
||||||
_listenerThread = null;
|
|
||||||
_listener.Stop();
|
|
||||||
return exited;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Start() {
|
|
||||||
_listenerThread = new Thread(RunServer);
|
|
||||||
_listener.Start();
|
|
||||||
_listenerThread.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void RunServer() {
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
var decUri = WebUtility.UrlDecode(ctx.Request.RawUrl)!; // TODO add path escape countermeasures+unittests
|
||||||
var ctx = _listener.GetContext();
|
var splitted = decUri.Split('?', 2, StringSplitOptions.None);
|
||||||
|
var path = WebUtility.UrlDecode(splitted.First());
|
||||||
|
|
||||||
ThreadPool.QueueUserWorkItem((localCtx) => {
|
|
||||||
HttpRequestType type;
|
using var rc = new RequestContext(ctx);
|
||||||
if (!Enum.TryParse(localCtx.Request.HttpMethod, out type)) {
|
if (simpleEndpointMethodInfos.TryGetValue((decUri, ctx.Request.HttpMethod.ToUpperInvariant()), out var endpointInvocationInfo)) {
|
||||||
Default404(localCtx).SendResponse(localCtx.Response);
|
var mi = endpointInvocationInfo.methodInfo;
|
||||||
|
var qparams = endpointInvocationInfo.queryParameters;
|
||||||
|
var args = splitted.Length == 2 ? splitted[1] : null;
|
||||||
|
|
||||||
|
var parsedQParams = new Dictionary<string, string>();
|
||||||
|
var convertedQParamValues = new object[qparams.Count + 1];
|
||||||
|
|
||||||
|
// TODO add authcheck here
|
||||||
|
|
||||||
|
if (args != null) {
|
||||||
|
var queryStringArgs = args.Split('&', StringSplitOptions.None);
|
||||||
|
foreach (var queryKV in queryStringArgs) {
|
||||||
|
var queryKVSplitted = queryKV.Split('=');
|
||||||
|
if (queryKVSplitted.Length != 2) {
|
||||||
|
rc.SetStatusCodeAndDispose(HttpStatusCode.BadRequest, "Malformed request URL parameters");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var path = localCtx.Request.Url!.LocalPath.Replace('\\', '/');
|
if (!parsedQParams.TryAdd(WebUtility.UrlDecode(queryKVSplitted[0]), WebUtility.UrlDecode(queryKVSplitted[1]))) {
|
||||||
HttpEndpointHandler? ep = null;
|
rc.SetStatusCodeAndDispose(HttpStatusCode.BadRequest, "Duplicate request URL parameters");
|
||||||
if (!_plainEndpoints.TryGetValue((path, type), out ep)) {
|
|
||||||
// not found among plain endpoints
|
|
||||||
foreach (var epk in _pparamEndpoints.Keys) {
|
|
||||||
if (epk.rType == type && path.StartsWith(epk.path)) {
|
|
||||||
ep = _pparamEndpoints[epk];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (ep == null) {
|
|
||||||
Default404(localCtx).SendResponse(localCtx.Response);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ep.Handle(localCtx);
|
|
||||||
}, ctx, false);
|
|
||||||
}
|
|
||||||
} catch (ThreadInterruptedException) {
|
|
||||||
// this can only be reached when listener.GetContext is interrupted
|
|
||||||
// safely exit main loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PrintErrorOrThrow(TextWriter error, MethodInfo method, bool forceThrow, string msg) {
|
for (int i = 0; i < qparams.Count;) {
|
||||||
if (forceThrow) {
|
var (qparamName, qparamInfo) = qparams[i];
|
||||||
throw new Exception(msg);
|
i++;
|
||||||
|
|
||||||
|
if (parsedQParams.TryGetValue(qparamName, out var qparamValue)) {
|
||||||
|
if (stringToTypeParameterConverters[qparamInfo.type].TryConvertFromString(qparamValue, out object objRes)) {
|
||||||
|
convertedQParamValues[i] = objRes;
|
||||||
} else {
|
} else {
|
||||||
error.WriteLine($"> {msg}\n skipping {GetFancyMethodName(method)} ...");
|
rc.SetStatusCodeAndDispose(HttpStatusCode.BadRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (qparamInfo.isOptional) {
|
||||||
|
convertedQParamValues[i] = null!;
|
||||||
|
} else {
|
||||||
|
rc.SetStatusCodeAndDispose(HttpStatusCode.BadRequest, $"Missing required query parameter {qparamName}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
convertedQParamValues[0] = rc;
|
||||||
|
|
||||||
|
await (Task) (mi.Invoke(null, convertedQParamValues) ?? throw new NullReferenceException("Website func returned null unexpectedly"));
|
||||||
|
} else {
|
||||||
|
// invoke 404
|
||||||
|
await HandleDefaultErrorPageAsync(rc, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception ex) {
|
||||||
|
logger.Fatal($"Caught otherwise uncaught exception while ProcessingRequest:\n{ex}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetFancyMethodName(MethodInfo method) => method.DeclaringType!.Name + "#" + method.Name;
|
|
||||||
|
private static async Task HandleDefaultErrorPageAsync(RequestContext ctx, int errorCode) {
|
||||||
|
await ctx.WriteLineToRespAsync($"""
|
||||||
|
<body>
|
||||||
|
<h1>Oh no, and error occurred!</h1>
|
||||||
|
<p>Code: {errorCode}</p>
|
||||||
|
</body>
|
||||||
|
""");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,13 +8,13 @@ namespace SimpleHttpServer.Internal;
|
||||||
internal class HttpEndpointHandler {
|
internal class HttpEndpointHandler {
|
||||||
private static readonly DefaultAuthorizer defaultAuth = new();
|
private static readonly DefaultAuthorizer defaultAuth = new();
|
||||||
|
|
||||||
private readonly IAuthorizer _auth;
|
private readonly IAuthorizer auth;
|
||||||
private readonly MethodInfo _handler;
|
private readonly MethodInfo handler;
|
||||||
private readonly Dictionary<string, (int pindex, Type type, int pparamIdx)> _params;
|
private readonly Dictionary<string, (int pindex, Type type, int pparamIdx)> @params;
|
||||||
private readonly Func<Exception, HttpResponseBuilder> _errorPageBuilder;
|
private readonly Func<Exception, HttpResponseBuilder> errorPageBuilder;
|
||||||
|
|
||||||
public HttpEndpointHandler() {
|
public HttpEndpointHandler() {
|
||||||
_auth = defaultAuth;
|
auth = defaultAuth;
|
||||||
}
|
}
|
||||||
|
|
||||||
public HttpEndpointHandler(IAuthorizer auth) {
|
public HttpEndpointHandler(IAuthorizer auth) {
|
||||||
|
|
@ -23,14 +23,14 @@ internal class HttpEndpointHandler {
|
||||||
|
|
||||||
public virtual void Handle(HttpListenerContext ctx) {
|
public virtual void Handle(HttpListenerContext ctx) {
|
||||||
try {
|
try {
|
||||||
var (isAuth, authData) = _auth.IsAuthenticated(ctx);
|
var (isAuth, authData) = auth.IsAuthenticated(ctx);
|
||||||
if (!isAuth) {
|
if (!isAuth) {
|
||||||
throw new HttpHandlingException(401, "Authorization required!");
|
throw new HttpHandlingException(401, "Authorization required!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// collect parameters
|
// collect parameters
|
||||||
var invokeParams = new object?[_params.Count + 1];
|
var invokeParams = new object?[@params.Count + 1];
|
||||||
var set = new BitArray(_params.Count);
|
var set = new BitArray(@params.Count);
|
||||||
invokeParams[0] = ctx;
|
invokeParams[0] = ctx;
|
||||||
|
|
||||||
// read pparams
|
// read pparams
|
||||||
|
|
@ -38,8 +38,8 @@ internal class HttpEndpointHandler {
|
||||||
// read qparams
|
// read qparams
|
||||||
var qst = ctx.Request.QueryString;
|
var qst = ctx.Request.QueryString;
|
||||||
foreach (var qelem in ctx.Request.QueryString.AllKeys) {
|
foreach (var qelem in ctx.Request.QueryString.AllKeys) {
|
||||||
if (_params.ContainsKey(qelem!)) {
|
if (@params.ContainsKey(qelem!)) {
|
||||||
var (pindex, type, isPParam) = _params[qelem!];
|
var (pindex, type, isPParam) = @params[qelem!];
|
||||||
if (type == typeof(string)) {
|
if (type == typeof(string)) {
|
||||||
invokeParams[pindex] = ctx.Request.QueryString[qelem!];
|
invokeParams[pindex] = ctx.Request.QueryString[qelem!];
|
||||||
set.Set(pindex - 1, true);
|
set.Set(pindex - 1, true);
|
||||||
|
|
@ -54,20 +54,20 @@ internal class HttpEndpointHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
// fill with defaults
|
// fill with defaults
|
||||||
foreach (var p in _params) {
|
foreach (var p in @params) {
|
||||||
if (!set.Get(p.Value.pindex)) {
|
if (!set.Get(p.Value.pindex)) {
|
||||||
invokeParams[p.Value.pindex] = p.Value.type.IsValueType ? Activator.CreateInstance(p.Value.type) : null;
|
invokeParams[p.Value.pindex] = p.Value.type.IsValueType ? Activator.CreateInstance(p.Value.type) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var builder = _handler.Invoke(null, invokeParams) as HttpResponseBuilder;
|
var builder = handler.Invoke(null, invokeParams) as HttpResponseBuilder;
|
||||||
builder!.SendResponse(ctx.Response);
|
builder!.SendResponse(ctx.Response);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (e is TargetInvocationException tex) {
|
if (e is TargetInvocationException tex) {
|
||||||
e = tex.InnerException!;
|
e = tex.InnerException!;
|
||||||
}
|
}
|
||||||
_errorPageBuilder(e).SendResponse(ctx.Response);
|
errorPageBuilder(e).SendResponse(ctx.Response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,18 @@
|
||||||
namespace SimpleHttpServer;
|
namespace SimpleHttpServer;
|
||||||
public class Logger {
|
public class Logger {
|
||||||
private readonly string topic;
|
private readonly string topic;
|
||||||
private readonly TextWriter outWriter;
|
private readonly LogOutputTopic ltopic;
|
||||||
|
private readonly bool printToConsole;
|
||||||
|
private readonly SimpleHttpServerConfiguration.CustomLogMessageHandler? externalLogMsgHandler;
|
||||||
|
|
||||||
internal Logger(string topic) : this(topic, Console.Out) { }
|
internal Logger(LogOutputTopic topic, SimpleHttpServerConfiguration conf) {
|
||||||
internal Logger(string topic, TextWriter? outWriter) {
|
this.topic = Enum.GetName(topic) ?? throw new ArgumentException("The given LogOutputTopic is not defined!");
|
||||||
this.topic = topic;
|
ltopic = topic;
|
||||||
this.outWriter = outWriter ?? Console.Out;
|
externalLogMsgHandler = conf.LogMessageHandler;
|
||||||
|
printToConsole = !conf.DisableLogMessagePrinting;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly object writeLock = new object();
|
private readonly object writeLock = new();
|
||||||
public void Log(string message, LogOutputLevel level) {
|
public void Log(string message, LogOutputLevel level) {
|
||||||
var fgColor = level switch {
|
var fgColor = level switch {
|
||||||
LogOutputLevel.Debug => ConsoleColor.Gray,
|
LogOutputLevel.Debug => ConsoleColor.Gray,
|
||||||
|
|
@ -22,12 +25,15 @@ public class Logger {
|
||||||
_ => throw new NotImplementedException(),
|
_ => throw new NotImplementedException(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (printToConsole)
|
||||||
lock (writeLock) {
|
lock (writeLock) {
|
||||||
var origColor = Console.ForegroundColor;
|
var origColor = Console.ForegroundColor;
|
||||||
Console.ForegroundColor = fgColor;
|
Console.ForegroundColor = fgColor;
|
||||||
outWriter.WriteLine($"[{topic}] {message}");
|
Console.WriteLine($"[{topic}] {message}");
|
||||||
Console.ForegroundColor = origColor;
|
Console.ForegroundColor = origColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
externalLogMsgHandler?.Invoke(ltopic, message, level);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Conditional("DEBUG")]
|
[Conditional("DEBUG")]
|
||||||
|
|
@ -36,6 +42,7 @@ public class Logger {
|
||||||
public void Warning(string message) => Log(message, LogOutputLevel.Warning);
|
public void Warning(string message) => Log(message, LogOutputLevel.Warning);
|
||||||
public void Error(string message) => Log(message, LogOutputLevel.Error);
|
public void Error(string message) => Log(message, LogOutputLevel.Error);
|
||||||
public void Fatal(string message) => Log(message, LogOutputLevel.Fatal);
|
public void Fatal(string message) => Log(message, LogOutputLevel.Fatal);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum LogOutputLevel {
|
public enum LogOutputLevel {
|
||||||
|
|
@ -45,3 +52,10 @@ public enum LogOutputLevel {
|
||||||
Error,
|
Error,
|
||||||
Fatal
|
Fatal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum LogOutputTopic {
|
||||||
|
Main,
|
||||||
|
Request,
|
||||||
|
Security
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,127 @@
|
||||||
using System;
|
using System.Net;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace SimpleHttpServer;
|
namespace SimpleHttpServer;
|
||||||
internal class RequestContext {
|
public class RequestContext : IDisposable {
|
||||||
|
|
||||||
|
public HttpListenerContext ListenerContext { get; }
|
||||||
|
|
||||||
|
private StreamReader? reqReader;
|
||||||
|
public StreamReader ReqReader => reqReader ??= new(ListenerContext.Request.InputStream);
|
||||||
|
|
||||||
|
private StreamWriter? respWriter;
|
||||||
|
public StreamWriter RespWriter => respWriter ??= new(ListenerContext.Response.OutputStream) { NewLine = "\n" };
|
||||||
|
|
||||||
public RequestContext(HttpListenerContext listenerContext) {
|
public RequestContext(HttpListenerContext listenerContext) {
|
||||||
ListenerContext = listenerContext;
|
ListenerContext = listenerContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
public HttpListenerContext ListenerContext { get; }
|
public async Task WriteLineToRespAsync(string resp) => await RespWriter.WriteLineAsync(resp);
|
||||||
|
public async Task WriteToRespAsync(string resp) => await RespWriter.WriteAsync(resp);
|
||||||
|
|
||||||
|
public void SetStatusCode(int status) {
|
||||||
|
ListenerContext.Response.StatusCode = status;
|
||||||
|
ListenerContext.Response.StatusDescription = GetDescriptionFromStatusCode(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetStatusCode(HttpStatusCode status) => SetStatusCode((int) status);
|
||||||
|
|
||||||
|
public void SetStatusCodeAndDispose(int status) {
|
||||||
|
using (this)
|
||||||
|
SetStatusCode(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetStatusCodeAndDispose(HttpStatusCode status) {
|
||||||
|
using (this)
|
||||||
|
SetStatusCode((int) status);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public void SetStatusCodeAndDispose(int status, string description) {
|
||||||
|
using (this) {
|
||||||
|
ListenerContext.Response.StatusCode = status;
|
||||||
|
ListenerContext.Response.StatusDescription = description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public void SetStatusCodeAndDispose(HttpStatusCode status, string description) => SetStatusCodeAndDispose((int) status, description);
|
||||||
|
|
||||||
|
|
||||||
|
void IDisposable.Dispose() {
|
||||||
|
reqReader?.Dispose();
|
||||||
|
respWriter?.Dispose();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// src: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
|
||||||
|
private static string GetDescriptionFromStatusCode(int status) => status switch {
|
||||||
|
100 => "Continue",
|
||||||
|
101 => "Switching Protocols",
|
||||||
|
102 => "Processing",
|
||||||
|
103 => "Early Hints",
|
||||||
|
|
||||||
|
200 => "OK",
|
||||||
|
201 => "Created",
|
||||||
|
202 => "Accepted",
|
||||||
|
203 => "Non-Authoritative Information",
|
||||||
|
204 => "No Content",
|
||||||
|
|
||||||
|
205 => "Reset Content",
|
||||||
|
206 => "Partial Content",
|
||||||
|
207 => "Multi-Status",
|
||||||
|
208 => "Already Reported",
|
||||||
|
226 => "IM Used",
|
||||||
|
|
||||||
|
300 => "Multiple Choices",
|
||||||
|
301 => "Moved Permanently",
|
||||||
|
302 => "Found",
|
||||||
|
303 => "See Other",
|
||||||
|
304 => "Not Modified",
|
||||||
|
305 => "Use Proxy",
|
||||||
|
306 => "Switch Proxy",
|
||||||
|
307 => "Temporary Redirect",
|
||||||
|
308 => "Permanent Redirect",
|
||||||
|
|
||||||
|
400 => "Bad Request",
|
||||||
|
401 => "Unauthorized",
|
||||||
|
402 => "Payment Required",
|
||||||
|
403 => "Forbidden",
|
||||||
|
404 => "Not Found",
|
||||||
|
405 => "Method Not Allowed",
|
||||||
|
406 => "Not Acceptable",
|
||||||
|
407 => "Proxy Authentication Required",
|
||||||
|
408 => "Request Timeout",
|
||||||
|
409 => "Conflict",
|
||||||
|
410 => "Gone",
|
||||||
|
411 => "Length Required",
|
||||||
|
412 => "Precondition Failed",
|
||||||
|
413 => "Payload Too Large",
|
||||||
|
414 => "URI Too Long",
|
||||||
|
415 => "Unsupported Media Type",
|
||||||
|
416 => "Range Not Satisfiable",
|
||||||
|
417 => "Expectation Failed",
|
||||||
|
421 => "Misdirected Request",
|
||||||
|
422 => "Unprocessable Content",
|
||||||
|
423 => "Locked",
|
||||||
|
424 => "Failed Dependency",
|
||||||
|
425 => "Too Early",
|
||||||
|
426 => "Upgrade Required",
|
||||||
|
428 => "Precondition Required",
|
||||||
|
429 => "Too Many Requests",
|
||||||
|
431 => "Request Header Fields Too Large",
|
||||||
|
451 => "Unavailable For Legal Reasons",
|
||||||
|
|
||||||
|
500 => "Internal Server Error",
|
||||||
|
501 => "Not Implemented",
|
||||||
|
502 => "Bad Gateway",
|
||||||
|
503 => "Service Unavailable",
|
||||||
|
504 => "Gateway Timeout",
|
||||||
|
505 => "HTTP Version Not Supported",
|
||||||
|
506 => "Variant Also Negotiates",
|
||||||
|
507 => "Insufficient Storage",
|
||||||
|
508 => "Loop Detected",
|
||||||
|
510 => "Not Extended",
|
||||||
|
511 => "Network Authentication Required",
|
||||||
|
|
||||||
|
_ => "Unknown",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
SimpleHttpServer/SimpleHttpServerConfiguration.cs
Normal file
19
SimpleHttpServer/SimpleHttpServerConfiguration.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
namespace SimpleHttpServer;
|
||||||
|
public class SimpleHttpServerConfiguration {
|
||||||
|
|
||||||
|
public delegate void CustomLogMessageHandler(LogOutputTopic topic, string message, LogOutputLevel logLevel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If set to true, log messages will not be printed to the console, and instead will only be outputted by calling <see cref="LogMessageHandler"/>.
|
||||||
|
/// If set to false, the aforementioned delegate will still be invoked, but messages will still be printed to the console.
|
||||||
|
/// Setting this to false and <see cref="LogMessageHandler"/> to null will effectively disable log output completely.
|
||||||
|
/// </summary>
|
||||||
|
public bool DisableLogMessagePrinting { get; init; } = false;
|
||||||
|
/// <summary>
|
||||||
|
/// See description of <see cref="DisableLogMessagePrinting"/>
|
||||||
|
/// </summary>
|
||||||
|
public CustomLogMessageHandler? LogMessageHandler { get; init; } = null;
|
||||||
|
|
||||||
|
public SimpleHttpServerConfiguration() { }
|
||||||
|
|
||||||
|
}
|
||||||
12
SimpleHttpServer/Types/EndpointInvocationInfo.cs
Normal file
12
SimpleHttpServer/Types/EndpointInvocationInfo.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace SimpleHttpServer.Types;
|
||||||
|
internal struct EndpointInvocationInfo {
|
||||||
|
internal readonly MethodInfo methodInfo;
|
||||||
|
internal readonly List<(string, (Type type, bool isOptional))> queryParameters;
|
||||||
|
|
||||||
|
public EndpointInvocationInfo(MethodInfo methodInfo, List<(string, (Type type, bool isOptional))> queryParameters) {
|
||||||
|
this.methodInfo = methodInfo ?? throw new ArgumentNullException(nameof(methodInfo));
|
||||||
|
this.queryParameters = queryParameters ?? throw new ArgumentNullException(nameof(queryParameters));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace SimpleHttpServer.Types.Exceptions;
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public class AssertionFailedException : Exception {
|
||||||
|
public AssertionFailedException() { }
|
||||||
|
public AssertionFailedException(string message) : base(message) { }
|
||||||
|
public AssertionFailedException(string message, Exception inner) : base(message, inner) { }
|
||||||
|
protected AssertionFailedException(
|
||||||
|
System.Runtime.Serialization.SerializationInfo info,
|
||||||
|
System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
|
||||||
|
namespace SimpleHttpServer.Types.Exceptions;
|
||||||
|
[Serializable]
|
||||||
|
internal class MissingParameterConverterException : Exception {
|
||||||
|
public MissingParameterConverterException() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public MissingParameterConverterException(string? message) : base(message) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public MissingParameterConverterException(string? message, Exception? innerException) : base(message, innerException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
protected MissingParameterConverterException(SerializationInfo info, StreamingContext context) : base(info, context) {
|
||||||
|
}
|
||||||
|
}
|
||||||
5
SimpleHttpServer/Types/IParameterConverter.cs
Normal file
5
SimpleHttpServer/Types/IParameterConverter.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
namespace SimpleHttpServer;
|
||||||
|
|
||||||
|
public interface IParameterConverter {
|
||||||
|
bool TryConvertFromString(string value, out object result);
|
||||||
|
}
|
||||||
21
SimpleHttpServer/Types/ParameterAttribute.cs
Normal file
21
SimpleHttpServer/Types/ParameterAttribute.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace SimpleHttpServer.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specifies the name of a http endpoint parameter. If this attribute is not specified, the variable name is used instead.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)]
|
||||||
|
public sealed class ParameterAttribute : Attribute {
|
||||||
|
// See the attribute guidelines at
|
||||||
|
// http://go.microsoft.com/fwlink/?LinkId=85236
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
public bool IsOptional { get; }
|
||||||
|
public ParameterAttribute(string name, bool isOptional = false) {
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) {
|
||||||
|
throw new ArgumentException($"'{nameof(name)}' cannot be null or whitespace.", nameof(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
Name = name;
|
||||||
|
IsOptional = isOptional;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
namespace SimpleHttpServer.Types.ParameterConverters;
|
||||||
|
internal class BoolParsableParameterConverter : IParameterConverter {
|
||||||
|
public bool TryConvertFromString(string value, out object result) {
|
||||||
|
var normalized = value.ToLowerInvariant();
|
||||||
|
if (normalized is "true" or "1") {
|
||||||
|
result = true;
|
||||||
|
return true;
|
||||||
|
} else if (normalized is "false" or "0") {
|
||||||
|
result = false;
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
result = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace SimpleHttpServer.Types.ParameterConverters;
|
||||||
|
internal class ParsableParameterConverter<T> : IParameterConverter where T : IParsable<T> {
|
||||||
|
public bool TryConvertFromString(string value, [NotNullWhen(true)] out object result) {
|
||||||
|
bool ok = T.TryParse(value, null, out T? res);
|
||||||
|
result = res!;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,52 @@
|
||||||
using SimpleHttpServer;
|
using SimpleHttpServer;
|
||||||
using SimpleHttpServerTest.SimpleTestServer;
|
|
||||||
|
|
||||||
namespace SimpleHttpServerTest;
|
namespace SimpleHttpServerTest;
|
||||||
|
|
||||||
[TestClass]
|
[TestClass]
|
||||||
public class SimpleServerTest {
|
public class SimpleServerTest {
|
||||||
|
|
||||||
|
const int PORT = 8833;
|
||||||
|
|
||||||
|
private HttpServer? activeServer = null;
|
||||||
|
private static string GetRequestPath(string url) => $"http://localhost:{PORT}/{url.TrimStart('/')}";
|
||||||
|
|
||||||
|
[TestInitialize]
|
||||||
|
public void Init() {
|
||||||
|
var conf = new SimpleHttpServerConfiguration();
|
||||||
|
if (activeServer != null)
|
||||||
|
throw new InvalidOperationException("Tried to create another httpserver instance when an existing one was already running.");
|
||||||
|
|
||||||
|
Console.WriteLine("Starting server...");
|
||||||
|
activeServer = new HttpServer(PORT, conf);
|
||||||
|
activeServer.RegisterEndpointsFromType<TestEndpoints>();
|
||||||
|
activeServer.Start();
|
||||||
|
|
||||||
|
Console.WriteLine("Server started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[TestCleanup]
|
||||||
|
public async Task Cleanup() {
|
||||||
|
var ctokSrc = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
||||||
|
if (activeServer == null) {
|
||||||
|
throw new InvalidOperationException("Tried to shut down server when an existing one wasnt runnign yet");
|
||||||
|
}
|
||||||
|
await Console.Out.WriteLineAsync("Shutting down server...");
|
||||||
|
await activeServer.StopAsync(ctokSrc.Token);
|
||||||
|
await Console.Out.WriteLineAsync("Shutdown finished.");
|
||||||
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void RunTestServer() {
|
public async Task CheckSimpleServe() {
|
||||||
var server = HttpServer.Create(8833, "localhost", typeof(SimpleEndpointDefinition));
|
using var hc = new HttpClient();
|
||||||
server.Start();
|
await hc.GetStringAsync(GetRequestPath("/"));
|
||||||
Console.WriteLine("press any key to exit");
|
}
|
||||||
Assert.IsTrue(server.Shutdown(10000), "server did not exit gracefully");
|
|
||||||
|
public class TestEndpoints {
|
||||||
|
|
||||||
|
[HttpEndpoint(HttpRequestType.GET, "/", "index.html", "amogus.html")]
|
||||||
|
public static async Task Index(RequestContext req) {
|
||||||
|
await req.RespWriter.WriteLineAsync("It works!");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
namespace SimpleHttpServerTest.SimpleTestServer;
|
|
||||||
internal class LdAuthorizer {
|
|
||||||
private readonly LoginProvider lprov;
|
|
||||||
|
|
||||||
internal LdAuthorizer(LoginProvider lprov) {
|
|
||||||
this.lprov = lprov;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
namespace SimpleHttpServerTest.SimpleTestServer;
|
|
||||||
internal class SimpleEndpointDefinition {
|
|
||||||
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user