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/HttpEndpoint.cs b/SimpleHttpServer/HttpEndpoint.cs deleted file mode 100644 index 8e673a9..0000000 --- a/SimpleHttpServer/HttpEndpoint.cs +++ /dev/null @@ -1,22 +0,0 @@ -using SimpleHttpServer.Internal; - -namespace SimpleHttpServer; - -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] -public class HttpEndpoint : Attribute where T : IAuthorizer { - - public HttpRequestType Type { get; private set; } - public string Location { get; private set; } - public Type Authorizer { get; private set; } - - public HttpEndpoint(HttpRequestType type, string location) { - Type = type; - Location = location; - Authorizer = typeof(T); - } -} - -[AttributeUsage(AttributeTargets.Method)] -public class HttpEndpoint : HttpEndpoint { - public HttpEndpoint(HttpRequestType type, string location) : base(type, location) { } -} 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/HttpServer.cs b/SimpleHttpServer/HttpServer.cs index 6c98a26..4c20c37 100644 --- a/SimpleHttpServer/HttpServer.cs +++ b/SimpleHttpServer/HttpServer.cs @@ -1,134 +1,210 @@ -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; public sealed class HttpServer { - private Thread? _listenerThread; - 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 int Port { get; } - public string Url { get; private set; } - public Func Default404 { get; private set; } + private readonly HttpListener listener; + private Task? listenerTask; + private readonly Logger logger; + private readonly SimpleHttpServerConfiguration conf; + private bool shutdown = false; - 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(HttpEndpoint<>))) - .Select(x => (HttpEndpoint) 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) { - // this path contains path parameters - throw new NotImplementedException("Implement path parameters!"); - } - var qparams = new List<(string, Type)>(); - } - } - return null!; - } - - - public void Shutdown() { - Shutdown(-1); - } - - 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 HttpServer(int port, SimpleHttpServerConfiguration configuration) { + Port = port; + conf = configuration; + listener = new HttpListener(); + listener.Prefixes.Add($"http://localhost:{port}/"); + logger = new(LogOutputTopic.Main, conf); } public void Start() { - _listenerThread = new Thread(RunServer); - _listener.Start(); - _listenerThread.Start(); + logger.Information($"Starting on port {Port}..."); + Assert(listenerTask == null, "Server was already started!"); + listener.Start(); + listenerTask = Task.Run(GetContextLoopAsync); + logger.Information($"Ready to handle requests!"); } + 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); + } - private void RunServer() { + public async Task GetContextLoopAsync() { + while (!shutdown) { + try { + var ctx = await listener.GetContextAsync(); + _ = ProcessRequestAsync(ctx); + } catch (Exception ex) { + logger.Fatal($"Caught otherwise uncaught exception in GetContextLoop:\n{ex}"); + } + } + } + + 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(); + + var t = typeof(T); + foreach (var (mi, attrib) in t.GetMethods() + .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(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)!"); + } + } + + foreach (var location in attrib.Locations) { + int idx = location.IndexOf('{'); + if (idx >= 0) { + // this path contains path parameters + throw new NotImplementedException("Path parameters are not yet implemented!"); + } + + var reqMethod = Enum.GetName(attrib.RequestMethod) ?? throw new ArgumentException("Request method was undefined"); + simpleEndpointMethodInfos.Add((location, reqMethod), new EndpointInvocationInfo(mi, qparams)); + } + } + } + + private readonly Dictionary stringToTypeParameterConverters = new(); + + + private async Task ProcessRequestAsync(HttpListenerContext ctx) { try { - for (; ; ) { - 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 new file mode 100644 index 0000000..6f3b2ae --- /dev/null +++ b/SimpleHttpServer/Logger.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; + +namespace SimpleHttpServer; +public class Logger { + private readonly string topic; + private readonly LogOutputTopic ltopic; + private readonly bool printToConsole; + private readonly SimpleHttpServerConfiguration.CustomLogMessageHandler? externalLogMsgHandler; + + 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(); + public void Log(string message, LogOutputLevel level) { + var fgColor = level switch { + LogOutputLevel.Debug => ConsoleColor.Gray, + LogOutputLevel.Information => ConsoleColor.White, + LogOutputLevel.Warning => ConsoleColor.Yellow, + LogOutputLevel.Error => ConsoleColor.Red, + LogOutputLevel.Fatal => ConsoleColor.Magenta, + _ => throw new NotImplementedException(), + }; + + 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")] + public void Debug(string message) => Log(message, LogOutputLevel.Debug); + public void Information(string message) => Log(message, LogOutputLevel.Information); + 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 { + Debug, + Information, + Warning, + Error, + Fatal +} + +public enum LogOutputTopic { + Main, + Request, + Security +} + diff --git a/SimpleHttpServer/RequestContext.cs b/SimpleHttpServer/RequestContext.cs new file mode 100644 index 0000000..c6f5427 --- /dev/null +++ b/SimpleHttpServer/RequestContext.cs @@ -0,0 +1,127 @@ +using System.Net; + +namespace SimpleHttpServer; +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 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 { - -}