using Castle.Core.Logging; using Newtonsoft.Json.Linq; using Pilz.Extensions.Reflection; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net; using System.Reflection; using System.Text.Encodings.Web; 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 readonly HttpListener httpListener = new(); 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, string? ResultJson); 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 virtual void Start() { Log.Info("Start listening"); httpListener.Prefixes.Add(ApiUrl + "/"); httpListener.Start(); Receive(); } public virtual void Stop() { httpListener.Stop(); Log.Info("Stopped listening"); } 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 + 1), regex); var index = url.Substring(0, nextBreacket + 1).Split('/').Length; parameters.Add(new(name, index)); useRegEx = true; nextBreacket = url.IndexOf('{', endBreacket + 1); } } if (useRegEx) url = url.Replace(".", "\\."); // Escape special characters // 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 (Exception ex) { Log.ErrorFormat("Error at getting context: ", ex.ToString()); 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 OnCheckContext?.Invoke(this, new(context)); try { CheckContext(context); } catch (Exception ex) { Log.ErrorFormat("Error at checking context: ", ex.ToString()); if (DebugMode) throw; } finally { OnCheckContextCompleted?.Invoke(this, new(context)); } // Listen for new request if (!AllowMultipleRequests) Receive(); } protected virtual void CheckContext(HttpListenerContext context) { Log.Info("Start handling request"); void close() { Log.Info("End handling request"); context.Response.OutputStream.Close(); } // Parse url Log.Debug("Parse url"); var path = context.Request.Url?.PathAndQuery.Replace(ApiUrl, string.Empty); if (string.IsNullOrWhiteSpace(path)) { Log.Info("Request has no path"); close(); return; } // Find handler Log.Debug("Find handler"); if (!TryGetHandler(path, context.Request.HttpMethod, out var handler)) { Log.Info("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(path, 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.ResultJson is not null) { Log.Info("Sending response"); context.Response.ContentType = "application/json"; using StreamWriter output = new(context.Response.OutputStream); output.Write(result.ResultJson); } Log.Debug("Finish response"); close(); } protected virtual PrivateApiResult? HandleMessage(string url, 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, handler, () => message, () => new(message, isAuthenticated, authKeyDecoded, url, method)); 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 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, PrivateMessageHandler handler, Func getMessage, Func getRequestInfo) { var infos = handler.Handler.Method.GetParameters(); var objs = new List(); 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 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; } }