up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Symbols Server CI / symbols-smoke (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-24 20:57:49 +02:00
parent 46c8c47d06
commit 7c39058386
92 changed files with 3549 additions and 157 deletions

View File

@@ -0,0 +1,8 @@
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public interface IGraphSearchService
{
IAsyncEnumerable<string> SearchAsync(string tenant, GraphSearchRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,95 @@
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphRepository
{
private readonly List<NodeTile> _nodes;
public InMemoryGraphRepository()
{
_nodes = new List<NodeTile>
{
new() { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0", ["ecosystem"] = "npm" } },
new() { Id = "gn:acme:component:widget", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } },
new() { Id = "gn:acme:artifact:sha256:abc", Kind = "artifact", Tenant = "acme", Attributes = new() { ["digest"] = "sha256:abc", ["ecosystem"] = "container" } },
new() { Id = "gn:acme:component:gamma", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:nuget/Gamma@3.1.4", ["ecosystem"] = "nuget" } },
new() { Id = "gn:bravo:component:widget", Kind = "component", Tenant = "bravo",Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } },
new() { Id = "gn:bravo:artifact:sha256:def", Kind = "artifact", Tenant = "bravo",Attributes = new() { ["digest"] = "sha256:def", ["ecosystem"] = "container" } },
};
}
public IEnumerable<NodeTile> Query(string tenant, GraphSearchRequest request)
{
var limit = Math.Clamp(request.Limit ?? 50, 1, 500);
var cursorOffset = CursorCodec.Decode(request.Cursor);
var queryable = _nodes
.Where(n => n.Tenant.Equals(tenant, StringComparison.Ordinal))
.Where(n => request.Kinds.Contains(n.Kind, StringComparer.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(request.Query))
{
queryable = queryable.Where(n => MatchesQuery(n, request.Query!));
}
if (request.Filters is not null)
{
queryable = queryable.Where(n => FiltersMatch(n, request.Filters!));
}
queryable = request.Ordering switch
{
"id" => queryable.OrderBy(n => n.Id, StringComparer.Ordinal),
_ => queryable.OrderBy(n => n.Id.Length).ThenBy(n => n.Id, StringComparer.Ordinal)
};
return queryable.Skip(cursorOffset).Take(limit + 1).ToArray();
}
private static bool MatchesQuery(NodeTile node, string query)
{
var q = query.ToLowerInvariant();
return node.Id.ToLowerInvariant().Contains(q)
|| node.Attributes.Values.OfType<string>().Any(v => v.Contains(q, StringComparison.OrdinalIgnoreCase));
}
private static bool FiltersMatch(NodeTile node, IReadOnlyDictionary<string, object> filters)
{
foreach (var kvp in filters)
{
if (!node.Attributes.TryGetValue(kvp.Key, out var value))
{
return false;
}
if (kvp.Value is null)
{
continue;
}
if (!kvp.Value.ToString()!.Equals(value?.ToString(), StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
}
internal static class CursorCodec
{
public static string Encode(int offset) => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(offset.ToString()));
public static int Decode(string? token)
{
if (string.IsNullOrWhiteSpace(token)) return 0;
try
{
var text = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(token));
return int.TryParse(text, out var value) ? value : 0;
}
catch
{
return 0;
}
}
}

View File

@@ -0,0 +1,46 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphSearchService : IGraphSearchService
{
private readonly InMemoryGraphRepository _repository;
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public InMemoryGraphSearchService(InMemoryGraphRepository repository)
{
_repository = repository;
}
public async IAsyncEnumerable<string> SearchAsync(string tenant, GraphSearchRequest request, [EnumeratorCancellation] CancellationToken ct = default)
{
var limit = Math.Clamp(request.Limit ?? 50, 1, 500);
var results = _repository.Query(tenant, request).ToArray();
var items = results.Take(limit).ToArray();
var remaining = results.Length > limit ? results.Length - limit : 0;
var cost = new CostBudget(limit, Math.Max(0, limit - items.Length), items.Length);
var seq = 0;
foreach (var item in items)
{
var envelope = new TileEnvelope("node", seq++, item, cost);
yield return JsonSerializer.Serialize(envelope, Options);
}
if (remaining > 0)
{
var nextCursor = CursorCodec.Encode(CursorCodec.Decode(request.Cursor) + items.Length);
var cursorTile = new TileEnvelope("cursor", seq++, new CursorTile(nextCursor, $"https://gateway.local/api/graph/search?cursor={nextCursor}"));
yield return JsonSerializer.Serialize(cursorTile, Options);
}
await Task.CompletedTask;
}
}