using Castle.Core.Logging; using Pilz.Extensions.Reflection; using System.Net; using System.Reflection; using static Pilz.Net.Api.IApiServer; namespace Pilz.Net.Api; public class ApiServer(string apiUrl) : IApiServer { protected readonly Dictionary handlers = []; protected readonly Dictionary serializers = []; protected readonly HttpListener httpListener = new(); public event OnCheckAuthenticationEventHandler? OnCheckAuthentication; public event OnCheckContextEventHandler? OnCheckContext; public event OnCheckContextEventHandler? OnCheckContextCompleted; 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; } // Add handler var fullUrl = attribute.Route; Log.InfoFormat("Added handler for {0}", fullUrl); handlers.Add(fullUrl, handler); } 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(); } Log.Debug("Sanity checks"); if (context.Request.HttpMethod != HttpMethod.Post.Method || context.Request.ContentType is not string contentType || !contentType.Contains("application/json")) { Log.Info("Request has no json content"); close(); return; } // 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; } // Read input content Log.Debug("Read input content"); string? contentJson; if (context.Request.ContentLength64 > 0) { using StreamReader input = new(context.Request.InputStream); contentJson = input.ReadToEnd(); } else contentJson = null; // Get auth key Log.Debug("Get auth key"); if (context.Request.Headers.Get("API-AUTH-KEY") is not string authKey) authKey = null!; // Handle message Log.Debug("Handle mssage"); if (HandleMessage(path, 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? json, string? authKey) { // Get handler Log.Debug("Find handler"); if (!handlers.TryGetValue(url, out var handler) || handler.Method.GetCustomAttribute() is not ApiMessageHandlerAttribute attribute) return null; // Check authentication Log.Debug("Check authentication"); var isAuthenticated = false; if (!string.IsNullOrWhiteSpace(authKey) && DecodeAuthKey(authKey) is string authKeyDecoded) isAuthenticated = CheckAuthentication(authKeyDecoded, handler); else authKeyDecoded = null!; if (attribute.RequiesAuth && !isAuthenticated) return new(ApiResult.Unauthorized(), null); // Get required infos Log.Debug("Identify message parameter type and serializer"); var targetType = handler.Method.GetParameters().FirstOrDefault(p => p.ParameterType.IsAssignableTo(typeof(ApiMessage)))?.ParameterType; var serializer = GetSerializer(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(handler, () => message, () => new(message, isAuthenticated, authKeyDecoded)); if (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 object?[]? BuildParameters(Delegate handler, Func getMessage, Func getRequestInfo) { var infos = 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 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; } }