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,78 @@
using System.Text.Json.Serialization;
namespace StellaOps.Graph.Api.Contracts;
public record GraphSearchRequest
{
[JsonPropertyName("kinds")]
public string[] Kinds { get; init; } = Array.Empty<string>();
[JsonPropertyName("query")]
public string? Query { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("filters")]
public Dictionary<string, object>? Filters { get; init; }
[JsonPropertyName("ordering")]
public string? Ordering { get; init; }
[JsonPropertyName("cursor")]
public string? Cursor { get; init; }
}
public static class SearchValidator
{
public static string? Validate(GraphSearchRequest req)
{
if (req.Kinds is null || req.Kinds.Length == 0)
{
return "kinds is required";
}
if (req.Limit.HasValue && (req.Limit.Value <= 0 || req.Limit.Value > 500))
{
return "limit must be between 1 and 500";
}
if (string.IsNullOrWhiteSpace(req.Query) && (req.Filters is null || req.Filters.Count == 0) && string.IsNullOrWhiteSpace(req.Cursor))
{
return "query or filters or cursor must be provided";
}
if (!string.IsNullOrWhiteSpace(req.Ordering) && req.Ordering is not ("relevance" or "id"))
{
return "ordering must be relevance or id";
}
return null;
}
}
public record CostBudget(int Limit, int Remaining, int Consumed);
public record NodeTile
{
public string Id { get; init; } = string.Empty;
public string Kind { get; init; } = string.Empty;
public string Tenant { get; init; } = string.Empty;
public Dictionary<string, object?> Attributes { get; init; } = new();
public int? PathHop { get; init; }
public Dictionary<string, OverlayPayload>? Overlays { get; init; }
}
public record CursorTile(string Token, string ResumeUrl);
public record TileEnvelope(string Type, int Seq, object Data, CostBudget? Cost = null);
public record OverlayPayload(string Kind, string Version, object Data);
public record ErrorResponse
{
public string Error { get; init; } = "GRAPH_VALIDATION_FAILED";
public string Message { get; init; } = string.Empty;
public object? Details { get; init; }
public string? RequestId { get; init; }
}

View File

@@ -0,0 +1,56 @@
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<InMemoryGraphRepository>();
builder.Services.AddSingleton<IGraphSearchService, InMemoryGraphSearchService>();
var app = builder.Build();
app.UseRouting();
app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest request, IGraphSearchService service, CancellationToken ct) =>
{
context.Response.ContentType = "application/x-ndjson";
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
var validation = SearchValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
return Results.Empty;
}
await foreach (var line in service.SearchAsync(tenant!, request, ct))
{
await context.Response.WriteAsync(line, ct);
await context.Response.WriteAsync("\n", ct);
await context.Response.Body.FlushAsync(ct);
}
return Results.Empty;
});
app.Run();
static async Task WriteError(HttpContext ctx, int status, string code, string message, CancellationToken ct)
{
ctx.Response.StatusCode = status;
var payload = System.Text.Json.JsonSerializer.Serialize(new ErrorResponse
{
Error = code,
Message = message
});
await ctx.Response.WriteAsync(payload + "\n", ct);
}

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;
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1591</NoWarn>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,65 @@
using System.Collections.Generic;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class SearchServiceTests
{
[Fact]
public async Task SearchAsync_ReturnsNodeAndCursorTiles()
{
var service = new InMemoryGraphSearchService();
var req = new GraphSearchRequest
{
Kinds = new[] { "component" },
Query = "example",
Limit = 5
};
var results = new List<string>();
await foreach (var line in service.SearchAsync("acme", req))
{
results.Add(line);
}
Assert.Collection(results,
first => Assert.Contains("\"type\":\"node\"", first),
second => Assert.Contains("\"type\":\"cursor\"", second));
}
[Fact]
public async Task SearchAsync_RespectsCursorAndLimit()
{
var service = new InMemoryGraphSearchService();
var firstPage = new GraphSearchRequest { Kinds = new[] { "component" }, Limit = 1, Query = "widget" };
var results = new List<string>();
await foreach (var line in service.SearchAsync("acme", firstPage))
{
results.Add(line);
}
Assert.Equal(2, results.Count); // node + cursor
var cursorToken = ExtractCursor(results.Last());
var secondPage = firstPage with { Cursor = cursorToken };
var secondResults = new List<string>();
await foreach (var line in service.SearchAsync("acme", secondPage))
{
secondResults.Add(line);
}
Assert.Contains(secondResults, r => r.Contains("\"type\":\"node\""));
}
private static string ExtractCursor(string cursorJson)
{
const string tokenMarker = "\"token\":\"";
var start = cursorJson.IndexOf(tokenMarker, StringComparison.Ordinal);
if (start < 0) return string.Empty;
start += tokenMarker.Length;
var end = cursorJson.IndexOf('"', start);
return end > start ? cursorJson[start..end] : string.Empty;
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Graph.Api/StellaOps.Graph.Api.csproj" />
<PackageReference Update="xunit" />
<PackageReference Update="xunit.runner.visualstudio" />
<PackageReference Update="Microsoft.NET.Test.Sdk" />
</ItemGroup>
</Project>