From 213973a323702e2158676d766072b9af29ed21b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:51:16 +0000 Subject: [PATCH] Replace hardcoded SwaggerHandler logic with attribute-based metadata system Co-authored-by: andrew-welker <1765622+andrew-welker@users.noreply.github.com> --- .../Web/Attributes/OpenApiAttributes.cs | 177 +++++++++++ .../RequestHandlers/DevJsonRequestHandler.cs | 10 + .../RequestHandlers/DevListRequestHandler.cs | 8 + .../GetFeedbacksForDeviceRequestHandler.cs | 10 + .../Web/RequestHandlers/GetRoutesHandler.cs | 7 + .../RestartProgramRequestHandler.cs | 7 + .../Web/RequestHandlers/SwaggerHandler.cs | 292 +++++++++++++----- 7 files changed, 434 insertions(+), 77 deletions(-) create mode 100644 src/PepperDash.Essentials.Core/Web/Attributes/OpenApiAttributes.cs diff --git a/src/PepperDash.Essentials.Core/Web/Attributes/OpenApiAttributes.cs b/src/PepperDash.Essentials.Core/Web/Attributes/OpenApiAttributes.cs new file mode 100644 index 00000000..6ac8aa7a --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/Attributes/OpenApiAttributes.cs @@ -0,0 +1,177 @@ +using System; +using System.ComponentModel; + +namespace PepperDash.Essentials.Core.Web.Attributes +{ + /// + /// Base class for HTTP method attributes + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public abstract class HttpMethodAttribute : Attribute + { + public string Method { get; } + + protected HttpMethodAttribute(string method) + { + Method = method; + } + } + + /// + /// Indicates that a request handler supports HTTP GET operations + /// + public class HttpGetAttribute : HttpMethodAttribute + { + public HttpGetAttribute() : base("GET") { } + } + + /// + /// Indicates that a request handler supports HTTP POST operations + /// + public class HttpPostAttribute : HttpMethodAttribute + { + public HttpPostAttribute() : base("POST") { } + } + + /// + /// Provides OpenAPI operation metadata for a request handler + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class OpenApiOperationAttribute : Attribute + { + /// + /// A brief summary of what the operation does + /// + public string Summary { get; set; } + + /// + /// A verbose explanation of the operation behavior + /// + public string Description { get; set; } + + /// + /// Unique string used to identify the operation + /// + public string OperationId { get; set; } + + /// + /// A list of tags for API documentation control + /// + public string[] Tags { get; set; } + + public OpenApiOperationAttribute() + { + } + } + + /// + /// Describes a response from an API operation + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class OpenApiResponseAttribute : Attribute + { + /// + /// The HTTP status code + /// + public int StatusCode { get; } + + /// + /// A short description of the response + /// + public string Description { get; set; } + + /// + /// The content type of the response + /// + public string ContentType { get; set; } = "application/json"; + + /// + /// The type that represents the response schema + /// + public Type Type { get; set; } + + public OpenApiResponseAttribute(int statusCode) + { + StatusCode = statusCode; + } + } + + /// + /// Indicates that an operation requires a request body + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class OpenApiRequestBodyAttribute : Attribute + { + /// + /// Determines if the request body is required + /// + public bool Required { get; set; } = true; + + /// + /// The content type of the request body + /// + public string ContentType { get; set; } = "application/json"; + + /// + /// The type that represents the request body schema + /// + public Type Type { get; set; } + + /// + /// Description of the request body + /// + public string Description { get; set; } + + public OpenApiRequestBodyAttribute() + { + } + } + + /// + /// Describes a parameter for the operation + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class OpenApiParameterAttribute : Attribute + { + /// + /// The name of the parameter + /// + public string Name { get; } + + /// + /// The location of the parameter + /// + public ParameterLocation In { get; set; } = ParameterLocation.Path; + + /// + /// Determines whether this parameter is mandatory + /// + public bool Required { get; set; } = true; + + /// + /// A brief description of the parameter + /// + public string Description { get; set; } + + /// + /// The type of the parameter + /// + public Type Type { get; set; } = typeof(string); + + public OpenApiParameterAttribute(string name) + { + Name = name; + } + } + + /// + /// The location of the parameter + /// + public enum ParameterLocation + { + Query, + Header, + Path, + Cookie + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DevJsonRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DevJsonRequestHandler.cs index d14ffb83..e0ee7900 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DevJsonRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DevJsonRequestHandler.cs @@ -3,10 +3,20 @@ using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core; using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Web.Attributes; using Serilog.Events; namespace PepperDash.Essentials.Core.Web.RequestHandlers { + [HttpPost] + [OpenApiOperation( + Summary = "DevJson", + Description = "Send a command to a specific device", + OperationId = "sendDeviceCommand")] + [OpenApiParameter("deviceKey", Description = "The key of the device to send the command to")] + [OpenApiRequestBody(Description = "Device command data")] + [OpenApiResponse(200, Description = "Command executed successfully")] + [OpenApiResponse(400, Description = "Bad Request")] public class DevJsonRequestHandler : WebApiBaseRequestHandler { /// diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DevListRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DevListRequestHandler.cs index a83685fb..9a343739 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/DevListRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/DevListRequestHandler.cs @@ -2,9 +2,17 @@ using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Web.Attributes; namespace PepperDash.Essentials.Core.Web.RequestHandlers { + [HttpGet] + [OpenApiOperation( + Summary = "DevList", + Description = "Retrieve a list of all devices in the system", + OperationId = "getDevices")] + [OpenApiResponse(200, Description = "Successful response", ContentType = "application/json")] + [OpenApiResponse(404, Description = "Not Found")] public class DevListRequestHandler : WebApiBaseRequestHandler { /// diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs index 5dd5495c..aef7c2a6 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetFeedbacksForDeviceRequestHandler.cs @@ -2,9 +2,19 @@ using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Web.Attributes; namespace PepperDash.Essentials.Core.Web.RequestHandlers { + [HttpGet] + [OpenApiOperation( + Summary = "GetFeedbacksForDeviceKey", + Description = "Get feedback values from a specific device", + OperationId = "getDeviceFeedbacks")] + [OpenApiParameter("deviceKey", Description = "The key of the device to get feedbacks from")] + [OpenApiResponse(200, Description = "Device feedback values")] + [OpenApiResponse(400, Description = "Bad Request")] + [OpenApiResponse(404, Description = "Device not found")] public class GetFeedbacksForDeviceRequestHandler : WebApiBaseRequestHandler { /// diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutesHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutesHandler.cs index 2ba9cb33..9934d178 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutesHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/GetRoutesHandler.cs @@ -2,9 +2,16 @@ using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Web.Attributes; namespace PepperDash.Essentials.Core.Web.RequestHandlers { + [HttpGet] + [OpenApiOperation( + Summary = "GetPaths", + Description = "Get available API paths and routes", + OperationId = "getApiPaths")] + [OpenApiResponse(200, Description = "Successful response")] public class GetRoutesHandler:WebApiBaseRequestHandler { private HttpCwsRouteCollection routeCollection; diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/RestartProgramRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/RestartProgramRequestHandler.cs index 0bb568f6..ed25e466 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/RestartProgramRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/RestartProgramRequestHandler.cs @@ -3,9 +3,16 @@ using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core; using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Web.Attributes; namespace PepperDash.Essentials.Core.Web.RequestHandlers { + [HttpPost] + [OpenApiOperation( + Summary = "Restart Program", + Description = "Restart the program", + OperationId = "restartProgram")] + [OpenApiResponse(200, Description = "Program restart initiated successfully")] public class RestartProgramRequestHandler : WebApiBaseRequestHandler { /// diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/SwaggerHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/SwaggerHandler.cs index 6cf11cd8..69255847 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/SwaggerHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/SwaggerHandler.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Reflection; using Crestron.SimplSharp; using Crestron.SimplSharp.WebScripting; using Newtonsoft.Json; using PepperDash.Core.Web.RequestHandlers; +using PepperDash.Essentials.Core.Web.Attributes; namespace PepperDash.Essentials.Core.Web.RequestHandlers { @@ -84,56 +87,155 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers private object GeneratePathItem(HttpCwsRoute route) { - // Determine HTTP methods and create appropriate operation objects + if (route.RouteHandler == null) return null; + + var handlerType = route.RouteHandler.GetType(); var operations = new Dictionary(); - // Based on the route name and common patterns, determine likely HTTP methods - var routeName = route.Name?.ToLower() ?? ""; - var routeUrl = route.Url?.ToLower() ?? ""; + // Get HTTP method attributes from the handler class + var httpMethodAttributes = handlerType.GetCustomAttributes(typeof(HttpMethodAttribute), false) + .Cast() + .ToList(); - if (routeName.Contains("get") || routeUrl.Contains("devices") || routeUrl.Contains("config") || - routeUrl.Contains("versions") || routeUrl.Contains("types") || routeUrl.Contains("tielines") || - routeUrl.Contains("apipaths") || routeUrl.Contains("feedbacks") || routeUrl.Contains("properties") || - routeUrl.Contains("methods") || routeUrl.Contains("joinmap") || routeUrl.Contains("routingports")) + // If no HTTP method attributes found, fall back to the original logic + if (!httpMethodAttributes.Any()) { - operations["get"] = GenerateOperation(route, "GET"); + httpMethodAttributes = DetermineHttpMethodsFromRoute(route); } - if (routeName.Contains("command") || routeName.Contains("restart") || routeName.Contains("load") || - routeName.Contains("debug") || routeName.Contains("disable")) + foreach (var methodAttr in httpMethodAttributes) { - operations["post"] = GenerateOperation(route, "POST"); + var operation = GenerateOperation(route, methodAttr.Method, handlerType); + if (operation != null) + { + operations[methodAttr.Method.ToLower()] = operation; + } } return operations.Count > 0 ? operations : null; } - private object GenerateOperation(HttpCwsRoute route, string method) + private List DetermineHttpMethodsFromRoute(HttpCwsRoute route) { - var operation = new Dictionary - { - ["summary"] = route.Name ?? "API Operation", - ["operationId"] = route.Name?.Replace(" ", "") ?? "operation", - ["responses"] = new Dictionary - { - ["200"] = new - { - description = "Successful response", - content = new Dictionary - { - ["application/json"] = new { schema = new { type = "object" } } - } - }, - ["400"] = new { description = "Bad Request" }, - ["404"] = new { description = "Not Found" }, - ["500"] = new { description = "Internal Server Error" } - } - }; + var methods = new List(); + var routeName = route.Name?.ToLower() ?? ""; + var routeUrl = route.Url?.ToLower() ?? ""; - // Add parameters for path variables + // Fallback logic for routes without attributes + if (routeName.Contains("get") || routeUrl.Contains("devices") || routeUrl.Contains("config") || + routeUrl.Contains("versions") || routeUrl.Contains("types") || routeUrl.Contains("tielines") || + routeUrl.Contains("apipaths") || routeUrl.Contains("feedbacks") || routeUrl.Contains("properties") || + routeUrl.Contains("methods") || routeUrl.Contains("joinmap") || routeUrl.Contains("routingports")) + { + methods.Add(new HttpGetAttribute()); + } + + if (routeName.Contains("command") || routeName.Contains("restart") || routeName.Contains("load") || + routeName.Contains("debug") || routeName.Contains("disable")) + { + methods.Add(new HttpPostAttribute()); + } + + return methods; + } + + private object GenerateOperation(HttpCwsRoute route, string method, Type handlerType) + { + var operation = new Dictionary(); + + // Get OpenApiOperation attribute + var operationAttr = handlerType.GetCustomAttribute(); + if (operationAttr != null) + { + operation["summary"] = operationAttr.Summary ?? route.Name ?? "API Operation"; + operation["operationId"] = operationAttr.OperationId ?? route.Name?.Replace(" ", "") ?? "operation"; + + if (!string.IsNullOrEmpty(operationAttr.Description)) + { + operation["description"] = operationAttr.Description; + } + + if (operationAttr.Tags != null && operationAttr.Tags.Length > 0) + { + operation["tags"] = operationAttr.Tags; + } + } + else + { + // Fallback to route name + operation["summary"] = route.Name ?? "API Operation"; + operation["operationId"] = route.Name?.Replace(" ", "") ?? "operation"; + + // Add fallback description + var fallbackDescription = GetFallbackDescription(route); + if (!string.IsNullOrEmpty(fallbackDescription)) + { + operation["description"] = fallbackDescription; + } + } + + // Get response attributes + var responses = new Dictionary(); + var responseAttrs = handlerType.GetCustomAttributes().ToList(); + + if (responseAttrs.Any()) + { + foreach (var responseAttr in responseAttrs) + { + var responseObj = new Dictionary + { + ["description"] = responseAttr.Description ?? "Response" + }; + + if (!string.IsNullOrEmpty(responseAttr.ContentType)) + { + responseObj["content"] = new Dictionary + { + [responseAttr.ContentType] = new { schema = new { type = "object" } } + }; + } + + responses[responseAttr.StatusCode.ToString()] = responseObj; + } + } + else + { + // Default responses + responses["200"] = new + { + description = "Successful response", + content = new Dictionary + { + ["application/json"] = new { schema = new { type = "object" } } + } + }; + responses["400"] = new { description = "Bad Request" }; + responses["404"] = new { description = "Not Found" }; + responses["500"] = new { description = "Internal Server Error" }; + } + + operation["responses"] = responses; + + // Get parameter attributes + var parameterAttrs = handlerType.GetCustomAttributes().ToList(); + var parameters = new List(); + + // Add parameters from attributes + foreach (var paramAttr in parameterAttrs) + { + parameters.Add(new + { + name = paramAttr.Name, + @in = paramAttr.In.ToString().ToLower(), + required = paramAttr.Required, + schema = new { type = GetSchemaType(paramAttr.Type) }, + description = paramAttr.Description ?? $"The {paramAttr.Name} parameter" + }); + } + + // Add parameters from URL path variables (fallback) if (route.Url.Contains("{")) { - var parameters = new List(); var url = route.Url; while (url.Contains("{")) { @@ -142,107 +244,143 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers if (end > start) { var paramName = url.Substring(start + 1, end - start - 1); - parameters.Add(new + + // Only add if not already added from attributes + if (!parameters.Any(p => ((dynamic)p).name == paramName)) { - name = paramName, - @in = "path", - required = true, - schema = new { type = "string" }, - description = $"The {paramName} parameter" - }); + parameters.Add(new + { + name = paramName, + @in = "path", + required = true, + schema = new { type = "string" }, + description = $"The {paramName} parameter" + }); + } url = url.Substring(end + 1); } else break; } - if (parameters.Count > 0) + } + + if (parameters.Count > 0) + { + operation["parameters"] = parameters; + } + + // Get request body attribute for POST operations + if (method == "POST") + { + var requestBodyAttr = handlerType.GetCustomAttribute(); + if (requestBodyAttr != null) { - operation["parameters"] = parameters; + operation["requestBody"] = new + { + required = requestBodyAttr.Required, + description = requestBodyAttr.Description, + content = new Dictionary + { + [requestBodyAttr.ContentType] = new Dictionary + { + ["schema"] = requestBodyAttr.Type != null + ? (object)new Dictionary { ["$ref"] = $"#/components/schemas/{requestBodyAttr.Type.Name}" } + : new Dictionary { ["type"] = "object" } + } + } + }; + } + else if (route.Name != null && route.Name.Contains("Command")) + { + // Fallback for command routes + operation["requestBody"] = new + { + required = true, + content = new Dictionary + { + ["application/json"] = new + { + schema = new Dictionary { ["$ref"] = "#/components/schemas/DeviceCommand" } + } + } + }; } } - // Add request body for POST operations - if (method == "POST" && route.Name != null && route.Name.Contains("Command")) - { - operation["requestBody"] = new - { - required = true, - content = new Dictionary - { - ["application/json"] = new - { - schema = new Dictionary { ["$ref"] = "#/components/schemas/DeviceCommand" } - } - } - }; - } - - // Add specific descriptions based on route patterns - AddRouteSpecificDescription(operation, route); - return operation; } - private void AddRouteSpecificDescription(Dictionary operation, HttpCwsRoute route) + private string GetSchemaType(Type type) + { + if (type == typeof(string)) return "string"; + if (type == typeof(int) || type == typeof(long)) return "integer"; + if (type == typeof(bool)) return "boolean"; + if (type == typeof(double) || type == typeof(float)) return "number"; + return "string"; // default + } + + private string GetFallbackDescription(HttpCwsRoute route) { var routeName = route.Name?.ToLower() ?? ""; var routeUrl = route.Url?.ToLower() ?? ""; if (routeUrl.Contains("devices") && !routeUrl.Contains("{")) { - operation["description"] = "Retrieve a list of all devices in the system"; + return "Retrieve a list of all devices in the system"; } else if (routeUrl.Contains("versions")) { - operation["description"] = "Get version information for loaded assemblies"; + return "Get version information for loaded assemblies"; } else if (routeUrl.Contains("config")) { - operation["description"] = "Retrieve the current system configuration"; + return "Retrieve the current system configuration"; } else if (routeUrl.Contains("devicecommands")) { - operation["description"] = "Send a command to a specific device"; + return "Send a command to a specific device"; } else if (routeUrl.Contains("devicefeedbacks")) { - operation["description"] = "Get feedback values from a specific device"; + return "Get feedback values from a specific device"; } else if (routeUrl.Contains("deviceproperties")) { - operation["description"] = "Get properties of a specific device"; + return "Get properties of a specific device"; } else if (routeUrl.Contains("devicemethods")) { - operation["description"] = "Get available methods for a specific device"; + return "Get available methods for a specific device"; } else if (routeUrl.Contains("types")) { - operation["description"] = routeUrl.Contains("{") ? "Get types filtered by the specified filter" : "Get all available types"; + return routeUrl.Contains("{") ? "Get types filtered by the specified filter" : "Get all available types"; } else if (routeUrl.Contains("tielines")) { - operation["description"] = "Get information about tielines in the system"; + return "Get information about tielines in the system"; } else if (routeUrl.Contains("joinmap")) { - operation["description"] = "Get join map information for bridge or device"; + return "Get join map information for bridge or device"; } else if (routeUrl.Contains("routingports")) { - operation["description"] = "Get routing ports for a specific device"; + return "Get routing ports for a specific device"; } else if (routeUrl.Contains("apipaths")) { - operation["description"] = "Get available API paths and routes"; + return "Get available API paths and routes"; } else if (routeName.Contains("restart")) { - operation["description"] = "Restart the program"; + return "Restart the program"; } else if (routeName.Contains("debug")) { - operation["description"] = "Debug operation"; + return "Debug operation"; } + + return null; } private Dictionary GetSchemas()