WiP
This commit is contained in:
18
MyCompany.MyProject.BackendApi/Models/ComponentDefinition.cs
Normal file
18
MyCompany.MyProject.BackendApi/Models/ComponentDefinition.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace MyCompany.MyProject.BackendApi.Models;
|
||||||
|
|
||||||
|
record ComponentDefinition
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Icon { get; set; } = string.Empty;
|
||||||
|
public List<ComponentInput> Inputs { get; set; } = new();
|
||||||
|
public List<ComponentOutput> Outputs { get; set; } = new();
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
public string Class { get; set; } = string.Empty;
|
||||||
|
public List<string> Methods { get; set; } = new();
|
||||||
|
public string Gui { get; set; } = string.Empty;
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public List<string> Tags { get; set; } = new();
|
||||||
|
public List<PresetDefinition> Presets { get; set; } = new();
|
||||||
|
}
|
||||||
10
MyCompany.MyProject.BackendApi/Models/ComponentInput.cs
Normal file
10
MyCompany.MyProject.BackendApi/Models/ComponentInput.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace MyCompany.MyProject.BackendApi.Models;
|
||||||
|
|
||||||
|
record ComponentInput
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public List<string>? Enum { get; set; }
|
||||||
|
public bool Required { get; set; } = true;
|
||||||
|
}
|
||||||
8
MyCompany.MyProject.BackendApi/Models/ComponentOutput.cs
Normal file
8
MyCompany.MyProject.BackendApi/Models/ComponentOutput.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MyCompany.MyProject.BackendApi.Models;
|
||||||
|
|
||||||
|
record ComponentOutput
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
14
MyCompany.MyProject.BackendApi/Models/PresetDefinition.cs
Normal file
14
MyCompany.MyProject.BackendApi/Models/PresetDefinition.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace MyCompany.MyProject.BackendApi.Models;
|
||||||
|
|
||||||
|
record PresetDefinition
|
||||||
|
{
|
||||||
|
public string PresetId { get; set; } = string.Empty;
|
||||||
|
public string PresetName { get; set; } = string.Empty;
|
||||||
|
public string BaseComponentCode { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Version { get; set; } = string.Empty;
|
||||||
|
public string Author { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public Dictionary<string, object> Inputs { get; set; } = new();
|
||||||
|
public Dictionary<string, object> Metadata { get; set; } = new();
|
||||||
|
}
|
||||||
8
MyCompany.MyProject.BackendApi/Models/RuntimeRequest.cs
Normal file
8
MyCompany.MyProject.BackendApi/Models/RuntimeRequest.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MyCompany.MyProject.BackendApi.Models;
|
||||||
|
|
||||||
|
record RuntimeRequest
|
||||||
|
{
|
||||||
|
public string ClassName { get; set; } = string.Empty;
|
||||||
|
public string MethodName { get; set; } = string.Empty;
|
||||||
|
public object? Inputs { get; set; }
|
||||||
|
}
|
||||||
7
MyCompany.MyProject.BackendApi/Models/RuntimeResponse.cs
Normal file
7
MyCompany.MyProject.BackendApi/Models/RuntimeResponse.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace MyCompany.MyProject.BackendApi.Models;
|
||||||
|
|
||||||
|
record RuntimeResponse
|
||||||
|
{
|
||||||
|
public object? Outputs { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
using MyCompany.MyProject.BackendApi.Models;
|
||||||
|
using System.Security;
|
||||||
|
|
||||||
// Ensure S8n.Components.Packages assembly is loaded
|
// Ensure S8n.Components.Packages assembly is loaded
|
||||||
_ = typeof(S8n.Components.Basics.Calculator).Assembly;
|
_ = typeof(S8n.Components.Basics.Calculator).Assembly;
|
||||||
@@ -25,185 +24,197 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
var summaries = new[]
|
|
||||||
{
|
|
||||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
|
||||||
};
|
|
||||||
|
|
||||||
app.MapGet("/weatherforecast", () =>
|
|
||||||
{
|
|
||||||
var forecast = Enumerable.Range(1, 5).Select(index =>
|
|
||||||
new WeatherForecast
|
|
||||||
(
|
|
||||||
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
|
||||||
Random.Shared.Next(-20, 55),
|
|
||||||
summaries[Random.Shared.Next(summaries.Length)]
|
|
||||||
))
|
|
||||||
.ToArray();
|
|
||||||
return forecast;
|
|
||||||
})
|
|
||||||
.WithName("GetWeatherForecast");
|
|
||||||
|
|
||||||
// Component registry endpoint
|
// Component registry endpoint
|
||||||
app.MapGet("/api/components", () =>
|
app.MapGet("/api/components", () =>
|
||||||
{
|
{
|
||||||
var components = new List<ComponentDefinition>();
|
var components = new List<ComponentDefinition>();
|
||||||
var componentsPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "components");
|
var presets = new List<PresetDefinition>();
|
||||||
|
|
||||||
|
// Load components from components/ directory
|
||||||
|
var componentsPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "components");
|
||||||
if (!Directory.Exists(componentsPath))
|
if (!Directory.Exists(componentsPath))
|
||||||
{
|
{
|
||||||
// Fallback to absolute path from workspace root
|
// Fallback to absolute path from workspace root
|
||||||
componentsPath = Path.Combine(Directory.GetCurrentDirectory(), "components");
|
componentsPath = Path.Combine(Directory.GetCurrentDirectory(), "components");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Directory.Exists(componentsPath))
|
// Load presets from components_user/ directory
|
||||||
|
var userComponentsPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "components_user");
|
||||||
|
if (!Directory.Exists(userComponentsPath))
|
||||||
{
|
{
|
||||||
var jsonFiles = Directory.GetFiles(componentsPath, "*.json", SearchOption.AllDirectories);
|
userComponentsPath = Path.Combine(Directory.GetCurrentDirectory(), "components_user");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all JSON files from both directories
|
||||||
|
var allPaths = new List<string>();
|
||||||
|
if (Directory.Exists(componentsPath)) allPaths.Add(componentsPath);
|
||||||
|
if (Directory.Exists(userComponentsPath)) allPaths.Add(userComponentsPath);
|
||||||
|
|
||||||
|
foreach (var path in allPaths)
|
||||||
|
{
|
||||||
|
var jsonFiles = Directory.GetFiles(path, "*.json", SearchOption.AllDirectories);
|
||||||
foreach (var file in jsonFiles)
|
foreach (var file in jsonFiles)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(file);
|
var json = File.ReadAllText(file);
|
||||||
var component = JsonSerializer.Deserialize<ComponentDefinition>(json, new JsonSerializerOptions
|
|
||||||
|
// Try to determine if this is a component or preset by checking for "type" field
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var isPreset = doc.RootElement.TryGetProperty("type", out var typeElement) &&
|
||||||
|
typeElement.GetString() == "preset";
|
||||||
|
|
||||||
|
if (isPreset)
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
var preset = JsonSerializer.Deserialize<PresetDefinition>(json, new JsonSerializerOptions
|
||||||
});
|
{
|
||||||
if (component != null)
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
if (preset != null)
|
||||||
|
{
|
||||||
|
presets.Add(preset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
components.Add(component);
|
var component = JsonSerializer.Deserialize<ComponentDefinition>(json, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
if (component != null)
|
||||||
|
{
|
||||||
|
components.Add(component);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Log error but continue
|
// Log error but continue
|
||||||
Console.WriteLine($"Error reading component file {file}: {ex.Message}");
|
Console.WriteLine($"Error reading component/preset file {file}: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group presets by baseComponentCode and attach to components
|
||||||
|
var presetsByComponent = presets.GroupBy(p => p.BaseComponentCode)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
foreach (var component in components)
|
||||||
|
{
|
||||||
|
if (presetsByComponent.TryGetValue(component.Code, out var componentPresets))
|
||||||
|
{
|
||||||
|
component.Presets = componentPresets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Results.Ok(components);
|
return Results.Ok(components);
|
||||||
})
|
})
|
||||||
.WithName("GetComponents");
|
.WithName("GetComponents");
|
||||||
|
|
||||||
// Runtime execution endpoint
|
// Runtime execution endpoint
|
||||||
app.MapPost("/api/runtime/execute", async (RuntimeRequest request) =>
|
app.MapPost("/api/runtime/execute", async (RuntimeRequest request, ILogger<RuntimeRequest> logger) =>
|
||||||
{
|
{
|
||||||
|
const int MAX_CLASS_NAME_LENGTH = 500;
|
||||||
|
const int MAX_METHOD_NAME_LENGTH = 100;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get the type from the class name
|
// Input validation and sanitization
|
||||||
var type = Type.GetType(request.ClassName);
|
if (string.IsNullOrWhiteSpace(request.ClassName) ||
|
||||||
if (type == null)
|
string.IsNullOrWhiteSpace(request.MethodName) ||
|
||||||
|
request.ClassName.Length > MAX_CLASS_NAME_LENGTH ||
|
||||||
|
request.MethodName.Length > MAX_METHOD_NAME_LENGTH)
|
||||||
{
|
{
|
||||||
// Try to find in loaded assemblies
|
logger.LogWarning("Invalid input parameters received");
|
||||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
return Results.BadRequest(new RuntimeResponse
|
||||||
{
|
{
|
||||||
type = assembly.GetType(request.ClassName);
|
Error = "Invalid class name or method name"
|
||||||
if (type != null) break;
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: Validate class name format to prevent malicious type loading
|
||||||
|
if (!IsValidTypeName(request.ClassName))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Invalid type name format: {ClassName}", request.ClassName);
|
||||||
|
return Results.BadRequest(new RuntimeResponse
|
||||||
|
{
|
||||||
|
Error = "Invalid class name format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: Validate method name format
|
||||||
|
if (!IsValidMethodName(request.MethodName))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Invalid method name format: {MethodName}", request.MethodName);
|
||||||
|
return Results.BadRequest(new RuntimeResponse
|
||||||
|
{
|
||||||
|
Error = "Invalid method name format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the type from the class name with security restrictions
|
||||||
|
var type = GetTypeSafely(request.ClassName);
|
||||||
if (type == null)
|
if (type == null)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new RuntimeResponse
|
logger.LogWarning("Class not found: {ClassName}", request.ClassName);
|
||||||
|
return Results.NotFound(new RuntimeResponse
|
||||||
{
|
{
|
||||||
Error = $"Class not found: {request.ClassName}"
|
Error = $"Class not found: {request.ClassName}"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create instance
|
// Security: Verify the type is in allowed assemblies/namespaces
|
||||||
var instance = Activator.CreateInstance(type);
|
if (!IsAllowedAssembly(type.Assembly))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Access to type from disallowed assembly: {Assembly}", type.Assembly.FullName);
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: Check if type is instantiable and safe
|
||||||
|
if (!IsSafeInstantiableType(type))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Attempted to instantiate unsafe type: {Type}", type.FullName);
|
||||||
|
return Results.BadRequest(new RuntimeResponse
|
||||||
|
{
|
||||||
|
Error = "Type instantiation is not allowed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create instance with dependency injection if available
|
||||||
|
var instance = CreateInstanceSafely(type);
|
||||||
if (instance == null)
|
if (instance == null)
|
||||||
{
|
{
|
||||||
|
logger.LogError("Failed to create instance of: {ClassName}", request.ClassName);
|
||||||
return Results.BadRequest(new RuntimeResponse
|
return Results.BadRequest(new RuntimeResponse
|
||||||
{
|
{
|
||||||
Error = $"Failed to create instance of: {request.ClassName}"
|
Error = $"Failed to create instance of: {request.ClassName}"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the method
|
// Get the method with security checks
|
||||||
var method = type.GetMethod(request.MethodName);
|
var method = type.GetMethod(request.MethodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static);
|
||||||
if (method == null)
|
if (method == null)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new RuntimeResponse
|
logger.LogWarning("Method not found: {MethodName} in type {Type}", request.MethodName, type.FullName);
|
||||||
|
return Results.NotFound(new RuntimeResponse
|
||||||
{
|
{
|
||||||
Error = $"Method not found: {request.MethodName}"
|
Error = $"Method not found: {request.MethodName}"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse inputs and invoke method
|
// Security: Verify method is safe to execute
|
||||||
var inputs = request.Inputs as JsonElement?;
|
if (!IsSafeExecutableMethod(method))
|
||||||
object? result;
|
|
||||||
|
|
||||||
if (inputs.HasValue)
|
|
||||||
{
|
{
|
||||||
// Get method parameters
|
logger.LogWarning("Unsafe method attempted: {Method} in type {Type}", method.Name, type.FullName);
|
||||||
var parameters = method.GetParameters();
|
return Results.BadRequest(new RuntimeResponse
|
||||||
var args = new List<object?>();
|
|
||||||
|
|
||||||
foreach (var param in parameters)
|
|
||||||
{
|
{
|
||||||
if (inputs.Value.TryGetProperty(param.Name!, out var property))
|
Error = "Method execution is not allowed"
|
||||||
{
|
});
|
||||||
var value = JsonSerializer.Deserialize(property.GetRawText(), param.ParameterType);
|
|
||||||
args.Add(value);
|
|
||||||
}
|
|
||||||
else if (param.IsOptional)
|
|
||||||
{
|
|
||||||
args.Add(param.DefaultValue);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new RuntimeResponse
|
|
||||||
{
|
|
||||||
Error = $"Missing required parameter: {param.Name}"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = method.Invoke(instance, args.ToArray());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result = method.Invoke(instance, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.Error.WriteLine($"Invocation result type: {result?.GetType()}");
|
// Execute method with proper parameter binding
|
||||||
// If method returns a Task, await it
|
var result = await ExecuteMethodSafely(instance, method, request.Inputs, logger);
|
||||||
if (result is Task taskResult)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Detected Task, awaiting...");
|
|
||||||
await taskResult.ConfigureAwait(false);
|
|
||||||
Console.Error.WriteLine($"Task completed, status: {taskResult.Status}");
|
|
||||||
|
|
||||||
// If it's a Task<T>, get the result via reflection
|
|
||||||
var resultType = taskResult.GetType();
|
|
||||||
Console.Error.WriteLine($"Task result type: {resultType}");
|
|
||||||
if (typeof(Task).IsAssignableFrom(resultType))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Generic Task detected");
|
|
||||||
// Get the Result property
|
|
||||||
var resultProperty = resultType.GetProperty("Result");
|
|
||||||
if (resultProperty != null)
|
|
||||||
{
|
|
||||||
result = resultProperty.GetValue(taskResult);
|
|
||||||
Console.Error.WriteLine($"Result value: {result}");
|
|
||||||
Console.Error.WriteLine($"Result value type: {result?.GetType()}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Result property not found");
|
|
||||||
result = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Task (non-generic) returns nothing
|
|
||||||
Console.Error.WriteLine($"Non-generic Task, setting result to null");
|
|
||||||
result = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.Error.WriteLine($"Final result: {result}");
|
|
||||||
return Results.Ok(new RuntimeResponse
|
return Results.Ok(new RuntimeResponse
|
||||||
{
|
{
|
||||||
Outputs = result
|
Outputs = result
|
||||||
@@ -211,69 +222,188 @@ app.MapPost("/api/runtime/execute", async (RuntimeRequest request) =>
|
|||||||
}
|
}
|
||||||
catch (TargetInvocationException tie) when (tie.InnerException != null)
|
catch (TargetInvocationException tie) when (tie.InnerException != null)
|
||||||
{
|
{
|
||||||
|
logger.LogError(tie.InnerException, "Method execution failed for {ClassName}.{MethodName}",
|
||||||
|
request?.ClassName, request?.MethodName);
|
||||||
|
|
||||||
return Results.BadRequest(new RuntimeResponse
|
return Results.BadRequest(new RuntimeResponse
|
||||||
{
|
{
|
||||||
Error = tie.InnerException.Message
|
Error = tie.InnerException.Message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
catch (SecurityException se)
|
||||||
|
{
|
||||||
|
logger.LogError(se, "Security violation during execution of {ClassName}.{MethodName}",
|
||||||
|
request?.ClassName, request?.MethodName);
|
||||||
|
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new RuntimeResponse
|
logger.LogError(ex, "Unexpected error during runtime execution for {ClassName}.{MethodName}",
|
||||||
{
|
request?.ClassName, request?.MethodName);
|
||||||
Error = ex.Message
|
|
||||||
});
|
return Results.Problem("An unexpected error occurred during execution", statusCode: 500);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.WithName("ExecuteRuntime");
|
.WithName("ExecuteRuntime");
|
||||||
|
|
||||||
|
// Helper methods for enhanced security and functionality
|
||||||
|
static bool IsValidTypeName(string typeName)
|
||||||
|
{
|
||||||
|
// Basic validation - only allow alphanumeric characters, dots, underscores, and spaces
|
||||||
|
return System.Text.RegularExpressions.Regex.IsMatch(typeName, @"^[a-zA-Z0-9._]+$") &&
|
||||||
|
!typeName.Contains("..") && // Prevent path traversal attempts
|
||||||
|
!typeName.StartsWith(".") && // Prevent relative paths
|
||||||
|
!typeName.EndsWith("."); // Prevent malformed names
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsValidMethodName(string methodName)
|
||||||
|
{
|
||||||
|
// Basic validation for method names
|
||||||
|
return System.Text.RegularExpressions.Regex.IsMatch(methodName, @"^[a-zA-Z_][a-zA-Z0-9_]*$");
|
||||||
|
}
|
||||||
|
|
||||||
|
static Type? GetTypeSafely(string className)
|
||||||
|
{
|
||||||
|
// First try direct lookup
|
||||||
|
var type = Type.GetType(className);
|
||||||
|
if (type != null) return type;
|
||||||
|
|
||||||
|
// Search in loaded assemblies with additional safety
|
||||||
|
var assemblies = AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.Where(a => !a.IsDynamic && !a.FullName!.StartsWith("System.Private"))
|
||||||
|
.Take(50); // Limit search scope for performance
|
||||||
|
|
||||||
|
foreach (var assembly in assemblies)
|
||||||
|
{
|
||||||
|
type = assembly.GetType(className);
|
||||||
|
if (type != null) return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsAllowedAssembly(Assembly assembly)
|
||||||
|
{
|
||||||
|
var assemblyName = assembly.GetName().Name;
|
||||||
|
|
||||||
|
// Allow only specific assemblies (customize based on your needs)
|
||||||
|
var allowedPrefixes = new[] {
|
||||||
|
"S8n.Components.", // Replace with your application namespace
|
||||||
|
"MyCompany.MyProject." // Replace with your library namespaces
|
||||||
|
};
|
||||||
|
|
||||||
|
return allowedPrefixes.Any(prefix => assemblyName!.StartsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsSafeInstantiableType(Type type)
|
||||||
|
{
|
||||||
|
// Don't allow instantiation of system types, delegates, interfaces, abstract classes
|
||||||
|
return !type.IsInterface &&
|
||||||
|
!type.IsAbstract &&
|
||||||
|
!type.IsGenericTypeDefinition &&
|
||||||
|
!type.IsNotPublic &&
|
||||||
|
!type.Namespace!.StartsWith("System.") && // More restrictive than just containing "System"
|
||||||
|
!type.Namespace.StartsWith("Microsoft.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static object? CreateInstanceSafely(Type type)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use Activator.CreateInstance with proper exception handling
|
||||||
|
return Activator.CreateInstance(type, nonPublic: false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsSafeExecutableMethod(MethodInfo method)
|
||||||
|
{
|
||||||
|
// Don't allow access to potentially dangerous methods
|
||||||
|
var dangerousNames = new[] { "GetType", "MemberwiseClone", "Finalize", "Dispose" };
|
||||||
|
|
||||||
|
if (dangerousNames.Contains(method.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Check if method has dangerous attributes or return types
|
||||||
|
var returnType = method.ReturnType;
|
||||||
|
if (returnType == typeof(Type))
|
||||||
|
return false; // Potential information disclosure
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<object?> ExecuteMethodSafely(object instance, MethodInfo method, object? inputs, ILogger logger)
|
||||||
|
{
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
object?[] args;
|
||||||
|
|
||||||
|
if (inputs is JsonElement jsonElement)
|
||||||
|
{
|
||||||
|
args = BindParameters(parameters, jsonElement, logger);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
args = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = method.Invoke(instance, args);
|
||||||
|
|
||||||
|
// Handle async methods properly
|
||||||
|
if (result is Task taskResult)
|
||||||
|
{
|
||||||
|
await taskResult.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Extract result from generic Task<T>
|
||||||
|
if (taskResult.GetType().IsGenericType)
|
||||||
|
{
|
||||||
|
var resultProperty = taskResult.GetType().GetProperty("Result");
|
||||||
|
if (resultProperty != null)
|
||||||
|
{
|
||||||
|
return resultProperty.GetValue(taskResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null; // For non-generic Task
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static object?[] BindParameters(ParameterInfo[] parameters, JsonElement jsonElement, ILogger logger)
|
||||||
|
{
|
||||||
|
var args = new object?[parameters.Length];
|
||||||
|
|
||||||
|
for (int i = 0; i < parameters.Length; i++)
|
||||||
|
{
|
||||||
|
var param = parameters[i];
|
||||||
|
|
||||||
|
if (jsonElement.TryGetProperty(param.Name!, out var property))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = JsonSerializer.Deserialize(property.GetRawText(), param.ParameterType);
|
||||||
|
args[i] = value;
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to deserialize parameter {ParamName}", param.Name);
|
||||||
|
throw new ArgumentException($"Invalid value for parameter '{param.Name}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (param.IsOptional)
|
||||||
|
{
|
||||||
|
args[i] = param.DefaultValue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Missing required parameter: {param.Name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
|
||||||
{
|
|
||||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
|
||||||
}
|
|
||||||
|
|
||||||
record RuntimeRequest
|
|
||||||
{
|
|
||||||
public string ClassName { get; set; } = string.Empty;
|
|
||||||
public string MethodName { get; set; } = string.Empty;
|
|
||||||
public object? Inputs { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
record RuntimeResponse
|
|
||||||
{
|
|
||||||
public object? Outputs { get; set; }
|
|
||||||
public string? Error { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
record ComponentDefinition
|
|
||||||
{
|
|
||||||
public string Code { get; set; } = string.Empty;
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
public string Icon { get; set; } = string.Empty;
|
|
||||||
public List<ComponentInput> Inputs { get; set; } = new();
|
|
||||||
public List<ComponentOutput> Outputs { get; set; } = new();
|
|
||||||
public string Source { get; set; } = string.Empty;
|
|
||||||
public string Class { get; set; } = string.Empty;
|
|
||||||
public List<string> Methods { get; set; } = new();
|
|
||||||
public string Gui { get; set; } = string.Empty;
|
|
||||||
public string Category { get; set; } = string.Empty;
|
|
||||||
public List<string> Tags { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
record ComponentInput
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Type { get; set; } = string.Empty;
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
public List<string>? Enum { get; set; }
|
|
||||||
public bool Required { get; set; } = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
record ComponentOutput
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Type { get; set; } = string.Empty;
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,38 +27,57 @@
|
|||||||
<q-btn flat color="primary" label="Retry" @click="refresh" class="q-mt-sm" />
|
<q-btn flat color="primary" label="Retry" @click="refresh" class="q-mt-sm" />
|
||||||
</div>
|
</div>
|
||||||
<q-list separator v-else>
|
<q-list separator v-else>
|
||||||
<q-item
|
<template v-for="component in filteredComponents" :key="component.code">
|
||||||
v-for="component in filteredComponents"
|
<!-- Component item -->
|
||||||
:key="component.code"
|
<q-item
|
||||||
clickable
|
clickable
|
||||||
:active="selectedComponent === component.code"
|
:active="selectedComponent === component.code"
|
||||||
@click="selectComponent(component.code)"
|
@click="selectComponent(component.code)"
|
||||||
active-class="bg-primary text-white"
|
active-class="bg-primary text-white"
|
||||||
>
|
>
|
||||||
<q-item-section avatar>
|
<q-icon :name="component.icon" class="q-mr-sm" size="sm" />
|
||||||
<q-icon :name="component.icon" />
|
<q-item-section>
|
||||||
</q-item-section>
|
<q-item-label>{{ component.name }}</q-item-label>
|
||||||
<q-item-section>
|
<q-item-label
|
||||||
<q-item-label>{{ component.name }}</q-item-label>
|
caption
|
||||||
<q-item-label
|
:class="selectedComponent === component.code ? 'text-white' : 'text-grey'"
|
||||||
caption
|
|
||||||
:class="selectedComponent === component.code ? 'text-white' : 'text-grey'"
|
|
||||||
>
|
|
||||||
{{ component.description }}
|
|
||||||
</q-item-label>
|
|
||||||
<div v-if="component.tags.length" class="q-mt-xs">
|
|
||||||
<q-badge
|
|
||||||
v-for="tag in component.tags"
|
|
||||||
:key="tag"
|
|
||||||
color="secondary"
|
|
||||||
class="q-mr-xs q-mb-xs"
|
|
||||||
size="sm"
|
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ component.description }}
|
||||||
</q-badge>
|
</q-item-label>
|
||||||
</div>
|
<div v-if="component.tags.length" class="q-mt-xs">
|
||||||
</q-item-section>
|
<q-badge
|
||||||
</q-item>
|
v-for="tag in component.tags"
|
||||||
|
:key="tag"
|
||||||
|
color="secondary"
|
||||||
|
class="q-mr-xs q-mb-xs"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<!-- Preset sub-items (only shown if component is selected and has presets) -->
|
||||||
|
<template v-if="component.presets && component.presets.length > 0 && selectedComponent === component.code">
|
||||||
|
<q-item
|
||||||
|
v-for="preset in component.presets"
|
||||||
|
:key="preset.presetId"
|
||||||
|
clickable
|
||||||
|
class="preset-item"
|
||||||
|
dense
|
||||||
|
@click="selectPreset(preset.presetId)"
|
||||||
|
>
|
||||||
|
<q-icon name="mdi-clipboard-play-outline" size="xs" class="q-mr-sm" />
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="text-caption">{{ preset.presetName }}</q-item-label>
|
||||||
|
<q-item-label caption class="text-grey">
|
||||||
|
{{ preset.description }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-card>
|
</q-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,6 +98,7 @@ const emit = defineEmits<{
|
|||||||
'update:selectedComponent': [code: string]
|
'update:selectedComponent': [code: string]
|
||||||
'update:searchQuery': [query: string]
|
'update:searchQuery': [query: string]
|
||||||
refresh: []
|
refresh: []
|
||||||
|
selectPreset: [presetId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const searchQueryModel = computed({
|
const searchQueryModel = computed({
|
||||||
@@ -90,7 +110,20 @@ const selectComponent = (code: string) => {
|
|||||||
emit('update:selectedComponent', code)
|
emit('update:selectedComponent', code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectPreset = (presetId: string) => {
|
||||||
|
emit('selectPreset', presetId)
|
||||||
|
}
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.preset-item {
|
||||||
|
padding-left: 32px;
|
||||||
|
.q-item__section--side {
|
||||||
|
padding-right: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
@click="refreshComponents"
|
@click="refreshComponents"
|
||||||
title="Reload components"
|
title="Reload components"
|
||||||
/>
|
/>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="mdi-content-copy"
|
||||||
|
@click="copyPreset"
|
||||||
|
title="Copy current inputs as preset"
|
||||||
|
/>
|
||||||
<q-btn flat round dense icon="mdi-cog" />
|
<q-btn flat round dense icon="mdi-cog" />
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
@@ -32,6 +40,7 @@ import { useDarkMode } from 'src/composables/useDarkMode'
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: []
|
refresh: []
|
||||||
|
copyPreset: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { toggleDarkMode, isDark } = useDarkMode()
|
const { toggleDarkMode, isDark } = useDarkMode()
|
||||||
@@ -45,4 +54,8 @@ const toggleLeftMenu = () => {
|
|||||||
const refreshComponents = () => {
|
const refreshComponents = () => {
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyPreset = () => {
|
||||||
|
emit('copyPreset')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<div class="s8n-calculator q-pa-md">
|
<div class="s8n-calculator q-pa-md">
|
||||||
<div class="q-gutter-md">
|
<div class="q-gutter-md">
|
||||||
<q-select
|
<q-select
|
||||||
outlined
|
|
||||||
v-model="inputs.operator"
|
v-model="inputs.operator"
|
||||||
label="Operator"
|
label="Operator"
|
||||||
:options="[
|
:options="[
|
||||||
@@ -17,7 +16,6 @@
|
|||||||
<q-input
|
<q-input
|
||||||
v-for="(a, aidx) in inputs.args"
|
v-for="(a, aidx) in inputs.args"
|
||||||
:key="aidx"
|
:key="aidx"
|
||||||
outlined
|
|
||||||
v-model.number="inputs.args[aidx]"
|
v-model.number="inputs.args[aidx]"
|
||||||
:label="`${aidx === 0 ? 'Base' : aidx === 1 ? 'Second' : aidx === 2 ? 'Third' : `${aidx + 1}th`} Number`"
|
:label="`${aidx === 0 ? 'Base' : aidx === 1 ? 'Second' : aidx === 2 ? 'Third' : `${aidx + 1}th`} Number`"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -29,8 +27,6 @@
|
|||||||
class="col-auto"
|
class="col-auto"
|
||||||
icon="mdi-delete"
|
icon="mdi-delete"
|
||||||
color="negative"
|
color="negative"
|
||||||
outline
|
|
||||||
dense
|
|
||||||
></q-btn>
|
></q-btn>
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
@@ -80,10 +76,9 @@ defineExpose({
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.s8n-calculator {
|
.s8n-calculator {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.result-section {
|
.result-section {
|
||||||
min-height: 3em;
|
min-height: 2em;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -2,18 +2,16 @@
|
|||||||
<div class="s8n-http-request q-pa-md">
|
<div class="s8n-http-request q-pa-md">
|
||||||
<div class="q-gutter-md">
|
<div class="q-gutter-md">
|
||||||
<!-- HTTP Method and URL -->
|
<!-- HTTP Method and URL -->
|
||||||
<div class="row q-gutter-md">
|
<div class="row">
|
||||||
<q-select
|
<q-select
|
||||||
outlined
|
|
||||||
v-model="inputs.method"
|
v-model="inputs.method"
|
||||||
label="Method"
|
label="Method"
|
||||||
:options="httpMethods"
|
:options="httpMethods"
|
||||||
emit-value
|
emit-value
|
||||||
map-options
|
map-options
|
||||||
class="col-3"
|
class="col-3 q-mr-sm"
|
||||||
/>
|
/>
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
|
||||||
v-model="inputs.url"
|
v-model="inputs.url"
|
||||||
label="URL"
|
label="URL"
|
||||||
placeholder="https://api.example.com/data"
|
placeholder="https://api.example.com/data"
|
||||||
@@ -31,27 +29,25 @@
|
|||||||
<div
|
<div
|
||||||
v-for="([key, value], index) in Object.entries(inputs.headers)"
|
v-for="([key, value], index) in Object.entries(inputs.headers)"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="row q-gutter-sm items-center"
|
class="row items-center"
|
||||||
>
|
>
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
|
||||||
:model-value="key"
|
:model-value="key"
|
||||||
@update:model-value="updateHeaderKey(index, $event as string)"
|
@update:model-value="updateHeaderKey(index, $event as string)"
|
||||||
label="Header Name"
|
label="Header Name"
|
||||||
dense
|
dense
|
||||||
class="col-4"
|
class="col-4 q-mr-md"
|
||||||
/>
|
/>
|
||||||
<q-input
|
<q-input
|
||||||
outlined
|
|
||||||
:model-value="value"
|
:model-value="value"
|
||||||
@update:model-value="updateHeaderValue(key, $event as string)"
|
@update:model-value="updateHeaderValue(key, $event as string)"
|
||||||
label="Header Value"
|
label="Header Value"
|
||||||
dense
|
dense
|
||||||
class="col"
|
class="col"
|
||||||
/>
|
/>
|
||||||
<q-btn icon="mdi-delete" color="negative" outline dense @click="removeHeader(key)" />
|
<q-btn icon="mdi-delete" color="negative" dense @click="removeHeader(key)" />
|
||||||
</div>
|
</div>
|
||||||
<q-btn icon="mdi-plus" label="Add Header" color="secondary" outline @click="addHeader" />
|
<q-btn icon="mdi-plus" label="Add Header" color="secondary" @click="addHeader" />
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ export interface ComponentOutput {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PresetInfo {
|
||||||
|
presetId: string
|
||||||
|
presetName: string
|
||||||
|
baseComponentCode: string
|
||||||
|
description: string
|
||||||
|
version: string
|
||||||
|
author: string
|
||||||
|
createdAt: string
|
||||||
|
inputs: Record<string, unknown>
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiComponent {
|
export interface ApiComponent {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
@@ -28,6 +40,7 @@ export interface ApiComponent {
|
|||||||
inputs: ComponentInput[]
|
inputs: ComponentInput[]
|
||||||
outputs: ComponentOutput[]
|
outputs: ComponentOutput[]
|
||||||
source: string
|
source: string
|
||||||
|
presets?: PresetInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentInfo {
|
export interface ComponentInfo {
|
||||||
@@ -44,10 +57,12 @@ export interface ComponentInfo {
|
|||||||
outputs: ComponentOutput[]
|
outputs: ComponentOutput[]
|
||||||
source: string
|
source: string
|
||||||
category: string
|
category: string
|
||||||
|
presets: PresetInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useComponentStore() {
|
export function useComponentStore() {
|
||||||
const selectedComponent = ref<string>('')
|
const selectedComponent = ref<string>('')
|
||||||
|
const selectedPreset = ref<string>('')
|
||||||
const availableComponents = ref<ComponentInfo[]>([])
|
const availableComponents = ref<ComponentInfo[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
@@ -55,19 +70,9 @@ export function useComponentStore() {
|
|||||||
|
|
||||||
// Map API component to ComponentInfo
|
// Map API component to ComponentInfo
|
||||||
const mapApiComponent = (apiComponent: ApiComponent): ComponentInfo => {
|
const mapApiComponent = (apiComponent: ApiComponent): ComponentInfo => {
|
||||||
const shortCode = apiComponent.code.split('.').pop() || apiComponent.code
|
const shortCode = apiComponent.code
|
||||||
|
|
||||||
let componentImport: AsyncComponentLoader
|
const componentImport: AsyncComponentLoader = () => import('../components_s8n/' + apiComponent.gui)
|
||||||
switch (apiComponent.gui) {
|
|
||||||
case 'ComponentCalculator.vue':
|
|
||||||
componentImport = () => import('../components_s8n/ComponentCalculator.vue')
|
|
||||||
break
|
|
||||||
case 'ComponentHttpRequest.vue':
|
|
||||||
componentImport = () => import('../components_s8n/ComponentHttpRequest.vue')
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
componentImport = () => import('../components_s8n/ComponentCalculator.vue')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: shortCode,
|
code: shortCode,
|
||||||
@@ -83,6 +88,7 @@ export function useComponentStore() {
|
|||||||
outputs: apiComponent.outputs,
|
outputs: apiComponent.outputs,
|
||||||
source: apiComponent.source,
|
source: apiComponent.source,
|
||||||
category: apiComponent.category,
|
category: apiComponent.category,
|
||||||
|
presets: apiComponent.presets || [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +132,43 @@ export function useComponentStore() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get preset by ID
|
||||||
|
const getPresetById = (presetId: string): PresetInfo | undefined => {
|
||||||
|
for (const component of availableComponents.value) {
|
||||||
|
const preset = component.presets.find(p => p.presetId === presetId)
|
||||||
|
if (preset) return preset
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply preset to component
|
||||||
|
const applyPreset = (presetId: string) => {
|
||||||
|
const preset = getPresetById(presetId)
|
||||||
|
if (!preset) {
|
||||||
|
console.error(`Preset not found: ${presetId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select the component if not already selected
|
||||||
|
if (selectedComponent.value !== preset.baseComponentCode) {
|
||||||
|
selectedComponent.value = preset.baseComponentCode
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedPreset.value = presetId
|
||||||
|
|
||||||
|
// Note: Actual application of inputs happens in PlaygroundPage.vue
|
||||||
|
// which will watch for preset changes and apply to component
|
||||||
|
return preset.inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selected preset
|
||||||
|
const clearSelectedPreset = () => {
|
||||||
|
selectedPreset.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedComponent,
|
selectedComponent,
|
||||||
|
selectedPreset,
|
||||||
availableComponents,
|
availableComponents,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
@@ -135,5 +176,8 @@ export function useComponentStore() {
|
|||||||
fetchComponents,
|
fetchComponents,
|
||||||
currentComponentInfo,
|
currentComponentInfo,
|
||||||
filteredComponents,
|
filteredComponents,
|
||||||
|
getPresetById,
|
||||||
|
applyPreset,
|
||||||
|
clearSelectedPreset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<q-page class="playground-page">
|
<q-page class="playground-page">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<PlaygroundToolbar @refresh="fetchComponents" />
|
<PlaygroundToolbar @refresh="fetchComponents" @copy-preset="copyCurrentPreset" />
|
||||||
|
|
||||||
<div class="row q-pa-md q-col-gutter-md">
|
<div class="row q-pa-md q-col-gutter-md">
|
||||||
<!-- Main Component Area -->
|
<!-- Main Component Area -->
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
@update:selected-component="selectedComponent = $event"
|
@update:selected-component="selectedComponent = $event"
|
||||||
@update:search-query="searchQuery = $event"
|
@update:search-query="searchQuery = $event"
|
||||||
@refresh="fetchComponents"
|
@refresh="fetchComponents"
|
||||||
|
@select-preset="handlePresetSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Component Info Card -->
|
<!-- Component Info Card -->
|
||||||
@@ -67,6 +68,7 @@ const {
|
|||||||
fetchComponents,
|
fetchComponents,
|
||||||
currentComponentInfo,
|
currentComponentInfo,
|
||||||
filteredComponents,
|
filteredComponents,
|
||||||
|
applyPreset,
|
||||||
} = useComponentStore()
|
} = useComponentStore()
|
||||||
|
|
||||||
// Call history
|
// Call history
|
||||||
@@ -182,6 +184,81 @@ const reproduceHistoryItem = async (item: CallHistoryItem) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle preset selection
|
||||||
|
const handlePresetSelect = async (presetId: string) => {
|
||||||
|
console.log('Selected preset:', presetId)
|
||||||
|
|
||||||
|
// Apply preset via store (this will select the component if needed)
|
||||||
|
const presetInputs = applyPreset(presetId)
|
||||||
|
if (!presetInputs) {
|
||||||
|
console.error('Failed to apply preset:', presetId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for component to be loaded (next tick) then set inputs
|
||||||
|
await nextTick()
|
||||||
|
if (componentRef.value && componentRef.value.inputs) {
|
||||||
|
// inputs is a Ref<TIn> from runtime.createExecutor
|
||||||
|
const inputsRef = componentRef.value.inputs as { value: unknown }
|
||||||
|
if (typeof inputsRef === 'object' && inputsRef !== null && 'value' in inputsRef) {
|
||||||
|
inputsRef.value = presetInputs
|
||||||
|
} else {
|
||||||
|
// Fallback: assign directly (might not be reactive)
|
||||||
|
componentRef.value.inputs = presetInputs
|
||||||
|
}
|
||||||
|
console.log('Preset applied:', presetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy current inputs as preset JSON
|
||||||
|
const copyCurrentPreset = async () => {
|
||||||
|
if (!componentRef.value || !componentRef.value.inputs || !currentComponentInfo.value) {
|
||||||
|
console.warn('No component selected or no inputs available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current inputs value
|
||||||
|
let inputsValue: unknown
|
||||||
|
const inputsRef = componentRef.value.inputs as { value: unknown }
|
||||||
|
if (typeof inputsRef === 'object' && inputsRef !== null && 'value' in inputsRef) {
|
||||||
|
inputsValue = inputsRef.value
|
||||||
|
} else {
|
||||||
|
inputsValue = componentRef.value.inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create preset template
|
||||||
|
const presetTemplate = {
|
||||||
|
type: 'preset',
|
||||||
|
presetId: 'my-preset',
|
||||||
|
presetName: 'my-preset',
|
||||||
|
baseComponentCode: currentComponentInfo.value.code,
|
||||||
|
description: `Preset for ${currentComponentInfo.value.name}`,
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'User',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
inputs: inputsValue,
|
||||||
|
metadata: {
|
||||||
|
tags: ['custom'],
|
||||||
|
category: 'user'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to JSON string
|
||||||
|
const jsonString = JSON.stringify(presetTemplate, null, 2)
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
await navigator.clipboard.writeText(jsonString)
|
||||||
|
|
||||||
|
// Show notification (you might want to use Quasar's notification system)
|
||||||
|
console.log('Preset copied to clipboard!')
|
||||||
|
alert('Preset copied to clipboard! You can now save it as a .json file in the components_user/ directory.')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy preset:', err)
|
||||||
|
alert('Failed to copy preset to clipboard. See console for details.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void fetchComponents()
|
void fetchComponents()
|
||||||
|
|||||||
Submodule components updated: 2964949153...dcb3e98ab1
18
components_user/calculator_addtwonumbers.json
Normal file
18
components_user/calculator_addtwonumbers.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"type": "preset",
|
||||||
|
"presetId": "addtwonumbers",
|
||||||
|
"presetName": "addtwonumbers",
|
||||||
|
"baseComponentCode": "basics.calculator",
|
||||||
|
"description": "Add two numbers: 5 and 3",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "System",
|
||||||
|
"createdAt": "2026-02-11T11:16:32.694Z",
|
||||||
|
"inputs": {
|
||||||
|
"operator": "add",
|
||||||
|
"args": [5, 3]
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"tags": ["addition", "simple"],
|
||||||
|
"category": "examples"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
components_user/httprequest_googletest.json
Normal file
22
components_user/httprequest_googletest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"type": "preset",
|
||||||
|
"presetId": "googletest",
|
||||||
|
"presetName": "googletest",
|
||||||
|
"baseComponentCode": "basics.httprequest",
|
||||||
|
"description": "Test request to Google homepage",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "System",
|
||||||
|
"createdAt": "2026-02-11T11:16:32.694Z",
|
||||||
|
"inputs": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://www.google.com",
|
||||||
|
"headers": {
|
||||||
|
"User-Agent": "S8n-Component/1.0"
|
||||||
|
},
|
||||||
|
"body": null
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"tags": ["google", "test", "demo"],
|
||||||
|
"category": "examples"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
components_user/httprequest_postexample.json
Normal file
27
components_user/httprequest_postexample.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"type": "preset",
|
||||||
|
"presetId": "postexample",
|
||||||
|
"presetName": "postexample",
|
||||||
|
"baseComponentCode": "basics.httprequest",
|
||||||
|
"description": "Example POST request to JSON placeholder API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "System",
|
||||||
|
"createdAt": "2026-02-11T11:16:32.694Z",
|
||||||
|
"inputs": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://jsonplaceholder.typicode.com/posts",
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"title": "foo",
|
||||||
|
"body": "bar",
|
||||||
|
"userId": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"tags": ["api", "post", "example"],
|
||||||
|
"category": "examples"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
# Implementation Plan: Replace Swagger with Scalar, Add HttpRequest Component, Create PlaygroundPage
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This plan outlines the steps to accomplish three main tasks:
|
|
||||||
1. Replace the current OpenAPI UI (Swagger) with Scalar API reference UI in the backend.
|
|
||||||
2. Add a new HttpRequest component (similar to Calculator) for making HTTP requests.
|
|
||||||
3. Create a PlaygroundPage.vue for dynamic s8n components manipulation.
|
|
||||||
|
|
||||||
## Task 1: Replace Swagger with Scalar
|
|
||||||
|
|
||||||
### Current State
|
|
||||||
- Backend uses `Microsoft.AspNetCore.OpenApi` package (built-in OpenAPI).
|
|
||||||
- OpenAPI endpoint is mapped via `app.MapOpenApi()` in development environment.
|
|
||||||
- No Swashbuckle/Swagger UI currently installed.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
- Integrate Scalar as middleware to serve a modern API reference UI.
|
|
||||||
- Maintain OpenAPI specification generation (already provided by `AddOpenApi`).
|
|
||||||
- Scalar UI should be accessible at a dedicated route (e.g., `/scalar`).
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
#### 1.1 Add Scalar.AspNetCore NuGet Package
|
|
||||||
Add package reference to `MyCompany.MyProject.BackendApi.csproj`:
|
|
||||||
```xml
|
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="2.0.0" />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 Configure Scalar Middleware
|
|
||||||
Modify `Program.cs`:
|
|
||||||
- Keep `builder.Services.AddOpenApi()` for OpenAPI spec generation.
|
|
||||||
- Replace `app.MapOpenApi()` with Scalar middleware in development environment.
|
|
||||||
- Configure Scalar to use the generated OpenAPI spec.
|
|
||||||
|
|
||||||
Example code:
|
|
||||||
```csharp
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.MapScalarApiReference();
|
|
||||||
// Optional: keep MapOpenApi if needed for raw spec
|
|
||||||
app.MapOpenApi();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.3 Customize Scalar Settings (Optional)
|
|
||||||
Configure Scalar options (title, theme, etc.) via `AddScalar`.
|
|
||||||
|
|
||||||
### Expected Outcome
|
|
||||||
- Visiting `/scalar` in browser displays Scalar API reference UI.
|
|
||||||
- OpenAPI spec remains available at `/openapi/v1.json`.
|
|
||||||
|
|
||||||
## Task 2: Add HttpRequest Component
|
|
||||||
|
|
||||||
### Component Structure
|
|
||||||
|
|
||||||
#### 2.1 C# Backend Component
|
|
||||||
- **Location**: `S8n.Components.Packages/Basics/HttpRequest.cs`
|
|
||||||
- **Namespace**: `S8n.Components.Basics`
|
|
||||||
- **Class**: `HttpRequest`
|
|
||||||
- **Method**: `Execute` (or `Request`)
|
|
||||||
- **Inputs**:
|
|
||||||
- `method` (string): HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
||||||
- `url` (string): Target URL
|
|
||||||
- `headers` (Dictionary<string, string>): Optional request headers
|
|
||||||
- `body` (object): Optional request body (serialized as JSON)
|
|
||||||
- **Outputs**:
|
|
||||||
- `statusCode` (int): HTTP status code
|
|
||||||
- `response` (string): Response body as string
|
|
||||||
- `headers` (Dictionary<string, string>): Response headers
|
|
||||||
- `duration` (long): Request duration in milliseconds
|
|
||||||
|
|
||||||
Implementation will use `HttpClient` to make the request.
|
|
||||||
|
|
||||||
#### 2.2 Vue Frontend Component
|
|
||||||
- **Location**: `QuasarFrontend/src/components_s8n/ComponentHttpRequest.vue`
|
|
||||||
- **Pattern**: Follow `ComponentCalculator.vue` structure.
|
|
||||||
- **UI Elements**:
|
|
||||||
- Dropdown for HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
||||||
- Text input for URL
|
|
||||||
- Dynamic key-value pair inputs for headers (add/remove)
|
|
||||||
- JSON editor for request body (textarea with JSON validation)
|
|
||||||
- Execute button with loading state
|
|
||||||
- Display response status, headers, body, and duration
|
|
||||||
|
|
||||||
#### 2.3 Component Definition Markdown
|
|
||||||
- **Location**: `components/basics/httprequest.md`
|
|
||||||
- **Format**: Similar to `calculator.md` with appropriate inputs/outputs.
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
#### 2.4 Create C# Class
|
|
||||||
1. Create `HttpRequest.cs` file.
|
|
||||||
2. Implement method with error handling (timeout, invalid URL, etc.).
|
|
||||||
3. Ensure JSON serialization/deserialization.
|
|
||||||
|
|
||||||
#### 2.5 Create Vue Component
|
|
||||||
1. Create `ComponentHttpRequest.vue`.
|
|
||||||
2. Use `runtime.createExecutor` with appropriate types.
|
|
||||||
3. Design UI using Quasar components.
|
|
||||||
|
|
||||||
#### 2.6 Create Markdown Definition
|
|
||||||
1. Create `httprequest.md` with YAML definition.
|
|
||||||
|
|
||||||
#### 2.7 Register Component (if needed)
|
|
||||||
- The runtime automatically discovers classes via reflection; no explicit registration required.
|
|
||||||
|
|
||||||
## Task 3: Create PlaygroundPage.vue
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
- A dedicated page for interacting with s8n components.
|
|
||||||
- Dynamic component selection and rendering.
|
|
||||||
- Support for multiple components (Calculator, HttpRequest, future components).
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
#### 3.1 Add Route
|
|
||||||
Modify `QuasarFrontend/src/router/routes.ts`:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
path: '/playground',
|
|
||||||
component: () => import('layouts/MainLayout.vue'),
|
|
||||||
children: [{ path: '', component: () => import('pages/PlaygroundPage.vue') }],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2 Create PlaygroundPage.vue
|
|
||||||
- **Location**: `QuasarFrontend/src/pages/PlaygroundPage.vue`
|
|
||||||
- **Structure**:
|
|
||||||
- Left sidebar: List of available components (hardcoded for now).
|
|
||||||
- Main content: Dynamic component render area.
|
|
||||||
- Output display area (could be integrated into each component).
|
|
||||||
|
|
||||||
#### 3.3 Component Registry
|
|
||||||
Create a component registry file (`src/components_s8n/registry.ts`) that exports:
|
|
||||||
- Component definitions (name, description, Vue component import).
|
|
||||||
- Dynamic import of components.
|
|
||||||
|
|
||||||
#### 3.4 Dynamic Component Loading
|
|
||||||
Use Vue's `<component :is="selectedComponent">` to render selected component.
|
|
||||||
|
|
||||||
#### 3.5 UI Enhancements
|
|
||||||
- Use Quasar's `q-select` for component selection.
|
|
||||||
- Provide a clean layout with card containers.
|
|
||||||
|
|
||||||
## Dependencies and Considerations
|
|
||||||
|
|
||||||
### Backend Dependencies
|
|
||||||
- `Scalar.AspNetCore` (new)
|
|
||||||
- `Microsoft.AspNetCore.OpenApi` (already present)
|
|
||||||
- `System.Net.Http` (for HttpRequest component)
|
|
||||||
|
|
||||||
### Frontend Dependencies
|
|
||||||
- None new; use existing Quasar and Vue.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- Test Scalar UI loads correctly.
|
|
||||||
- Test HttpRequest component with mock HTTP server.
|
|
||||||
- Test PlaygroundPage navigation and component switching.
|
|
||||||
|
|
||||||
## Timeline and Priority
|
|
||||||
1. **Priority 1**: HttpRequest component (core functionality).
|
|
||||||
2. **Priority 2**: PlaygroundPage (user interface).
|
|
||||||
3. **Priority 3**: Scalar integration (documentation).
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
- Scalar UI accessible at `/scalar` (development only).
|
|
||||||
- HttpRequest component works for basic GET/POST requests.
|
|
||||||
- PlaygroundPage renders Calculator and HttpRequest components.
|
|
||||||
- No regression in existing Calculator component.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- Scalar integration may require configuration for production; currently planned for development only.
|
|
||||||
- HttpRequest component should handle CORS limitations (backend acts as proxy).
|
|
||||||
- PlaygroundPage can be extended later with component discovery from backend API.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
1. Review this plan with stakeholders.
|
|
||||||
2. Proceed to implementation in Code mode.
|
|
||||||
Reference in New Issue
Block a user