commit 05b722e513d567e1baf3b90bfcd835e669d1e153 Author: 00asdf Date: Thu Nov 30 17:31:19 2023 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4a3c31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vs/ +*/obj/ +*/bin/ \ No newline at end of file diff --git a/SimpleHttpServer.sln b/SimpleHttpServer.sln new file mode 100644 index 0000000..2407b75 --- /dev/null +++ b/SimpleHttpServer.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33213.308 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleHttpServer", "SimpleHttpServer\SimpleHttpServer.csproj", "{36394C2C-AA78-4009-A0EB-13DD78556F56}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleHttpServerTest", "SimpleHttpServerTest\SimpleHttpServerTest.csproj", "{B5D81497-B28F-4EE6-9988-B6F4CA82FFE3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {36394C2C-AA78-4009-A0EB-13DD78556F56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {36394C2C-AA78-4009-A0EB-13DD78556F56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {36394C2C-AA78-4009-A0EB-13DD78556F56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {36394C2C-AA78-4009-A0EB-13DD78556F56}.Release|Any CPU.Build.0 = Release|Any CPU + {B5D81497-B28F-4EE6-9988-B6F4CA82FFE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5D81497-B28F-4EE6-9988-B6F4CA82FFE3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5D81497-B28F-4EE6-9988-B6F4CA82FFE3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5D81497-B28F-4EE6-9988-B6F4CA82FFE3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EEC27661-6BF4-459C-AAEB-E0DE61D96E6B} + EndGlobalSection +EndGlobal diff --git a/SimpleHttpServer/DefaultAuthorizer.cs b/SimpleHttpServer/DefaultAuthorizer.cs new file mode 100644 index 0000000..0ab5b63 --- /dev/null +++ b/SimpleHttpServer/DefaultAuthorizer.cs @@ -0,0 +1,8 @@ +using System.Net; + +namespace SimpleHttpServer; + +public sealed class DefaultAuthorizer : IAuthorizer +{ + public (bool auth, object? data) IsAuthenticated(HttpListenerContext contect) => (true, null); +} diff --git a/SimpleHttpServer/HttpEndpoint.cs b/SimpleHttpServer/HttpEndpoint.cs new file mode 100644 index 0000000..229efa1 --- /dev/null +++ b/SimpleHttpServer/HttpEndpoint.cs @@ -0,0 +1,23 @@ +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/HttpEndpointHandler.cs b/SimpleHttpServer/HttpEndpointHandler.cs new file mode 100644 index 0000000..f0a5b17 --- /dev/null +++ b/SimpleHttpServer/HttpEndpointHandler.cs @@ -0,0 +1,76 @@ +using Newtonsoft.Json; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace SimpleHttpServer; + +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; + + public HttpEndpointHandler() { + _auth = defaultAuth; + } + + public HttpEndpointHandler(IAuthorizer auth) { + + } + + public virtual void Handle(HttpListenerContext ctx) { + try { + 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); + invokeParams[0] = ctx; + // read pparams + // 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 (type == typeof(string)) { + invokeParams[pindex] = ctx.Request.QueryString[qelem!]; + set.Set(pindex - 1, true); + } else { + var elem = JsonConvert.DeserializeObject(ctx.Request.QueryString[qelem!]!, type); + if (elem != null) { + invokeParams[pindex] = elem; + set.Set(pindex - 1, true); + } + } + } + } + + // fill with defaults + 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; + builder!.SendResponse(ctx.Response); + } catch (Exception e) { + if (e is TargetInvocationException tex) { + e = tex.InnerException!; + } + _errorPageBuilder(e).SendResponse(ctx.Response); + } + } +} diff --git a/SimpleHttpServer/HttpHandlingException.cs b/SimpleHttpServer/HttpHandlingException.cs new file mode 100644 index 0000000..832469b --- /dev/null +++ b/SimpleHttpServer/HttpHandlingException.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SimpleHttpServer; + +internal class HttpHandlingException : Exception { + public readonly int status; + public HttpHandlingException(int status, string msg) : base(msg) { + this.status = status; + } +} diff --git a/SimpleHttpServer/HttpRequestType.cs b/SimpleHttpServer/HttpRequestType.cs new file mode 100644 index 0000000..d6a7972 --- /dev/null +++ b/SimpleHttpServer/HttpRequestType.cs @@ -0,0 +1,13 @@ +namespace SimpleHttpServer; + +public enum HttpRequestType { + GET, + HEAD, + POST, + PUT, + DELETE, + CONNECT, + OPTIONS, + TRACE, + PATCH, +} diff --git a/SimpleHttpServer/HttpResponseBuilder.cs b/SimpleHttpServer/HttpResponseBuilder.cs new file mode 100644 index 0000000..0fa621c --- /dev/null +++ b/SimpleHttpServer/HttpResponseBuilder.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace SimpleHttpServer; +public class HttpResponseBuilder { + + public void SendResponse(HttpListenerResponse response) { + + } +} diff --git a/SimpleHttpServer/HttpServer.cs b/SimpleHttpServer/HttpServer.cs new file mode 100644 index 0000000..79fd6f9 --- /dev/null +++ b/SimpleHttpServer/HttpServer.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Reflection; + +namespace SimpleHttpServer; + +public sealed class HttpServer { + + private Thread? _listenerThread; + private 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 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(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; + } + 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 void Start() { + _listenerThread = new Thread(RunServer); + _listener.Start(); + _listenerThread.Start(); + } + + + private void RunServer() { + try { + for (; ; ) { + var ctx = _listener.GetContext(); + + 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; + } + } + if (ep == null) { + Default404(localCtx).SendResponse(localCtx.Response); + 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); + } else { + error.WriteLine($"> {msg}\n skipping {GetFancyMethodName(method)} ..."); + } + } + + private static string GetFancyMethodName(MethodInfo method) => method.DeclaringType!.Name + "#" + method.Name; +} \ No newline at end of file diff --git a/SimpleHttpServer/IAuthorizer.cs b/SimpleHttpServer/IAuthorizer.cs new file mode 100644 index 0000000..4720221 --- /dev/null +++ b/SimpleHttpServer/IAuthorizer.cs @@ -0,0 +1,7 @@ +using System.Net; + +namespace SimpleHttpServer; + +public interface IAuthorizer { + public abstract (bool auth, object? data) IsAuthenticated(HttpListenerContext contect); +} diff --git a/SimpleHttpServer/SimpleHttpServer.csproj b/SimpleHttpServer/SimpleHttpServer.csproj new file mode 100644 index 0000000..51eb283 --- /dev/null +++ b/SimpleHttpServer/SimpleHttpServer.csproj @@ -0,0 +1,13 @@ + + + + net7.0 + enable + enable + + + + + + + diff --git a/SimpleHttpServer/SketchBoard.txt b/SimpleHttpServer/SketchBoard.txt new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/SimpleHttpServer/SketchBoard.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/SimpleHttpServerTest/SimpleHttpServerTest.csproj b/SimpleHttpServerTest/SimpleHttpServerTest.csproj new file mode 100644 index 0000000..01bb70b --- /dev/null +++ b/SimpleHttpServerTest/SimpleHttpServerTest.csproj @@ -0,0 +1,18 @@ + + + + net7.0 + enable + enable + + false + + + + + + + + + + diff --git a/SimpleHttpServerTest/UnitTest1.cs b/SimpleHttpServerTest/UnitTest1.cs new file mode 100644 index 0000000..818d87c --- /dev/null +++ b/SimpleHttpServerTest/UnitTest1.cs @@ -0,0 +1,8 @@ +namespace SimpleHttpServerTest; + +[TestClass] +public class UnitTest1 { + [TestMethod] + public void TestMethod1() { + } +} \ No newline at end of file diff --git a/SimpleHttpServerTest/Usings.cs b/SimpleHttpServerTest/Usings.cs new file mode 100644 index 0000000..ab67c7e --- /dev/null +++ b/SimpleHttpServerTest/Usings.cs @@ -0,0 +1 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; \ No newline at end of file