w4c-workflows-api/Services/Nodes/NodeBlueprintCatalog.cs
2026-09-11 19:04:50 +03:00

179 lines
6.9 KiB
C#

using System.Reflection;
using System.Text.Json;
using w4c_workflows.Models.Nodes;
namespace w4c_workflows.Services.Nodes;
/// <summary>
/// The catalog of known node types. It is the single source of truth for the
/// compiler/validator and the authoring UI (palette + generic parameter forms).
///
/// Blueprints come from two places:
/// - built-in core nodes, shipped as embedded JSON under <c>Catalog/</c>;
/// - additional packs (connectors, OpenAPI imports) loaded from files.
/// </summary>
public class NodeBlueprintCatalog
{
private const string EmbeddedRoot = "w4c_workflows.Services.Nodes.Catalog.";
private readonly IReadOnlyDictionary<string, List<NodeBlueprint>> _byType;
public NodeBlueprintCatalog(IEnumerable<NodeBlueprint> blueprints)
{
var byType = new Dictionary<string, List<NodeBlueprint>>(StringComparer.Ordinal);
foreach (var blueprint in blueprints)
{
if (string.IsNullOrWhiteSpace(blueprint.Type))
throw new InvalidOperationException("a node blueprint is missing its type id");
if (!byType.TryGetValue(blueprint.Type, out var versions))
byType[blueprint.Type] = versions = new List<NodeBlueprint>();
if (versions.Any(existing => SameVersion(existing.Version, blueprint.Version)))
throw new InvalidOperationException(
$"duplicate node blueprint '{blueprint.Type}' version {blueprint.Version}");
versions.Add(blueprint);
}
foreach (var versions in byType.Values)
versions.Sort((a, b) => a.Version.CompareTo(b.Version));
_byType = byType;
}
/// <summary>All registered blueprints, newest version first within each type.</summary>
public IReadOnlyList<NodeBlueprint> All =>
_byType.Values.SelectMany(v => v).ToList();
/// <summary>Distinct category labels, sorted, for the palette's category rail.</summary>
public IReadOnlyList<string> Categories =>
_byType.Values.SelectMany(v => v)
.SelectMany(b => b.Categories)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(c => c, StringComparer.OrdinalIgnoreCase)
.ToList();
public bool Contains(string type) => _byType.ContainsKey(type);
/// <summary>The default (highest) version of a type, or null when unknown.</summary>
public NodeBlueprint? Get(string type)
=> _byType.TryGetValue(type, out var versions) ? versions[^1] : null;
/// <summary>
/// Resolves a specific version. A requested version of 0/null means "default"
/// (highest). Otherwise the closest version not newer than requested is used,
/// so a task pinned to 2.5 keeps working after 2.6 ships.
/// </summary>
public NodeBlueprint? Resolve(string type, double? version = null)
{
if (!_byType.TryGetValue(type, out var versions))
return null;
if (version is null or 0)
return versions[^1];
NodeBlueprint? match = null;
foreach (var candidate in versions)
{
if (candidate.Version <= version.Value + 0.0001)
match = candidate;
else
break;
}
return match;
}
/// <summary>All versions of a type, newest first.</summary>
public IReadOnlyList<NodeBlueprint> Versions(string type)
=> _byType.TryGetValue(type, out var versions)
? versions.OrderByDescending(v => v.Version).ToList()
: Array.Empty<NodeBlueprint>();
/// <summary>
/// Filters the palette. <paramref name="query"/> matches type, display name,
/// description or category; <paramref name="kind"/> matches <see cref="NodeKind"/>;
/// <paramref name="category"/> matches a category label.
/// </summary>
public IReadOnlyList<NodeBlueprint> Search(string? query = null, string? kind = null, string? category = null)
{
IEnumerable<NodeBlueprint> results = _byType.Values.Select(v => v[^1]);
if (!string.IsNullOrWhiteSpace(kind))
results = results.Where(b => string.Equals(b.Kind, kind, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(category))
results = results.Where(b => b.Categories.Any(c => string.Equals(c, category, StringComparison.OrdinalIgnoreCase)));
if (!string.IsNullOrWhiteSpace(query))
{
var needle = query.Trim();
results = results.Where(b =>
Contains(b.Type, needle) ||
Contains(b.DisplayName, needle) ||
Contains(b.Description, needle) ||
b.Categories.Any(c => Contains(c, needle)));
}
return results
.OrderBy(b => b.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>Loads core blueprints embedded in the running assembly.</summary>
public static IReadOnlyList<NodeBlueprint> LoadEmbedded()
=> LoadEmbedded(typeof(NodeBlueprintCatalog).Assembly);
/// <summary>Loads every <c>Catalog/*.json</c> embedded in <paramref name="assembly"/>.</summary>
public static IReadOnlyList<NodeBlueprint> LoadEmbedded(Assembly assembly)
{
var blueprints = new List<NodeBlueprint>();
foreach (var resource in assembly.GetManifestResourceNames())
{
if (!resource.StartsWith(EmbeddedRoot, StringComparison.Ordinal) ||
!resource.EndsWith(".json", StringComparison.Ordinal))
{
continue;
}
using var stream = assembly.GetManifestResourceStream(resource);
if (stream == null)
continue;
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var blueprint = JsonSerializer.Deserialize<NodeBlueprint>(json, NodeJson.Options)
?? throw new InvalidOperationException($"node blueprint '{resource}' is empty");
blueprints.Add(blueprint);
}
return blueprints;
}
/// <summary>Loads all <c>*.json</c> blueprints from a directory (connector packs).</summary>
public static IReadOnlyList<NodeBlueprint> LoadDirectory(string directory)
{
var blueprints = new List<NodeBlueprint>();
if (!Directory.Exists(directory))
return blueprints;
foreach (var file in Directory.EnumerateFiles(directory, "*.json", SearchOption.AllDirectories))
{
var json = File.ReadAllText(file);
var blueprint = JsonSerializer.Deserialize<NodeBlueprint>(json, NodeJson.Options)
?? throw new InvalidOperationException($"node blueprint '{file}' is empty");
blueprints.Add(blueprint);
}
return blueprints;
}
private static bool SameVersion(double a, double b) => Math.Abs(a - b) < 0.0001;
private static bool Contains(string? haystack, string needle)
=> haystack?.Contains(needle, StringComparison.OrdinalIgnoreCase) == true;
}