Huge refactor; Passing tests

This commit is contained in:
GHXX 2024-01-13 01:25:32 +01:00
parent a03fafebcf
commit 7bc6086509
18 changed files with 500 additions and 208 deletions

View 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);
}

View 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) { }
}

View File

@ -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) { }
}

View File

@ -1,5 +1,8 @@
using SimpleHttpServer.Internal;
using SimpleHttpServer.Types;
using SimpleHttpServer.Types.Exceptions;
using SimpleHttpServer.Types.ParameterConverters;
using System.Net;
using System.Numerics;
using System.Reflection;
namespace SimpleHttpServer;
@ -8,188 +11,200 @@ public sealed class HttpServer {
public int Port { get; }
private readonly CancellationTokenSource ctokSrc;
private readonly HttpListener listener;
private Task listenerTask;
private Logger logger;
private Task? listenerTask;
private readonly Logger logger;
private readonly SimpleHttpServerConfiguration conf;
private bool shutdown = false;
public HttpServer(int port, TextWriter? logRedirect = null) {
ctokSrc = new();
public HttpServer(int port, SimpleHttpServerConfiguration configuration) {
Port = port;
conf = configuration;
listener = new HttpListener();
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}...");
Assert(listenerTask == null, "Server was already started!");
listener.Start();
listenerTask = Task.Run(GetContextLoop);
listenerTask = Task.Run(GetContextLoopAsync);
logger.Information($"Ready to handle requests!");
await Task.Yield();
}
public async Task GetContextLoop() {
while (true) {
public async Task StopAsync(CancellationToken ctok) {
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 {
var ctx = await listener.GetContextAsync();
_ = ProcessRequestAsync(ctx);
} catch (Exception ex) {
} finally {
logger.Fatal($"Caught otherwise uncaught exception in GetContextLoop:\n{ex}");
}
}
}
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);
foreach (var (mi, attrib) in t.GetMethods()
.ToDictionary(x => x, x => x.GetCustomAttributes(typeof(HttpRoute<>)))
.Where(x => x.Value.Any()).ToDictionary(x => x.Key, x => x.Value.Single() as HttpRoute<IAuthorizer> ?? throw new InvalidCastException()))
{
simpleEndpoints.Add((attrib.Location, attrib.RequestMethod), mi.CreateDelegate<Action<RequestContext>>());
.ToDictionary(x => x, x => x.GetCustomAttributes(typeof(HttpEndpointAttribute<>)))
.Where(x => x.Value.Any()).ToDictionary(x => x.Key, x => (HttpEndpointAttribute) x.Value.Single())) {
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;
private readonly Dictionary<(string path, HttpRequestType rType), HttpEndpointHandler> _plainEndpoints = new();
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('{');
foreach (var location in attrib.Locations) {
int idx = location.IndexOf('{');
if (idx >= 0) {
// this path contains path parameters
throw new NotImplementedException("Implement path parameters!");
}
var qparams = new List<(string, Type)>();
}
}
return null!;
throw new NotImplementedException("Path parameters are not yet implemented!");
}
public void Shutdown() {
Shutdown(-1);
var reqMethod = Enum.GetName(attrib.RequestMethod) ?? throw new ArgumentException("Request method was undefined");
simpleEndpointMethodInfos.Add((location, reqMethod), new EndpointInvocationInfo(mi, qparams));
}
}
}
public bool Shutdown() {
if (_listenerThread == null)
throw new InvalidOperationException("Cannot shut down HttpServer that has not been started");
private readonly Dictionary<Type, IParameterConverter> stringToTypeParameterConverters = new();
}
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() {
private async Task ProcessRequestAsync(HttpListenerContext ctx) {
try {
while (true) {
var ctx = _listener.GetContext();
var decUri = WebUtility.UrlDecode(ctx.Request.RawUrl)!; // TODO add path escape countermeasures+unittests
var splitted = decUri.Split('?', 2, StringSplitOptions.None);
var path = WebUtility.UrlDecode(splitted.First());
ThreadPool.QueueUserWorkItem((localCtx) => {
HttpRequestType type;
if (!Enum.TryParse(localCtx.Request.HttpMethod, out type)) {
Default404(localCtx).SendResponse(localCtx.Response);
using var rc = new RequestContext(ctx);
if (simpleEndpointMethodInfos.TryGetValue((decUri, ctx.Request.HttpMethod.ToUpperInvariant()), out var endpointInvocationInfo)) {
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;
}
var path = localCtx.Request.Url!.LocalPath.Replace('\\', '/');
HttpEndpointHandler? ep = null;
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);
if (!parsedQParams.TryAdd(WebUtility.UrlDecode(queryKVSplitted[0]), WebUtility.UrlDecode(queryKVSplitted[1]))) {
rc.SetStatusCodeAndDispose(HttpStatusCode.BadRequest, "Duplicate request URL parameters");
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) {
if (forceThrow) {
throw new Exception(msg);
for (int i = 0; i < qparams.Count;) {
var (qparamName, qparamInfo) = qparams[i];
i++;
if (parsedQParams.TryGetValue(qparamName, out var qparamValue)) {
if (stringToTypeParameterConverters[qparamInfo.type].TryConvertFromString(qparamValue, out object objRes)) {
convertedQParamValues[i] = objRes;
} 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>
""");
}
}

View File

@ -8,13 +8,13 @@ namespace SimpleHttpServer.Internal;
internal class HttpEndpointHandler {
private static readonly DefaultAuthorizer defaultAuth = new();
private readonly IAuthorizer _auth;
private readonly MethodInfo _handler;
private readonly Dictionary<string, (int pindex, Type type, int pparamIdx)> _params;
private readonly Func<Exception, HttpResponseBuilder> _errorPageBuilder;
private readonly IAuthorizer auth;
private readonly MethodInfo handler;
private readonly Dictionary<string, (int pindex, Type type, int pparamIdx)> @params;
private readonly Func<Exception, HttpResponseBuilder> errorPageBuilder;
public HttpEndpointHandler() {
_auth = defaultAuth;
auth = defaultAuth;
}
public HttpEndpointHandler(IAuthorizer auth) {
@ -23,14 +23,14 @@ internal class HttpEndpointHandler {
public virtual void Handle(HttpListenerContext ctx) {
try {
var (isAuth, authData) = _auth.IsAuthenticated(ctx);
var (isAuth, authData) = auth.IsAuthenticated(ctx);
if (!isAuth) {
throw new HttpHandlingException(401, "Authorization required!");
}
// collect parameters
var invokeParams = new object?[_params.Count + 1];
var set = new BitArray(_params.Count);
var invokeParams = new object?[@params.Count + 1];
var set = new BitArray(@params.Count);
invokeParams[0] = ctx;
// read pparams
@ -38,8 +38,8 @@ internal class HttpEndpointHandler {
// read qparams
var qst = ctx.Request.QueryString;
foreach (var qelem in ctx.Request.QueryString.AllKeys) {
if (_params.ContainsKey(qelem!)) {
var (pindex, type, isPParam) = _params[qelem!];
if (@params.ContainsKey(qelem!)) {
var (pindex, type, isPParam) = @params[qelem!];
if (type == typeof(string)) {
invokeParams[pindex] = ctx.Request.QueryString[qelem!];
set.Set(pindex - 1, true);
@ -54,20 +54,20 @@ internal class HttpEndpointHandler {
}
// fill with defaults
foreach (var p in _params) {
foreach (var p in @params) {
if (!set.Get(p.Value.pindex)) {
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);
} catch (Exception e) {
if (e is TargetInvocationException tex) {
e = tex.InnerException!;
}
_errorPageBuilder(e).SendResponse(ctx.Response);
errorPageBuilder(e).SendResponse(ctx.Response);
}
}
}

View File

@ -3,15 +3,18 @@
namespace SimpleHttpServer;
public class Logger {
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(string topic, TextWriter? outWriter) {
this.topic = topic;
this.outWriter = outWriter ?? Console.Out;
internal Logger(LogOutputTopic topic, SimpleHttpServerConfiguration conf) {
this.topic = Enum.GetName(topic) ?? throw new ArgumentException("The given LogOutputTopic is not defined!");
ltopic = topic;
externalLogMsgHandler = conf.LogMessageHandler;
printToConsole = !conf.DisableLogMessagePrinting;
}
private readonly object writeLock = new object();
private readonly object writeLock = new();
public void Log(string message, LogOutputLevel level) {
var fgColor = level switch {
LogOutputLevel.Debug => ConsoleColor.Gray,
@ -22,12 +25,15 @@ public class Logger {
_ => throw new NotImplementedException(),
};
if (printToConsole)
lock (writeLock) {
var origColor = Console.ForegroundColor;
Console.ForegroundColor = fgColor;
outWriter.WriteLine($"[{topic}] {message}");
Console.WriteLine($"[{topic}] {message}");
Console.ForegroundColor = origColor;
}
externalLogMsgHandler?.Invoke(ltopic, message, level);
}
[Conditional("DEBUG")]
@ -36,6 +42,7 @@ public class Logger {
public void Warning(string message) => Log(message, LogOutputLevel.Warning);
public void Error(string message) => Log(message, LogOutputLevel.Error);
public void Fatal(string message) => Log(message, LogOutputLevel.Fatal);
}
public enum LogOutputLevel {
@ -45,3 +52,10 @@ public enum LogOutputLevel {
Error,
Fatal
}
public enum LogOutputTopic {
Main,
Request,
Security
}

View File

@ -1,15 +1,127 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Net;
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) {
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",
};
}

View 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() { }
}

View 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));
}
}

View File

@ -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) { }
}

View File

@ -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) {
}
}

View File

@ -0,0 +1,5 @@
namespace SimpleHttpServer;
public interface IParameterConverter {
bool TryConvertFromString(string value, out object result);
}

View 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;
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -1,15 +1,52 @@
using SimpleHttpServer;
using SimpleHttpServerTest.SimpleTestServer;
namespace SimpleHttpServerTest;
[TestClass]
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]
public void RunTestServer() {
var server = HttpServer.Create(8833, "localhost", typeof(SimpleEndpointDefinition));
server.Start();
Console.WriteLine("press any key to exit");
Assert.IsTrue(server.Shutdown(10000), "server did not exit gracefully");
public async Task CheckSimpleServe() {
using var hc = new HttpClient();
await hc.GetStringAsync(GetRequestPath("/"));
}
public class TestEndpoints {
[HttpEndpoint(HttpRequestType.GET, "/", "index.html", "amogus.html")]
public static async Task Index(RequestContext req) {
await req.RespWriter.WriteLineAsync("It works!");
}
}
}

View File

@ -1,10 +0,0 @@
namespace SimpleHttpServerTest.SimpleTestServer;
internal class LdAuthorizer {
private readonly LoginProvider lprov;
internal LdAuthorizer(LoginProvider lprov) {
this.lprov = lprov;
}
}

View File

@ -1,4 +0,0 @@
namespace SimpleHttpServerTest.SimpleTestServer;
internal class SimpleEndpointDefinition {
}