Huge refactor; Passing tests
This commit is contained in:
1 parent
3e307c27fa
commit
09fa3b8734
18 files changed
+577
-162
No files matched your search
+183
-107
@@ -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<HttpListenerContext, HttpResponseBuilder> 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<IAuthorizer>) x)
|
||||
.SingleOrDefault();
|
||||
|
||||
if (attrib == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// sanity checks
|
||||
if (!endpoint.IsStatic) {
|
||||
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, "HttpEndpointAttribute is only valid on static methods!");
|
||||
continue;
|
||||
}
|
||||
if (!endpoint.IsPublic) {
|
||||
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to be public!");
|
||||
}
|
||||
var myParams = endpoint.GetParameters();
|
||||
if (myParams.Length <= 0 || !myParams[0].GetType().IsAssignableFrom(typeof(HttpListenerContext))) {
|
||||
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to have a HttpListenerContext as its first argument!");
|
||||
continue;
|
||||
}
|
||||
if (!endpoint.ReturnParameter.ParameterType.IsAssignableTo(typeof(HttpResponseBuilder))) {
|
||||
PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to have a HttpResponseBuilder as the return type!");
|
||||
}
|
||||
|
||||
var path = attrib.Location;
|
||||
int idx = path.IndexOf('{');
|
||||
if (idx >= 0) {
|
||||
// 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<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();
|
||||
|
||||
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<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)!");
|
||||
}
|
||||
}
|
||||
|
||||
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<Type, IParameterConverter> 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<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;
|
||||
}
|
||||
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($"""
|
||||
<body>
|
||||
<h1>Oh no, and error occurred!</h1>
|
||||
<p>Code: {errorCode}</p>
|
||||
</body>
|
||||
""");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user