From 7bc608650932f4e8a5ef764ce7bd7c1fd417dbc6 Mon Sep 17 00:00:00 2001 From: GHXX Date: Sat, 13 Jan 2024 01:25:32 +0100 Subject: [PATCH] Huge refactor; Passing tests --- SimpleHttpServer/GlobalUsings.cs | 17 ++ SimpleHttpServer/HttpEndpointAttribute.cs | 22 ++ SimpleHttpServer/HttpRoute.cs | 22 -- SimpleHttpServer/HttpServer.cs | 279 +++++++++--------- .../Internal/HttpEndpointHandler.cs | 26 +- SimpleHttpServer/Logger.cs | 40 ++- SimpleHttpServer/RequestContext.cs | 128 +++++++- .../SimpleHttpServerConfiguration.cs | 19 ++ .../Types/EndpointInvocationInfo.cs | 12 + .../Exceptions/AssertionFailedException.cs | 11 + .../MissingParameterConverterException.cs | 17 ++ SimpleHttpServer/Types/IParameterConverter.cs | 5 + SimpleHttpServer/Types/ParameterAttribute.cs | 21 ++ .../BoolParsableParameterConverter.cs | 16 + .../ParsableParameterConverter.cs | 10 + SimpleHttpServerTest/SimpleServerTest.cs | 49 ++- .../SimpleTestServer/LdAuthorizer.cs | 10 - .../SimpleEndpointDefinition.cs | 4 - 18 files changed, 500 insertions(+), 208 deletions(-) create mode 100644 SimpleHttpServer/GlobalUsings.cs create mode 100644 SimpleHttpServer/HttpEndpointAttribute.cs delete mode 100644 SimpleHttpServer/HttpRoute.cs create mode 100644 SimpleHttpServer/SimpleHttpServerConfiguration.cs create mode 100644 SimpleHttpServer/Types/EndpointInvocationInfo.cs create mode 100644 SimpleHttpServer/Types/Exceptions/AssertionFailedException.cs create mode 100644 SimpleHttpServer/Types/Exceptions/MissingParameterConverterException.cs create mode 100644 SimpleHttpServer/Types/IParameterConverter.cs create mode 100644 SimpleHttpServer/Types/ParameterAttribute.cs create mode 100644 SimpleHttpServer/Types/ParameterConverters/BoolParsableParameterConverter.cs create mode 100644 SimpleHttpServer/Types/ParameterConverters/ParsableParameterConverter.cs delete mode 100644 SimpleHttpServerTest/SimpleTestServer/LdAuthorizer.cs delete mode 100644 SimpleHttpServerTest/SimpleTestServer/SimpleEndpointDefinition.cs diff --git a/SimpleHttpServer/GlobalUsings.cs b/SimpleHttpServer/GlobalUsings.cs new file mode 100644 index 0000000..89a0ee2 --- /dev/null +++ b/SimpleHttpServer/GlobalUsings.cs @@ -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); +} diff --git a/SimpleHttpServer/HttpEndpointAttribute.cs b/SimpleHttpServer/HttpEndpointAttribute.cs new file mode 100644 index 0000000..7396a87 --- /dev/null +++ b/SimpleHttpServer/HttpEndpointAttribute.cs @@ -0,0 +1,22 @@ +using SimpleHttpServer.Internal; + +namespace SimpleHttpServer; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class HttpEndpointAttribute : 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 { + public HttpEndpointAttribute(HttpRequestType type, params string[] locations) : base(type, locations) { } +} diff --git a/SimpleHttpServer/HttpRoute.cs b/SimpleHttpServer/HttpRoute.cs deleted file mode 100644 index 56cda41..0000000 --- a/SimpleHttpServer/HttpRoute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using SimpleHttpServer.Internal; - -namespace SimpleHttpServer; - -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] -public class HttpRoute : 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 { - public HttpEndpoint(HttpRequestType type, string location) : base(type, location) { } -} diff --git a/SimpleHttpServer/HttpServer.cs b/SimpleHttpServer/HttpServer.cs index b6fc876..4c20c37 100644 --- a/SimpleHttpServer/HttpServer.cs +++ b/SimpleHttpServer/HttpServer.cs @@ -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() where T : IParsable { + stringToTypeParameterConverters.Add(typeof(T), new ParsableParameterConverter()); + } + + stringToTypeParameterConverters.Add(typeof(bool), new BoolParsableParameterConverter()); + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); + + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); + + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); + RegisterConverter(); } + private readonly Dictionary<(string path, string rType), EndpointInvocationInfo> simpleEndpointMethodInfos = new(); + private static readonly Type[] expectedEndpointParameterTypes = new[] { typeof(RequestContext) }; + public void RegisterEndpointsFromType() { + if (simpleEndpointMethodInfos.Count == 0) + RegisterDefaultConverters(); - private readonly Dictionary<(string path, HttpRequestType rType), Action> simpleEndpoints = new(); - public void RegisterRoutesFromType() { 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 ?? throw new InvalidCastException())) - { - simpleEndpoints.Add((attrib.Location, attrib.RequestMethod), mi.CreateDelegate>()); - } - } + .ToDictionary(x => x, x => x.GetCustomAttributes(typeof(HttpEndpointAttribute<>))) + .Where(x => x.Value.Any()).ToDictionary(x => x.Key, x => (HttpEndpointAttribute) x.Value.Single())) { - private readonly HttpListener _listener; - private readonly Dictionary<(string path, HttpRequestType rType), HttpEndpointHandler> _plainEndpoints = new(); - private readonly Dictionary<(string path, HttpRequestType rType), HttpEndpointHandler> _pparamEndpoints = new(); + string GetFancyMethodName() => mi.DeclaringType!.FullName + "#" + mi.Name; - public string Url { get; private set; } - public Func Default404 { get; private set; } + Assert(mi.IsStatic, $"Method tagged with HttpEndpointAttribute must be static! ({GetFancyMethodName()})"); + Assert(mi.IsPublic, $"Method tagged with HttpEndpointAttribute must be public! ({GetFancyMethodName()})"); - public static HttpServer Create(int port, string url, params Type[] apiDefinitions) => Create(Console.Error, port, url, false, apiDefinitions); + 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}."); + } - public static HttpServer Create(TextWriter error, int port, string url, bool throwOnInvalidEndpoint, params Type[] apiDefinitions) { - var epDict = new Dictionary<(string, HttpRequestType), HttpEndpointHandler>(); + Assert(mi.ReturnType == typeof(Task), $"Return type of {GetFancyMethodName()} is not {typeof(Task)}!"); - 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) x) - .SingleOrDefault(); - if (attrib == null) { - continue; + 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(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)!"); } + } - // 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!"); + throw new NotImplementedException("Path parameters are not yet implemented!"); } - var qparams = new List<(string, Type)>(); + + var reqMethod = Enum.GetName(attrib.RequestMethod) ?? throw new ArgumentException("Request method was undefined"); + simpleEndpointMethodInfos.Add((location, reqMethod), new EndpointInvocationInfo(mi, qparams)); } } - return null!; } - - public void Shutdown() { - Shutdown(-1); - } - - public bool Shutdown() { - if (_listenerThread == null) - throw new InvalidOperationException("Cannot shut down HttpServer that has not been started"); + private readonly Dictionary 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); - 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; - } + + 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(); + 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; } - 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); + + 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 { + 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 (ThreadInterruptedException) { - // this can only be reached when listener.GetContext is interrupted - // safely exit main loop + + } catch (Exception ex) { + logger.Fatal($"Caught otherwise uncaught exception while ProcessingRequest:\n{ex}"); } } - private static void PrintErrorOrThrow(TextWriter error, MethodInfo method, bool forceThrow, string msg) { - if (forceThrow) { - throw new Exception(msg); - } else { - error.WriteLine($"> {msg}\n skipping {GetFancyMethodName(method)} ..."); - } - } - private static string GetFancyMethodName(MethodInfo method) => method.DeclaringType!.Name + "#" + method.Name; + private static async Task HandleDefaultErrorPageAsync(RequestContext ctx, int errorCode) { + await ctx.WriteLineToRespAsync($""" + +

Oh no, and error occurred!

+

Code: {errorCode}

+ + """); + } } \ No newline at end of file diff --git a/SimpleHttpServer/Internal/HttpEndpointHandler.cs b/SimpleHttpServer/Internal/HttpEndpointHandler.cs index 63bf7bc..1f3396d 100644 --- a/SimpleHttpServer/Internal/HttpEndpointHandler.cs +++ b/SimpleHttpServer/Internal/HttpEndpointHandler.cs @@ -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 _params; - private readonly Func _errorPageBuilder; + private readonly IAuthorizer auth; + private readonly MethodInfo handler; + private readonly Dictionary @params; + private readonly Func 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); } } } diff --git a/SimpleHttpServer/Logger.cs b/SimpleHttpServer/Logger.cs index 84da54d..6f3b2ae 100644 --- a/SimpleHttpServer/Logger.cs +++ b/SimpleHttpServer/Logger.cs @@ -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(), }; - lock (writeLock) { - var origColor = Console.ForegroundColor; - Console.ForegroundColor = fgColor; - outWriter.WriteLine($"[{topic}] {message}"); - Console.ForegroundColor = origColor; - } + if (printToConsole) + lock (writeLock) { + var origColor = Console.ForegroundColor; + Console.ForegroundColor = fgColor; + 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 { @@ -44,4 +51,11 @@ public enum LogOutputLevel { Warning, Error, Fatal -} \ No newline at end of file +} + +public enum LogOutputTopic { + Main, + Request, + Security +} + diff --git a/SimpleHttpServer/RequestContext.cs b/SimpleHttpServer/RequestContext.cs index 8e3f7de..c6f5427 100644 --- a/SimpleHttpServer/RequestContext.cs +++ b/SimpleHttpServer/RequestContext.cs @@ -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", + }; } diff --git a/SimpleHttpServer/SimpleHttpServerConfiguration.cs b/SimpleHttpServer/SimpleHttpServerConfiguration.cs new file mode 100644 index 0000000..64e4e63 --- /dev/null +++ b/SimpleHttpServer/SimpleHttpServerConfiguration.cs @@ -0,0 +1,19 @@ +namespace SimpleHttpServer; +public class SimpleHttpServerConfiguration { + + public delegate void CustomLogMessageHandler(LogOutputTopic topic, string message, LogOutputLevel logLevel); + + /// + /// If set to true, log messages will not be printed to the console, and instead will only be outputted by calling . + /// 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 to null will effectively disable log output completely. + /// + public bool DisableLogMessagePrinting { get; init; } = false; + /// + /// See description of + /// + public CustomLogMessageHandler? LogMessageHandler { get; init; } = null; + + public SimpleHttpServerConfiguration() { } + +} diff --git a/SimpleHttpServer/Types/EndpointInvocationInfo.cs b/SimpleHttpServer/Types/EndpointInvocationInfo.cs new file mode 100644 index 0000000..ed0e80c --- /dev/null +++ b/SimpleHttpServer/Types/EndpointInvocationInfo.cs @@ -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)); + } +} diff --git a/SimpleHttpServer/Types/Exceptions/AssertionFailedException.cs b/SimpleHttpServer/Types/Exceptions/AssertionFailedException.cs new file mode 100644 index 0000000..ea18d05 --- /dev/null +++ b/SimpleHttpServer/Types/Exceptions/AssertionFailedException.cs @@ -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) { } +} diff --git a/SimpleHttpServer/Types/Exceptions/MissingParameterConverterException.cs b/SimpleHttpServer/Types/Exceptions/MissingParameterConverterException.cs new file mode 100644 index 0000000..8ceaa86 --- /dev/null +++ b/SimpleHttpServer/Types/Exceptions/MissingParameterConverterException.cs @@ -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) { + } +} \ No newline at end of file diff --git a/SimpleHttpServer/Types/IParameterConverter.cs b/SimpleHttpServer/Types/IParameterConverter.cs new file mode 100644 index 0000000..f64f002 --- /dev/null +++ b/SimpleHttpServer/Types/IParameterConverter.cs @@ -0,0 +1,5 @@ +namespace SimpleHttpServer; + +public interface IParameterConverter { + bool TryConvertFromString(string value, out object result); +} \ No newline at end of file diff --git a/SimpleHttpServer/Types/ParameterAttribute.cs b/SimpleHttpServer/Types/ParameterAttribute.cs new file mode 100644 index 0000000..b3f4148 --- /dev/null +++ b/SimpleHttpServer/Types/ParameterAttribute.cs @@ -0,0 +1,21 @@ +namespace SimpleHttpServer.Types; + +/// +/// Specifies the name of a http endpoint parameter. If this attribute is not specified, the variable name is used instead. +/// +[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; + } +} diff --git a/SimpleHttpServer/Types/ParameterConverters/BoolParsableParameterConverter.cs b/SimpleHttpServer/Types/ParameterConverters/BoolParsableParameterConverter.cs new file mode 100644 index 0000000..380749d --- /dev/null +++ b/SimpleHttpServer/Types/ParameterConverters/BoolParsableParameterConverter.cs @@ -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; + } + } +} diff --git a/SimpleHttpServer/Types/ParameterConverters/ParsableParameterConverter.cs b/SimpleHttpServer/Types/ParameterConverters/ParsableParameterConverter.cs new file mode 100644 index 0000000..3908966 --- /dev/null +++ b/SimpleHttpServer/Types/ParameterConverters/ParsableParameterConverter.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SimpleHttpServer.Types.ParameterConverters; +internal class ParsableParameterConverter : IParameterConverter where T : IParsable { + public bool TryConvertFromString(string value, [NotNullWhen(true)] out object result) { + bool ok = T.TryParse(value, null, out T? res); + result = res!; + return ok; + } +} diff --git a/SimpleHttpServerTest/SimpleServerTest.cs b/SimpleHttpServerTest/SimpleServerTest.cs index 2dc83f0..20835ad 100644 --- a/SimpleHttpServerTest/SimpleServerTest.cs +++ b/SimpleHttpServerTest/SimpleServerTest.cs @@ -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(); + 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!"); + } } } \ No newline at end of file diff --git a/SimpleHttpServerTest/SimpleTestServer/LdAuthorizer.cs b/SimpleHttpServerTest/SimpleTestServer/LdAuthorizer.cs deleted file mode 100644 index d4d95d2..0000000 --- a/SimpleHttpServerTest/SimpleTestServer/LdAuthorizer.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace SimpleHttpServerTest.SimpleTestServer; -internal class LdAuthorizer { - private readonly LoginProvider lprov; - - internal LdAuthorizer(LoginProvider lprov) { - this.lprov = lprov; - } - - -} diff --git a/SimpleHttpServerTest/SimpleTestServer/SimpleEndpointDefinition.cs b/SimpleHttpServerTest/SimpleTestServer/SimpleEndpointDefinition.cs deleted file mode 100644 index ba56883..0000000 --- a/SimpleHttpServerTest/SimpleTestServer/SimpleEndpointDefinition.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace SimpleHttpServerTest.SimpleTestServer; -internal class SimpleEndpointDefinition { - -}