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
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:
78
src/Graph/StellaOps.Graph.Api/Contracts/SearchContracts.cs
Normal file
78
src/Graph/StellaOps.Graph.Api/Contracts/SearchContracts.cs
Normal 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; }
|
||||
}
|
||||
56
src/Graph/StellaOps.Graph.Api/Program.cs
Normal file
56
src/Graph/StellaOps.Graph.Api/Program.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
9
src/Graph/StellaOps.Graph.Api/StellaOps.Graph.Api.csproj
Normal file
9
src/Graph/StellaOps.Graph.Api/StellaOps.Graph.Api.csproj
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user