using Castle.Core.Logging; using Pilz.Extensions.Reflection; using System.Diagnostics.CodeAnalysis; using System.Net; using System.Reflection; using System.Text.RegularExpressions; using System.Web; using static Pilz.Net.Api.IApiServer; namespace Pilz.Net.Api; public class ApiServer(string apiUrl) : IApiServer { protected readonly List handlers = []; protected readonly Dictionary serializers = []; protected HttpListener httpListener = new(); protected int restartAttempts = 0; protected DateTime lastRestartAttempt; public event OnCheckAuthenticationEventHandler? OnCheckAuthentication; public event OnCheckContextEventHandler? OnCheckContext; public event OnCheckContextEventHandler? OnCheckContextCompleted; protected record PrivateParameterInfo(string Name, int Index); protected record PrivateMessageHandler(string Url, bool UseRegEx, Delegate Handler, PrivateParameterInfo[] Parameters, ApiMessageHandlerAttribute Attribute); protected record PrivateApiResult(ApiResult Original, object? ResultContent); public string ApiUrl { get; } = apiUrl; public virtual bool EnableAuth { get; set; } public IApiMessageSerializer Serializer { get; set; } = new DefaultApiMessageSerializer(); public ILogger Log { get; set; } = NullLogger.Instance; public bool DebugMode { get; set; } public bool AllowMultipleRequests { get; set; } public int StopDelay { get; set; } = 5000; public int AutoRestartOnError { get; set; } = 5000; public int MaxAutoRestartsPerMinute { get; set; } = 10; public virtual void Start() { Log.Info("Starting listener"); httpListener.Prefixes.Add(ApiUrl + "/"); httpListener.Start(); Log.Info("Started listener"); Receive(); } public virtual void Stop() { Log.Info("Stopping listener"); httpListener.Stop(); Thread.Sleep(StopDelay); httpListener.Close(); Log.Info("Stopped listener"); } public virtual void Restart() { Log.Info("Restarting listener"); Stop(); httpListener = new(); Start(); Log.Info("Restarted listener"); } protected virtual bool AutoRestart() { if (restartAttempts > MaxAutoRestartsPerMinute) { Log.Fatal("Reached maximum auto-restart attempts"); Stop(); return false; } var now = DateTime.Now; if (now - lastRestartAttempt > TimeSpan.FromMinutes(1)) lastRestartAttempt = now; restartAttempts += 1; Restart(); return true; } public virtual void RegisterHandler(T instance) where T : class { // Get all public instance methods var methods = typeof(T).GetMethods(BindingFlags.Instance | BindingFlags.Public); // Register each method foreach (var method in methods) RegisterHandler(method.CreateDelegate(instance), false); } public virtual void RegisterHandler(Delegate handler) { RegisterHandler(handler, true); } public virtual void RegisterHandler(Delegate handler, bool throwOnError) { var method = handler.Method; // Sanity checks if (method.GetCustomAttribute() is not ApiMessageHandlerAttribute attribute || !method.ReturnType.IsAssignableTo(typeof(ApiResult))) { if (throwOnError) throw new NotSupportedException("The first parameter needs to be of type ApiMessage and must return an ApiResult object and the method must have the MessageHandlerAttribute."); return; } // Resolves parameters var url = attribute.Route; var useRegEx = false; var nextBreacket = url.IndexOf('{'); var parameters = new List(); while (nextBreacket != -1) { var endBreacket = url.IndexOf('}', nextBreacket + 1); if (endBreacket != -1) { var name = url.Substring(nextBreacket + 1, endBreacket - nextBreacket - 1); const string regex = "[A-Za-z0-9%]+"; url = url.Replace(url.Substring(nextBreacket, endBreacket - nextBreacket + 1), regex); var index = url[..(nextBreacket + 1)].Split('/').Length - 1; parameters.Add(new(name, index)); useRegEx = true; nextBreacket = url.IndexOf('{', endBreacket + 1); } } if (useRegEx) { url = url.Replace(".", "\\."); // Escape special characters url = $"^{url}$"; // Define start and end of line (matches whole string) } // Add handler Log.InfoFormat("Added handler for {0}", attribute.Route); handlers.Add(new(url, useRegEx, handler, [.. parameters], attribute)); } protected void Receive() { if (httpListener.IsListening) httpListener?.BeginGetContext(ListenerCallback, null); } protected void ListenerCallback(IAsyncResult result) { HttpListenerContext? context; // Skip if not lisstening anymore if (!httpListener.IsListening) return; // Get context try { context = httpListener.EndGetContext(result); } catch (HttpListenerException ex) { if (ex.ErrorCode == 995 || ex.ErrorCode == 64) { Log.Fatal($"Fatal http error retriving context with code {ex.ErrorCode}", ex); if (AutoRestart()) // Try restart the server and skip this context return; } else Log.Error($"Http error retriving context with code {ex.ErrorCode}", ex); context = null; } catch (Exception ex) { Log.Error("Error retriving context", ex); context = null; } // Immitatly listen for new request if (AllowMultipleRequests) Receive(); // Cancel if we don't have a context if (context is null) return; // Check context Log.Info("Request retrived for " + context.Request.RawUrl); OnCheckContext?.Invoke(this, new(context)); try { CheckContext(context); } catch (Exception ex) { Log.Error("Error checking context", ex); if (DebugMode) throw; } finally { OnCheckContextCompleted?.Invoke(this, new(context)); } // Listen for new request if (!AllowMultipleRequests) Receive(); } protected virtual void CheckContext(HttpListenerContext context) { Log.Debug("Start handling request"); void close() { Log.Debug("End handling request"); context.Response.OutputStream.Close(); } // Parse url Log.Debug("Parse url"); var path = context.Request.Url?.AbsolutePath; var query = context.Request.Url?.Query; if (string.IsNullOrWhiteSpace(path)) { Log.Warn("Request has no path"); close(); return; } // Find handler Log.Debug("Find handler"); if (!TryGetHandler(path, query, context.Request.HttpMethod, out var handler)) { Log.Warn("Request handler couldn't be found"); close(); return; } // Get auth key Log.Debug("Get auth key"); if (context.Request.Headers.Get("API-AUTH-KEY") is not string authKey) authKey = null!; // Read input content Log.Debug("Read input content"); string? contentJson; if (context.Request.ContentType is string contentType && contentType.Contains("application/json") && context.Request.ContentLength64 > 0) { try { using StreamReader input = new(context.Request.InputStream); contentJson = input.ReadToEnd(); } catch (OutOfMemoryException) { Log.Error("Error reading remote data due to missing memory"); close(); return; } catch (Exception ex) { Log.Error("Error reading remote data", ex); close(); return; } } else contentJson = null; // Handle message Log.Debug("Handle mssage"); if (HandleMessage(context, path, query, context.Request.HttpMethod, handler, contentJson, authKey) is not PrivateApiResult result) { Log.Warn("Request couldn't be handled"); close(); return; } // Set response parameters Log.Debug("Set response parameters"); context.Response.StatusCode = (int)result.Original.StatusCode; // Write response content Log.Debug("Create response"); if (result.ResultContent is string resultJson) { Log.Info("Sending json response for " + context.Request.RawUrl); context.Response.ContentType = "application/json"; using StreamWriter output = new(context.Response.OutputStream); output.Write(resultJson); output.Flush(); } else if (result.ResultContent is byte[] resultBytes) { Log.Info("Sending raw bytes response for " + context.Request.RawUrl); context.Response.OutputStream.Write(resultBytes, 0, resultBytes.Length); context.Response.OutputStream.Flush(); } Log.Debug("Finish response"); close(); } protected virtual PrivateApiResult? HandleMessage(HttpListenerContext context, string url, string? query, string method, PrivateMessageHandler handler, string? json, string? authKey) { // Check authentication Log.Debug("Check authentication"); var isAuthenticated = false; if (!string.IsNullOrWhiteSpace(authKey) && DecodeAuthKey(authKey) is string authKeyDecoded) isAuthenticated = CheckAuthentication(authKeyDecoded, handler.Handler); else authKeyDecoded = null!; if (handler.Attribute.RequiesAuth && !isAuthenticated) return new(ApiResult.Unauthorized(), null); // Get required infos Log.Debug("Identify message parameter type and serializer"); var targetType = handler.Handler.Method.GetParameters().FirstOrDefault(p => p.ParameterType.IsAssignableTo(typeof(ApiMessage)))?.ParameterType; var serializer = GetSerializer(handler.Attribute.Serializer); // Deserialize Log.Debug("Deserialize message"); ApiMessage? message; if (json != null && targetType != null) message = serializer.Deserialize(json, targetType); else message = null; // Invoke handler Log.Debug("Invoke handler"); var parameters = BuildParameters(url, query, handler, () => message, () => new(message, isAuthenticated, authKeyDecoded, url, method, context)); if (handler.Handler.DynamicInvoke(parameters) is not ApiResult result) return new(ApiResult.InternalServerError(), null); // Return result without message Log.Debug("Check message"); if (result.Message is null) return new(result, null); // Serializer Log.Debug("Serialize message"); if (serializer.Serialize(result.Message) is not string resultStr) return new(ApiResult.InternalServerError(), null); // Return result with message Log.Debug("Complete result"); return new(result, resultStr); } protected virtual bool TryGetHandler(string url, string? query, string method, [NotNullWhen(true)] out PrivateMessageHandler? handler) { handler = handlers.FirstOrDefault(handler => { if (!handler.Attribute.Methods.Contains(method)) return false; if (handler.UseRegEx) return Regex.IsMatch(url, handler.Url, RegexOptions.IgnoreCase); return handler.Url.Equals(url, StringComparison.InvariantCultureIgnoreCase); }); return handler != null; } protected virtual object?[]? BuildParameters(string url, string? query, PrivateMessageHandler handler, Func getMessage, Func getRequestInfo) { var infos = handler.Handler.Method.GetParameters(); var objs = new List(); var queryparams = query == null ? [] : HttpUtility.ParseQueryString(query); foreach (var info in infos) { if (info.ParameterType.IsAssignableTo(typeof(ApiMessage))) objs.Add(getMessage()); else if (info.ParameterType.IsAssignableTo(typeof(ApiRequestInfo))) objs.Add(getRequestInfo()); else if (handler.Parameters.FirstOrDefault(p => p.Name.Equals(info.Name, StringComparison.InvariantCultureIgnoreCase)) is PrivateParameterInfo parameterInfo && url.Split('/').ElementAtOrDefault(parameterInfo.Index) is string parameterValue) objs.Add(Convert.ChangeType(HttpUtility.UrlDecode(parameterValue), info.ParameterType)); // or Uri.UnescapeDataString(); maybe run this line twice? else if (queryparams.AllKeys.FirstOrDefault(n => n != null && n.Equals(info.Name, StringComparison.InvariantCultureIgnoreCase)) is string querykey) objs.Add(Convert.ChangeType(HttpUtility.HtmlDecode(queryparams.Get(querykey)), info.ParameterType)); else objs.Add(null); } return [.. objs]; } protected virtual IApiMessageSerializer GetSerializer(Type? t) { if (t is not null) { if (serializers.TryGetValue(t, out var s) && s is not null) return s; else if (Activator.CreateInstance(t) is IApiMessageSerializer ss) { serializers.Add(t, ss); return ss; } } return Serializer; } protected virtual bool CheckAuthentication(string authKey, Delegate? handler) { if (OnCheckAuthentication != null) { var args = new ApiAuthCheckEventArgs(authKey, handler); OnCheckAuthentication?.Invoke(this, args); return args.Valid; } return false; } protected virtual string? DecodeAuthKey(string authKey) { return authKey; } }