up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-26 07:47:08 +02:00
parent 56e2f64d07
commit 1c782897f7
184 changed files with 8991 additions and 649 deletions

View File

@@ -23,6 +23,36 @@ public record GraphSearchRequest
public string? Cursor { get; init; }
}
public record GraphQueryRequest
{
[JsonPropertyName("kinds")]
public string[] Kinds { get; init; } = Array.Empty<string>();
[JsonPropertyName("query")]
public string? Query { get; init; }
[JsonPropertyName("filters")]
public Dictionary<string, object>? Filters { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("cursor")]
public string? Cursor { get; init; }
[JsonPropertyName("includeEdges")]
public bool IncludeEdges { get; init; } = true;
[JsonPropertyName("includeStats")]
public bool IncludeStats { get; init; } = true;
[JsonPropertyName("includeOverlays")]
public bool IncludeOverlays { get; init; } = false;
[JsonPropertyName("budget")]
public GraphQueryBudget? Budget { get; init; }
}
public static class SearchValidator
{
public static string? Validate(GraphSearchRequest req)
@@ -51,6 +81,234 @@ public static class SearchValidator
}
}
public static class QueryValidator
{
public static string? Validate(GraphQueryRequest 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 (req.Budget is not null)
{
if (req.Budget.Tiles.HasValue && (req.Budget.Tiles < 1 || req.Budget.Tiles > 6000))
{
return "budget.tiles must be between 1 and 5000";
}
if (req.Budget.Nodes.HasValue && req.Budget.Nodes < 1)
{
return "budget.nodes must be >= 1";
}
if (req.Budget.Edges.HasValue && req.Budget.Edges < 1)
{
return "budget.edges must be >= 1";
}
}
return null;
}
}
public record GraphExportRequest
{
[JsonPropertyName("format")]
public string Format { get; init; } = "ndjson"; // ndjson, csv, graphml, png, svg
[JsonPropertyName("includeEdges")]
public bool IncludeEdges { get; init; } = true;
[JsonPropertyName("snapshotId")]
public string? SnapshotId { get; init; }
[JsonPropertyName("kinds")]
public string[]? Kinds { get; init; }
[JsonPropertyName("query")]
public string? Query { get; init; }
[JsonPropertyName("filters")]
public Dictionary<string, object>? Filters { get; init; }
}
public static class ExportValidator
{
private static readonly HashSet<string> SupportedFormats = new(StringComparer.OrdinalIgnoreCase)
{
"ndjson", "csv", "graphml", "png", "svg"
};
public static string? Validate(GraphExportRequest req)
{
if (!SupportedFormats.Contains(req.Format))
{
return "format must be one of ndjson,csv,graphml,png,svg";
}
if (req.Kinds is not null && req.Kinds.Length == 0)
{
return "kinds cannot be empty array";
}
return null;
}
}
public record GraphPathRequest
{
[JsonPropertyName("sources")]
public string[] Sources { get; init; } = Array.Empty<string>();
[JsonPropertyName("targets")]
public string[] Targets { get; init; } = Array.Empty<string>();
[JsonPropertyName("kinds")]
public string[] Kinds { get; init; } = Array.Empty<string>();
[JsonPropertyName("maxDepth")]
public int? MaxDepth { get; init; }
[JsonPropertyName("filters")]
public Dictionary<string, object>? Filters { get; init; }
[JsonPropertyName("includeOverlays")]
public bool IncludeOverlays { get; init; } = false;
[JsonPropertyName("budget")]
public GraphQueryBudget? Budget { get; init; }
}
public static class PathValidator
{
public static string? Validate(GraphPathRequest req)
{
if (req.Sources is null || req.Sources.Length == 0)
{
return "sources is required";
}
if (req.Targets is null || req.Targets.Length == 0)
{
return "targets is required";
}
if (req.MaxDepth.HasValue && (req.MaxDepth.Value < 1 || req.MaxDepth.Value > 6))
{
return "maxDepth must be between 1 and 6";
}
if (req.Budget is not null)
{
if (req.Budget.Tiles.HasValue && (req.Budget.Tiles < 1 || req.Budget.Tiles > 6000))
{
return "budget.tiles must be between 1 and 6000";
}
if (req.Budget.Nodes.HasValue && req.Budget.Nodes < 1)
{
return "budget.nodes must be >= 1";
}
if (req.Budget.Edges.HasValue && req.Budget.Edges < 1)
{
return "budget.edges must be >= 1";
}
}
return null;
}
}
public record GraphDiffRequest
{
[JsonPropertyName("snapshotA")]
public string SnapshotA { get; init; } = string.Empty;
[JsonPropertyName("snapshotB")]
public string SnapshotB { get; init; } = string.Empty;
[JsonPropertyName("includeEdges")]
public bool IncludeEdges { get; init; } = true;
[JsonPropertyName("includeStats")]
public bool IncludeStats { get; init; } = true;
[JsonPropertyName("budget")]
public GraphQueryBudget? Budget { get; init; }
}
public static class DiffValidator
{
public static string? Validate(GraphDiffRequest req)
{
if (string.IsNullOrWhiteSpace(req.SnapshotA))
{
return "snapshotA is required";
}
if (string.IsNullOrWhiteSpace(req.SnapshotB))
{
return "snapshotB is required";
}
if (req.Budget is not null)
{
if (req.Budget.Tiles.HasValue && (req.Budget.Tiles < 1 || req.Budget.Tiles > 6000))
{
return "budget.tiles must be between 1 and 6000";
}
if (req.Budget.Nodes.HasValue && req.Budget.Nodes < 1)
{
return "budget.nodes must be >= 1";
}
if (req.Budget.Edges.HasValue && req.Budget.Edges < 1)
{
return "budget.edges must be >= 1";
}
}
return null;
}
}
public record GraphQueryBudget
{
[JsonPropertyName("tiles")]
public int? Tiles { get; init; }
[JsonPropertyName("nodes")]
public int? Nodes { get; init; }
[JsonPropertyName("edges")]
public int? Edges { get; init; }
public GraphQueryBudget ApplyDefaults()
{
return new GraphQueryBudget
{
Tiles = Tiles ?? 6000,
Nodes = Nodes ?? 5000,
Edges = Edges ?? 10000
};
}
public static GraphQueryBudget Default { get; } = new();
}
public record CostBudget(int Limit, int Remaining, int Consumed);
public record NodeTile
@@ -63,6 +321,22 @@ public record NodeTile
public Dictionary<string, OverlayPayload>? Overlays { get; init; }
}
public record EdgeTile
{
public string Id { get; init; } = string.Empty;
public string Kind { get; init; } = "depends_on";
public string Tenant { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty;
public string Target { get; init; } = string.Empty;
public Dictionary<string, object?> Attributes { get; init; } = new();
}
public record StatsTile
{
public int Nodes { get; init; }
public int Edges { get; init; }
}
public record CursorTile(string Token, string ResumeUrl);
public record TileEnvelope(string Type, int Seq, object Data, CostBudget? Cost = null);
@@ -76,3 +350,22 @@ public record ErrorResponse
public object? Details { get; init; }
public string? RequestId { get; init; }
}
public record DiffTile
{
public string EntityType { get; init; } = string.Empty;
public string ChangeType { get; init; } = string.Empty;
public string Id { get; init; } = string.Empty;
public object? Before { get; init; }
public object? After { get; init; }
}
public record DiffStatsTile
{
public int NodesAdded { get; init; }
public int NodesRemoved { get; init; }
public int NodesChanged { get; init; }
public int EdgesAdded { get; init; }
public int EdgesRemoved { get; init; }
public int EdgesChanged { get; init; }
}

View File

@@ -0,0 +1,19 @@
# Graph API Deploy Health Checks
- **Readiness**: `GET /healthz` on port 8080
- **Liveness**: `GET /healthz` on port 8080
- Expected latency: < 200ms on local/dev.
- Failing conditions:
- Missing `X-Stella-Tenant` header on app routes returns 400 but healthz remains 200.
- Rate limiting does not apply to `/healthz`.
Smoke test (once deployed):
```bash
curl -i http://localhost:8080/healthz
curl -i -X POST http://localhost:8080/graph/search \
-H "X-Stella-Tenant: demo" \
-H "X-Stella-Scopes: graph:read graph:query" \
-H "Authorization: bearer demo" \
-H "Content-Type: application/json" \
-d '{"kinds":["component"],"query":"pkg:"}'
```

View File

@@ -0,0 +1,18 @@
version: "3.9"
services:
graph-api:
image: stellaops/graph-api:latest
container_name: stellaops-graph-api
environment:
ASPNETCORE_URLS: "http://0.0.0.0:8080"
STELLAOPS_GRAPH_SNAPSHOT_DIR: "/data/snapshots"
ports:
- "8080:8080"
volumes:
- ./data/snapshots:/data/snapshots
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 15s
timeout: 5s
retries: 3

View File

@@ -0,0 +1,85 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: stellaops-graph-api
labels:
app: stellaops-graph-api
spec:
replicas: 2
selector:
matchLabels:
app: stellaops-graph-api
template:
metadata:
labels:
app: stellaops-graph-api
spec:
containers:
- name: graph-api
image: stellaops/graph-api:latest
imagePullPolicy: IfNotPresent
env:
- name: ASPNETCORE_URLS
value: http://0.0.0.0:8080
- name: STELLAOPS_GRAPH_SNAPSHOT_DIR
value: /var/lib/stellaops/graph/snapshots
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: snapshots
mountPath: /var/lib/stellaops/graph/snapshots
volumes:
- name: snapshots
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: stellaops-graph-api
labels:
app: stellaops-graph-api
spec:
selector:
app: stellaops-graph-api
ports:
- name: http
protocol: TCP
port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: stellaops-graph-api
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "25m"
spec:
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: stellaops-graph-api
port:
number: 80

View File

@@ -3,14 +3,24 @@ using StellaOps.Graph.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<InMemoryGraphRepository>();
builder.Services.AddSingleton<IGraphSearchService, InMemoryGraphSearchService>();
builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
builder.Services.AddScoped<IGraphDiffService, InMemoryGraphDiffService>();
builder.Services.AddScoped<IOverlayService, InMemoryOverlayService>();
builder.Services.AddScoped<IGraphExportService, InMemoryGraphExportService>();
builder.Services.AddSingleton<IRateLimiter>(_ => new RateLimiterService(limitPerWindow: 120));
builder.Services.AddSingleton<IAuditLogger, InMemoryAuditLogger>();
builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
var app = builder.Build();
app.UseRouting();
app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest request, IGraphSearchService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
context.Response.ContentType = "application/x-ndjson";
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(tenant))
@@ -25,10 +35,28 @@ app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest requ
return Results.Empty;
}
if (!RateLimit(context, "/graph/search"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/search", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
return Results.Empty;
}
var validation = SearchValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/search", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
@@ -38,10 +66,242 @@ app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest requ
await context.Response.WriteAsync("\n", ct);
await context.Response.Body.FlushAsync(ct);
}
LogAudit(context, "/graph/search", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Empty;
});
app.MapPost("/graph/query", async (HttpContext context, GraphQueryRequest request, IGraphQueryService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
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;
}
if (!RateLimit(context, "/graph/query"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/query", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
return Results.Empty;
}
var validation = QueryValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/query", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
await foreach (var line in service.QueryAsync(tenant!, request, ct))
{
await context.Response.WriteAsync(line, ct);
await context.Response.WriteAsync("\n", ct);
await context.Response.Body.FlushAsync(ct);
}
LogAudit(context, "/graph/query", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Empty;
});
app.MapPost("/graph/paths", async (HttpContext context, GraphPathRequest request, IGraphPathService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
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;
}
if (!RateLimit(context, "/graph/paths"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/paths", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
return Results.Empty;
}
var validation = PathValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/paths", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
await foreach (var line in service.FindPathsAsync(tenant!, request, ct))
{
await context.Response.WriteAsync(line, ct);
await context.Response.WriteAsync("\n", ct);
await context.Response.Body.FlushAsync(ct);
}
LogAudit(context, "/graph/paths", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Empty;
});
app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request, IGraphDiffService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
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;
}
if (!RateLimit(context, "/graph/diff"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/diff", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:query"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
return Results.Empty;
}
var validation = DiffValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/diff", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
await foreach (var line in service.DiffAsync(tenant!, request, ct))
{
await context.Response.WriteAsync(line, ct);
await context.Response.WriteAsync("\n", ct);
await context.Response.Body.FlushAsync(ct);
}
LogAudit(context, "/graph/diff", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Empty;
});
app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest request, IGraphExportService service, CancellationToken ct) =>
{
var sw = System.Diagnostics.Stopwatch.StartNew();
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);
LogAudit(context, "/graph/export", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
if (!context.Request.Headers.ContainsKey("Authorization"))
{
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
return Results.Empty;
}
var scopes = context.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!scopes.Contains("graph:export"))
{
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:export scope", ct);
return Results.Empty;
}
if (!RateLimit(context, "/graph/export"))
{
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
LogAudit(context, "/graph/export", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
return Results.Empty;
}
var validation = ExportValidator.Validate(request);
if (validation is not null)
{
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
LogAudit(context, "/graph/export", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
return Results.Empty;
}
var job = await service.StartExportAsync(tenant!, request, ct);
var manifest = new
{
jobId = job.JobId,
status = "completed",
format = job.Format,
sha256 = job.Sha256,
size = job.SizeBytes,
downloadUrl = $"/graph/export/{job.JobId}",
completedAt = job.CompletedAt
};
LogAudit(context, "/graph/export", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
return Results.Ok(manifest);
});
app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphExportService service) =>
{
var job = service.Get(jobId);
if (job is null)
{
return Results.NotFound(new ErrorResponse { Error = "GRAPH_EXPORT_NOT_FOUND", Message = "Export job not found" });
}
context.Response.Headers.ContentLength = job.Payload.Length;
context.Response.Headers["X-Content-SHA256"] = job.Sha256;
return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}");
});
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.Run();
static async Task WriteError(HttpContext ctx, int status, string code, string message, CancellationToken ct)
@@ -54,3 +314,30 @@ static async Task WriteError(HttpContext ctx, int status, string code, string me
});
await ctx.Response.WriteAsync(payload + "\n", ct);
}
static bool RateLimit(HttpContext ctx, string route)
{
var limiter = ctx.RequestServices.GetRequiredService<IRateLimiter>();
var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown";
return limiter.Allow(tenant, route);
}
static void LogAudit(HttpContext ctx, string route, int statusCode, long durationMs)
{
var logger = ctx.RequestServices.GetRequiredService<IAuditLogger>();
var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown";
var actor = ctx.Request.Headers["Authorization"].FirstOrDefault() ?? "anonymous";
var scopes = ctx.Request.Headers["X-Stella-Scopes"]
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
.ToArray();
logger.Log(new AuditEvent(
Timestamp: DateTimeOffset.UtcNow,
Tenant: tenant,
Route: route,
Method: ctx.Request.Method,
Actor: actor,
Scopes: scopes,
StatusCode: statusCode,
DurationMs: durationMs));
}

View File

@@ -0,0 +1,40 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Graph.Api.Services;
public interface IGraphMetrics : IDisposable
{
Counter<long> BudgetDenied { get; }
Histogram<double> QueryLatencySeconds { get; }
Counter<long> OverlayCacheHit { get; }
Counter<long> OverlayCacheMiss { get; }
Histogram<double> ExportLatencySeconds { get; }
Meter Meter { get; }
}
public sealed class GraphMetrics : IGraphMetrics
{
private readonly Meter _meter;
public GraphMetrics()
{
_meter = new Meter("StellaOps.Graph.Api", "1.0.0");
BudgetDenied = _meter.CreateCounter<long>("graph_query_budget_denied_total");
QueryLatencySeconds = _meter.CreateHistogram<double>("graph_tile_latency_seconds", unit: "s");
OverlayCacheHit = _meter.CreateCounter<long>("graph_overlay_cache_hits_total");
OverlayCacheMiss = _meter.CreateCounter<long>("graph_overlay_cache_misses_total");
ExportLatencySeconds = _meter.CreateHistogram<double>("graph_export_latency_seconds", unit: "s");
}
public Counter<long> BudgetDenied { get; }
public Histogram<double> QueryLatencySeconds { get; }
public Counter<long> OverlayCacheHit { get; }
public Counter<long> OverlayCacheMiss { get; }
public Histogram<double> ExportLatencySeconds { get; }
public Meter Meter => _meter;
public void Dispose()
{
_meter.Dispose();
}
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Graph.Api.Services;
public record AuditEvent(
DateTimeOffset Timestamp,
string Tenant,
string Route,
string Method,
string Actor,
string[] Scopes,
int StatusCode,
long DurationMs);
public interface IAuditLogger
{
void Log(AuditEvent evt);
IReadOnlyList<AuditEvent> GetRecent(int max = 100);
}
public sealed class InMemoryAuditLogger : IAuditLogger
{
private readonly LinkedList<AuditEvent> _events = new();
private readonly object _lock = new();
public void Log(AuditEvent evt)
{
lock (_lock)
{
_events.AddFirst(evt);
while (_events.Count > 500)
{
_events.RemoveLast();
}
}
Console.WriteLine($"[AUDIT] {evt.Timestamp:O} tenant={evt.Tenant} route={evt.Route} status={evt.StatusCode} scopes={string.Join(' ', evt.Scopes)} duration_ms={evt.DurationMs}");
}
public IReadOnlyList<AuditEvent> GetRecent(int max = 100)
{
lock (_lock)
{
return _events.Take(max).ToList();
}
}
}

View File

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

View File

@@ -0,0 +1,11 @@
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public record GraphExportJob(string JobId, string Tenant, string Format, string ContentType, byte[] Payload, string Sha256, long SizeBytes, DateTimeOffset CompletedAt);
public interface IGraphExportService
{
Task<GraphExportJob> StartExportAsync(string tenant, GraphExportRequest request, CancellationToken ct = default);
GraphExportJob? Get(string jobId);
}

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public interface IOverlayService
{
Task<IDictionary<string, Dictionary<string, OverlayPayload>>> GetOverlaysAsync(
string tenant,
IEnumerable<string> nodeIds,
bool sampleExplain,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,166 @@
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 InMemoryGraphDiffService : IGraphDiffService
{
private readonly InMemoryGraphRepository _repository;
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public InMemoryGraphDiffService(InMemoryGraphRepository repository)
{
_repository = repository;
}
public async IAsyncEnumerable<string> DiffAsync(string tenant, GraphDiffRequest request, [EnumeratorCancellation] CancellationToken ct = default)
{
var budget = (request.Budget?.ApplyDefaults()) ?? GraphQueryBudget.Default.ApplyDefaults();
var tileBudgetLimit = Math.Clamp(budget.Tiles ?? 6000, 1, 6000);
var nodeBudgetRemaining = budget.Nodes ?? 5000;
var edgeBudgetRemaining = budget.Edges ?? 10000;
var budgetRemaining = tileBudgetLimit;
var seq = 0;
var snapA = _repository.GetSnapshot(tenant, request.SnapshotA);
var snapB = _repository.GetSnapshot(tenant, request.SnapshotB);
if (snapA is null || snapB is null)
{
var error = new ErrorResponse
{
Error = "GRAPH_SNAPSHOT_NOT_FOUND",
Message = "One or both snapshots are missing.",
Details = new { request.SnapshotA, request.SnapshotB }
};
yield return JsonSerializer.Serialize(new TileEnvelope("error", seq++, error, Cost(tileBudgetLimit, budgetRemaining)), Options);
yield break;
}
var nodesA = snapA.Value.Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var nodesB = snapB.Value.Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var edgesA = snapA.Value.Edges.ToDictionary(e => e.Id, StringComparer.Ordinal);
var edgesB = snapB.Value.Edges.ToDictionary(e => e.Id, StringComparer.Ordinal);
foreach (var added in nodesB.Values.Where(n => !nodesA.ContainsKey(n.Id)).OrderBy(n => n.Id, StringComparer.Ordinal))
{
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
yield return JsonSerializer.Serialize(new TileEnvelope("node_added", seq++, added, Cost(tileBudgetLimit, budgetRemaining)), Options);
}
foreach (var removed in nodesA.Values.Where(n => !nodesB.ContainsKey(n.Id)).OrderBy(n => n.Id, StringComparer.Ordinal))
{
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
yield return JsonSerializer.Serialize(new TileEnvelope("node_removed", seq++, removed, Cost(tileBudgetLimit, budgetRemaining)), Options);
}
foreach (var common in nodesA.Keys.Intersect(nodesB.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal))
{
var a = nodesA[common];
var b = nodesB[common];
if (!AttributesEqual(a.Attributes, b.Attributes))
{
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
var diff = new DiffTile
{
EntityType = "node",
ChangeType = "changed",
Id = common,
Before = a,
After = b
};
yield return JsonSerializer.Serialize(new TileEnvelope("node_changed", seq++, diff, Cost(tileBudgetLimit, budgetRemaining)), Options);
}
}
if (request.IncludeEdges)
{
foreach (var added in edgesB.Values.Where(e => !edgesA.ContainsKey(e.Id)).OrderBy(e => e.Id, StringComparer.Ordinal))
{
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
yield return JsonSerializer.Serialize(new TileEnvelope("edge_added", seq++, added, Cost(tileBudgetLimit, budgetRemaining)), Options);
}
foreach (var removed in edgesA.Values.Where(e => !edgesB.ContainsKey(e.Id)).OrderBy(e => e.Id, StringComparer.Ordinal))
{
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
yield return JsonSerializer.Serialize(new TileEnvelope("edge_removed", seq++, removed, Cost(tileBudgetLimit, budgetRemaining)), Options);
}
foreach (var common in edgesA.Keys.Intersect(edgesB.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal))
{
var a = edgesA[common];
var b = edgesB[common];
if (!AttributesEqual(a.Attributes, b.Attributes))
{
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
var diff = new DiffTile
{
EntityType = "edge",
ChangeType = "changed",
Id = common,
Before = a,
After = b
};
yield return JsonSerializer.Serialize(new TileEnvelope("edge_changed", seq++, diff, Cost(tileBudgetLimit, budgetRemaining)), Options);
}
}
}
if (request.IncludeStats && budgetRemaining > 0)
{
var stats = new DiffStatsTile
{
NodesAdded = nodesB.Count(n => !nodesA.ContainsKey(n.Key)),
NodesRemoved = nodesA.Count(n => !nodesB.ContainsKey(n.Key)),
NodesChanged = nodesA.Keys.Intersect(nodesB.Keys, StringComparer.Ordinal).Count(id => !AttributesEqual(nodesA[id].Attributes, nodesB[id].Attributes)),
EdgesAdded = request.IncludeEdges ? edgesB.Count(e => !edgesA.ContainsKey(e.Key)) : 0,
EdgesRemoved = request.IncludeEdges ? edgesA.Count(e => !edgesB.ContainsKey(e.Key)) : 0,
EdgesChanged = request.IncludeEdges ? edgesA.Keys.Intersect(edgesB.Keys, StringComparer.Ordinal).Count(id => !AttributesEqual(edgesA[id].Attributes, edgesB[id].Attributes)) : 0
};
yield return JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options);
}
await Task.CompletedTask;
}
private static bool Spend(ref int budgetRemaining, ref int entityBudget, int limit, int seq, out string? tile)
{
if (budgetRemaining <= 0 || entityBudget <= 0)
{
tile = JsonSerializer.Serialize(new TileEnvelope("error", seq, new ErrorResponse
{
Error = "GRAPH_BUDGET_EXCEEDED",
Message = "Diff exceeded budget."
}, Cost(limit, budgetRemaining)), Options);
return false;
}
budgetRemaining--;
entityBudget--;
tile = null;
return true;
}
private static bool AttributesEqual(IDictionary<string, object?> a, IDictionary<string, object?> b)
{
if (a.Count != b.Count) return false;
foreach (var kvp in a)
{
if (!b.TryGetValue(kvp.Key, out var other)) return false;
if (!(kvp.Value?.ToString() ?? string.Empty).Equals(other?.ToString() ?? string.Empty, StringComparison.Ordinal))
{
return false;
}
}
return true;
}
private static CostBudget Cost(int limit, int remaining) =>
new(limit, remaining - 1, limit - (remaining - 1));
}

View File

@@ -0,0 +1,151 @@
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphExportService : IGraphExportService
{
private readonly InMemoryGraphRepository _repository;
private readonly IGraphMetrics _metrics;
private readonly Dictionary<string, GraphExportJob> _jobs = new(StringComparer.Ordinal);
public InMemoryGraphExportService(InMemoryGraphRepository repository, IGraphMetrics metrics)
{
_repository = repository;
_metrics = metrics;
}
public async Task<GraphExportJob> StartExportAsync(string tenant, GraphExportRequest request, CancellationToken ct = default)
{
// For now exports complete synchronously; job model kept for future async workers.
var sw = System.Diagnostics.Stopwatch.StartNew();
var (nodes, edges) = ResolveGraph(tenant, request);
var (payload, contentType) = request.Format.ToLowerInvariant() switch
{
"ndjson" => (ExportNdjson(nodes, edges, request.IncludeEdges), "application/x-ndjson"),
"csv" => (ExportCsv(nodes, edges, request.IncludeEdges), "text/csv"),
"graphml" => (ExportGraphml(nodes, edges, request.IncludeEdges), "application/graphml+xml"),
"png" => (ExportPlaceholder("png"), "image/png"),
"svg" => (ExportPlaceholder("svg"), "image/svg+xml"),
_ => (ExportNdjson(nodes, edges, request.IncludeEdges), "application/x-ndjson")
};
var sha = ComputeSha256(payload);
var jobId = $"job-{Guid.NewGuid():N}";
var job = new GraphExportJob(jobId, tenant, request.Format, contentType, payload, sha, payload.Length, DateTimeOffset.UtcNow);
_jobs[jobId] = job;
sw.Stop();
_metrics.ExportLatencySeconds.Record(sw.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("format", request.Format));
await Task.CompletedTask;
return job;
}
public GraphExportJob? Get(string jobId)
{
_jobs.TryGetValue(jobId, out var job);
return job;
}
private (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) ResolveGraph(string tenant, GraphExportRequest request)
{
if (!string.IsNullOrWhiteSpace(request.SnapshotId))
{
var snap = _repository.GetSnapshot(tenant, request.SnapshotId!);
if (snap is not null) return snap.Value;
}
var graphReq = new GraphQueryRequest
{
Kinds = request.Kinds ?? Array.Empty<string>(),
Query = request.Query,
Filters = request.Filters,
IncludeEdges = request.IncludeEdges,
Limit = 5000 // bounded export for in-memory demo
};
var (nodes, edges) = _repository.QueryGraph(tenant, graphReq);
return (nodes, edges);
}
private static byte[] ExportNdjson(IReadOnlyList<NodeTile> nodes, IReadOnlyList<EdgeTile> edges, bool includeEdges)
{
var lines = new List<string>(nodes.Count + (includeEdges ? edges.Count : 0));
foreach (var n in nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
{
lines.Add(System.Text.Json.JsonSerializer.Serialize(new { type = "node", data = n }, GraphQueryJson.Options));
}
if (includeEdges)
{
foreach (var e in edges.OrderBy(e => e.Id, StringComparer.Ordinal))
{
lines.Add(System.Text.Json.JsonSerializer.Serialize(new { type = "edge", data = e }, GraphQueryJson.Options));
}
}
return Encoding.UTF8.GetBytes(string.Join("\n", lines));
}
private static byte[] ExportCsv(IReadOnlyList<NodeTile> nodes, IReadOnlyList<EdgeTile> edges, bool includeEdges)
{
var sb = new StringBuilder();
sb.AppendLine("type,id,kind,tenant,source,target");
foreach (var n in nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
{
sb.AppendLine($"node,\"{n.Id}\",{n.Kind},{n.Tenant},,");
}
if (includeEdges)
{
foreach (var e in edges.OrderBy(e => e.Id, StringComparer.Ordinal))
{
sb.AppendLine($"edge,\"{e.Id}\",{e.Kind},{e.Tenant},\"{e.Source}\",\"{e.Target}\"");
}
}
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static byte[] ExportGraphml(IReadOnlyList<NodeTile> nodes, IReadOnlyList<EdgeTile> edges, bool includeEdges)
{
XNamespace ns = "http://graphml.graphdrawing.org/xmlns";
var g = new XElement(ns + "graph",
new XAttribute("id", "g0"),
new XAttribute("edgedefault", "directed"));
foreach (var n in nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
{
g.Add(new XElement(ns + "node", new XAttribute("id", n.Id)));
}
if (includeEdges)
{
foreach (var e in edges.OrderBy(e => e.Id, StringComparer.Ordinal))
{
g.Add(new XElement(ns + "edge",
new XAttribute("id", e.Id),
new XAttribute("source", e.Source),
new XAttribute("target", e.Target)));
}
}
var doc = new XDocument(new XElement(ns + "graphml", g));
using var ms = new MemoryStream();
doc.Save(ms);
return ms.ToArray();
}
private static byte[] ExportPlaceholder(string format) =>
Encoding.UTF8.GetBytes($"placeholder-{format}-export");
private static string ComputeSha256(byte[] payload)
{
using var sha = SHA256.Create();
return Convert.ToHexString(sha.ComputeHash(payload)).ToLowerInvariant();
}
}
internal static class GraphQueryJson
{
public static readonly System.Text.Json.JsonSerializerOptions Options = new(System.Text.Json.JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
}

View File

@@ -0,0 +1,246 @@
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 InMemoryGraphPathService : IGraphPathService
{
private readonly InMemoryGraphRepository _repository;
private readonly IOverlayService _overlayService;
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public InMemoryGraphPathService(InMemoryGraphRepository repository, IOverlayService overlayService)
{
_repository = repository;
_overlayService = overlayService;
}
public async IAsyncEnumerable<string> FindPathsAsync(string tenant, GraphPathRequest request, [EnumeratorCancellation] CancellationToken ct = default)
{
var maxDepth = Math.Clamp(request.MaxDepth ?? 3, 1, 6);
var budget = (request.Budget?.ApplyDefaults()) ?? GraphQueryBudget.Default.ApplyDefaults();
var tileBudgetLimit = Math.Clamp(budget.Tiles ?? 6000, 1, 6000);
var nodeBudgetRemaining = budget.Nodes ?? 5000;
var edgeBudgetRemaining = budget.Edges ?? 10000;
var budgetRemaining = tileBudgetLimit;
var seq = 0;
var result = FindShortestPath(tenant, request, maxDepth);
if (result is null)
{
var error = new ErrorResponse
{
Error = "GRAPH_PATH_NOT_FOUND",
Message = "No path found within depth budget.",
Details = new { sources = request.Sources, targets = request.Targets, maxDepth }
};
yield return JsonSerializer.Serialize(new TileEnvelope("error", seq++, error, Cost(tileBudgetLimit, budgetRemaining)), Options);
yield break;
}
var path = result.Value;
Dictionary<string, Dictionary<string, OverlayPayload>>? overlays = null;
if (request.IncludeOverlays && path.Nodes.Count > 0)
{
overlays = (await _overlayService.GetOverlaysAsync(tenant, path.Nodes.Select(n => n.Id), sampleExplain: true, ct))
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
}
foreach (var node in path.Nodes)
{
if (budgetRemaining <= 0 || nodeBudgetRemaining <= 0)
{
yield return BudgetExceeded(tileBudgetLimit, budgetRemaining, seq++);
yield break;
}
var nodeWithOverlay = node;
if (request.IncludeOverlays && overlays is not null && overlays.TryGetValue(node.Id, out var nodeOverlays))
{
nodeWithOverlay = node with { Overlays = nodeOverlays };
}
yield return JsonSerializer.Serialize(new TileEnvelope("node", seq++, nodeWithOverlay, Cost(tileBudgetLimit, budgetRemaining)), Options);
budgetRemaining--;
nodeBudgetRemaining--;
}
foreach (var edge in path.Edges)
{
if (budgetRemaining <= 0 || edgeBudgetRemaining <= 0)
{
yield return BudgetExceeded(tileBudgetLimit, budgetRemaining, seq++);
yield break;
}
yield return JsonSerializer.Serialize(new TileEnvelope("edge", seq++, edge, Cost(tileBudgetLimit, budgetRemaining)), Options);
budgetRemaining--;
edgeBudgetRemaining--;
}
if (budgetRemaining > 0)
{
var stats = new StatsTile
{
Nodes = path.Nodes.Count,
Edges = path.Edges.Count
};
yield return JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options);
}
await Task.CompletedTask;
}
private static string BudgetExceeded(int limit, int remaining, int seq) =>
JsonSerializer.Serialize(
new TileEnvelope("error", seq, new ErrorResponse
{
Error = "GRAPH_BUDGET_EXCEEDED",
Message = "Path computation exceeded tile budget."
}, Cost(limit, remaining)),
Options);
private (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? FindShortestPath(string tenant, GraphPathRequest request, int maxDepth)
{
var nodes = _repository
.Query(tenant, new GraphSearchRequest
{
Kinds = request.Kinds is { Length: > 0 } ? request.Kinds : _repositoryKindsForTenant(tenant),
Filters = request.Filters
})
.ToDictionary(n => n.Id, StringComparer.Ordinal);
// ensure sources/targets are present even if filters/kinds excluded
foreach (var id in request.Sources.Concat(request.Targets))
{
if (!nodes.ContainsKey(id))
{
var match = _repository.Query(tenant, new GraphSearchRequest
{
Kinds = Array.Empty<string>(),
Query = id
}).FirstOrDefault(n => n.Id.Equals(id, StringComparison.Ordinal));
if (match is not null)
{
nodes[id] = match;
}
}
}
var sources = request.Sources.Where(nodes.ContainsKey).Distinct(StringComparer.Ordinal).ToArray();
var targets = request.Targets.ToHashSet(StringComparer.Ordinal);
if (sources.Length == 0 || targets.Count == 0)
{
return null;
}
var edges = _repositoryEdges(tenant)
.Where(e => nodes.ContainsKey(e.Source) && nodes.ContainsKey(e.Target))
.OrderBy(e => e.Id, StringComparer.Ordinal)
.ToList();
var adjacency = new Dictionary<string, List<EdgeTile>>(StringComparer.Ordinal);
foreach (var edge in edges)
{
if (!adjacency.TryGetValue(edge.Source, out var list))
{
list = new List<EdgeTile>();
adjacency[edge.Source] = list;
}
list.Add(edge);
}
var queue = new Queue<(string NodeId, List<EdgeTile> PathEdges, string Origin)>();
var visited = new HashSet<string>(StringComparer.Ordinal);
foreach (var source in sources.OrderBy(s => s, StringComparer.Ordinal))
{
queue.Enqueue((source, new List<EdgeTile>(), source));
visited.Add(source);
}
while (queue.Count > 0)
{
var (current, pathEdges, origin) = queue.Dequeue();
if (targets.Contains(current))
{
var pathNodes = BuildNodeListFromEdges(nodes, origin, current, pathEdges);
return (pathNodes, pathEdges);
}
if (pathEdges.Count >= maxDepth)
{
continue;
}
if (!adjacency.TryGetValue(current, out var outgoing))
{
continue;
}
foreach (var edge in outgoing)
{
if (visited.Contains(edge.Target))
{
continue;
}
var nextEdges = new List<EdgeTile>(pathEdges.Count + 1);
nextEdges.AddRange(pathEdges);
nextEdges.Add(edge);
queue.Enqueue((edge.Target, nextEdges, origin));
visited.Add(edge.Target);
}
}
return null;
}
private static IReadOnlyList<NodeTile> BuildNodeListFromEdges(IDictionary<string, NodeTile> nodes, string currentSource, string target, List<EdgeTile> edges)
{
var list = new List<NodeTile>();
var firstId = edges.Count > 0 ? edges[0].Source : currentSource;
if (nodes.TryGetValue(firstId, out var first))
{
list.Add(first);
}
foreach (var edge in edges)
{
if (nodes.TryGetValue(edge.Target, out var node))
{
list.Add(node);
}
}
return list;
}
private IEnumerable<EdgeTile> _repositoryEdges(string tenant) =>
_repository
.QueryGraph(tenant, new GraphQueryRequest
{
Kinds = Array.Empty<string>(),
IncludeEdges = true,
IncludeStats = false,
Query = null,
Filters = null
}).Edges;
private string[] _repositoryKindsForTenant(string tenant) =>
_repository.Query(tenant, new GraphSearchRequest { Kinds = Array.Empty<string>(), Query = null, Filters = null })
.Select(n => n.Kind)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
private static CostBudget Cost(int limit, int remaining) =>
new(limit, remaining - 1, limit - (remaining - 1));
}

View File

@@ -0,0 +1,209 @@
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphQueryService : IGraphQueryService
{
private readonly InMemoryGraphRepository _repository;
private readonly IMemoryCache _cache;
private readonly IOverlayService _overlayService;
private readonly IGraphMetrics _metrics;
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public InMemoryGraphQueryService(InMemoryGraphRepository repository, IMemoryCache cache, IOverlayService overlayService, IGraphMetrics metrics)
{
_repository = repository;
_cache = cache;
_overlayService = overlayService;
_metrics = metrics;
}
public async IAsyncEnumerable<string> QueryAsync(string tenant, GraphQueryRequest request, [EnumeratorCancellation] CancellationToken ct = default)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var limit = Math.Clamp(request.Limit ?? 100, 1, 500);
var budget = (request.Budget?.ApplyDefaults()) ?? GraphQueryBudget.Default.ApplyDefaults();
var tileBudgetLimit = Math.Clamp(budget.Tiles ?? 6000, 1, 6000);
var nodeBudgetLimit = budget.Nodes ?? 5000;
var edgeBudgetLimit = budget.Edges ?? 10000;
var cacheKey = BuildCacheKey(tenant, request, limit, tileBudgetLimit, nodeBudgetLimit, edgeBudgetLimit);
if (_cache.TryGetValue(cacheKey, out string[]? cached))
{
foreach (var line in cached)
{
yield return line;
}
yield break;
}
var cursorOffset = CursorCodec.Decode(request.Cursor);
var (nodes, edges) = _repository.QueryGraph(tenant, request);
if (request.IncludeEdges && edges.Count > edgeBudgetLimit)
{
_metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "edges"));
var error = new ErrorResponse
{
Error = "GRAPH_BUDGET_EXCEEDED",
Message = $"Query exceeded edge budget (edges>{edgeBudgetLimit}).",
Details = new { nodes = nodes.Count, edges = edges.Count, budget }
};
var errorLine = JsonSerializer.Serialize(new TileEnvelope("error", 0, error), Options);
yield return errorLine;
_cache.Set(cacheKey, new[] { errorLine }, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
});
yield break;
}
var scored = nodes
.Select(n => (Node: n, Score: Score(n, request)))
.OrderByDescending(t => t.Score)
.ThenBy(t => t.Node.Id, StringComparer.Ordinal)
.ToArray();
var page = scored.Skip(cursorOffset).Take(limit).ToArray();
var remainingNodes = Math.Max(0, scored.Length - cursorOffset - page.Length);
var hasMore = remainingNodes > 0;
var seq = 0;
var lines = new List<string>();
var budgetRemaining = tileBudgetLimit;
Dictionary<string, Dictionary<string, OverlayPayload>>? overlays = null;
if (request.IncludeOverlays && page.Length > 0)
{
overlays = (await _overlayService.GetOverlaysAsync(tenant, page.Select(p => p.Node.Id), sampleExplain: true, ct))
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
}
foreach (var item in page)
{
if (hasMore && budgetRemaining == 1)
{
break; // reserve one tile for cursor
}
if (budgetRemaining <= 0 || nodeBudgetLimit <= 0)
{
break;
}
var nodeToEmit = item.Node;
if (request.IncludeOverlays && overlays is not null && overlays.TryGetValue(item.Node.Id, out var nodeOverlays))
{
nodeToEmit = item.Node with { Overlays = nodeOverlays };
}
lines.Add(JsonSerializer.Serialize(new TileEnvelope("node", seq++, nodeToEmit, Cost(tileBudgetLimit, budgetRemaining)), Options));
budgetRemaining--;
nodeBudgetLimit--;
}
if (request.IncludeEdges)
{
foreach (var edge in edges)
{
// Reserve cursor only if we actually have more nodes beyond current page
if (hasMore && budgetRemaining == 1) break;
if (budgetRemaining <= 0 || edgeBudgetLimit <= 0) break;
lines.Add(JsonSerializer.Serialize(new TileEnvelope("edge", seq++, edge, Cost(tileBudgetLimit, budgetRemaining)), Options));
budgetRemaining--;
edgeBudgetLimit--;
}
}
if (request.IncludeStats && budgetRemaining > (hasMore ? 1 : 0))
{
var stats = new StatsTile
{
Nodes = nodes.Count,
Edges = edges.Count
};
lines.Add(JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options));
budgetRemaining--;
}
if (hasMore && budgetRemaining > 0)
{
var nextCursor = CursorCodec.Encode(cursorOffset + page.Length);
lines.Add(JsonSerializer.Serialize(new TileEnvelope("cursor", seq++, new CursorTile(nextCursor, $"https://gateway.local/api/graph/query?cursor={nextCursor}"), Cost(tileBudgetLimit, budgetRemaining)), Options));
}
_cache.Set(cacheKey, lines.ToArray(), new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
});
stopwatch.Stop();
_metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("route", "/graph/query"));
foreach (var line in lines)
{
yield return line;
}
}
private static string BuildCacheKey(string tenant, GraphQueryRequest request, int limit, int tileBudget, int nodeBudget, int edgeBudget)
{
var filters = request.Filters is null
? string.Empty
: string.Join(";", request.Filters.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)
.Select(kvp => $"{kvp.Key}={kvp.Value}"));
var kinds = request.Kinds is null ? string.Empty : string.Join(",", request.Kinds.OrderBy(k => k, StringComparer.OrdinalIgnoreCase));
var budget = request.Budget is null ? "budget:none" : $"tiles:{request.Budget.Tiles};nodes:{request.Budget.Nodes};edges:{request.Budget.Edges}";
return $"{tenant}|{kinds}|{request.Query}|{limit}|{request.Cursor}|{filters}|edges:{request.IncludeEdges}|stats:{request.IncludeStats}|{budget}|tb:{tileBudget}|nb:{nodeBudget}|eb:{edgeBudget}";
}
private static int Score(NodeTile node, GraphQueryRequest request)
{
var score = 0;
if (!string.IsNullOrWhiteSpace(request.Query))
{
var query = request.Query!;
score += MatchScore(node.Id, query, exact: 100, prefix: 80, contains: 50);
foreach (var value in node.Attributes.Values.OfType<string>())
{
score += MatchScore(value, query, exact: 70, prefix: 40, contains: 25);
}
}
if (request.Filters is not null)
{
foreach (var filter in request.Filters)
{
if (node.Attributes.TryGetValue(filter.Key, out var value) && value is not null && filter.Value is not null)
{
if (value.ToString()!.Equals(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase))
{
score += 5;
}
}
}
}
return score;
}
private static int MatchScore(string candidate, string query, int exact, int prefix, int contains)
{
if (candidate.Equals(query, StringComparison.OrdinalIgnoreCase)) return exact;
if (candidate.StartsWith(query, StringComparison.OrdinalIgnoreCase)) return prefix;
return candidate.Contains(query, StringComparison.OrdinalIgnoreCase) ? contains : 0;
}
private static CostBudget Cost(int limit, int remainingBudget) =>
new(limit, remainingBudget - 1, limit - (remainingBudget - 1));
}

View File

@@ -5,10 +5,12 @@ namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphRepository
{
private readonly List<NodeTile> _nodes;
private readonly List<EdgeTile> _edges;
private readonly Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> _snapshots;
public InMemoryGraphRepository()
public InMemoryGraphRepository(IEnumerable<NodeTile>? seed = null, IEnumerable<EdgeTile>? edges = null)
{
_nodes = new List<NodeTile>
_nodes = seed?.ToList() ?? 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" } },
@@ -17,16 +19,26 @@ public sealed class InMemoryGraphRepository
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" } },
};
_edges = edges?.ToList() ?? new List<EdgeTile>
{
new() { Id = "ge:acme:artifact->component", Kind = "builds", Tenant = "acme", Source = "gn:acme:artifact:sha256:abc", Target = "gn:acme:component:example", Attributes = new() { ["reason"] = "sbom" } },
new() { Id = "ge:acme:component->component", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:example", Target = "gn:acme:component:widget", Attributes = new() { ["scope"] = "runtime" } },
new() { Id = "ge:bravo:artifact->component", Kind = "builds", Tenant = "bravo", Source = "gn:bravo:artifact:sha256:def", Target = "gn:bravo:component:widget", Attributes = new() { ["reason"] = "sbom" } },
};
// Drop edges whose endpoints aren't present in the current node set to avoid invalid graph seeds in tests.
var nodeIds = _nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
_edges = _edges.Where(e => nodeIds.Contains(e.Source) && nodeIds.Contains(e.Target)).ToList();
_snapshots = SeedSnapshots();
}
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));
.Where(n => request.Kinds is null || request.Kinds.Length == 0 || request.Kinds.Contains(n.Kind, StringComparer.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(request.Query))
{
@@ -38,13 +50,82 @@ public sealed class InMemoryGraphRepository
queryable = queryable.Where(n => FiltersMatch(n, request.Filters!));
}
queryable = request.Ordering switch
return queryable;
}
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) QueryGraph(string tenant, GraphQueryRequest request)
{
var nodes = Query(tenant, new GraphSearchRequest
{
"id" => queryable.OrderBy(n => n.Id, StringComparer.Ordinal),
_ => queryable.OrderBy(n => n.Id.Length).ThenBy(n => n.Id, StringComparer.Ordinal)
Kinds = request.Kinds,
Query = request.Query,
Filters = request.Filters,
Limit = request.Limit,
Cursor = request.Cursor
}).ToList();
var nodeIds = nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
var edges = request.IncludeEdges
? _edges.Where(e => e.Tenant.Equals(tenant, StringComparison.Ordinal) && nodeIds.Contains(e.Source) && nodeIds.Contains(e.Target))
.OrderBy(e => e.Id, StringComparer.Ordinal)
.ToList()
: new List<EdgeTile>();
return (nodes, edges);
}
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? GetSnapshot(string tenant, string snapshotId)
{
if (_snapshots.TryGetValue($"{tenant}:{snapshotId}", out var snap))
{
return (snap.Nodes, snap.Edges);
}
return null;
}
private Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> SeedSnapshots()
{
var dict = new Dictionary<string, (List<NodeTile>, List<EdgeTile>)>(StringComparer.Ordinal);
dict["acme:snapA"] = (new List<NodeTile>(_nodes), new List<EdgeTile>(_edges));
var updatedNodes = new List<NodeTile>(_nodes.Select(n => n with
{
Attributes = new Dictionary<string, object?>(n.Attributes)
}));
var widget = updatedNodes.FirstOrDefault(n => n.Id == "gn:acme:component:widget");
if (widget is null)
{
// Custom seeds may not include the default widget node; skip optional snapshot wiring in that case.
return dict;
}
widget.Attributes["purl"] = "pkg:npm/widget@2.1.0";
updatedNodes.Add(new NodeTile
{
Id = "gn:acme:component:newlib",
Kind = "component",
Tenant = "acme",
Attributes = new() { ["purl"] = "pkg:npm/newlib@1.0.0", ["ecosystem"] = "npm" }
});
var updatedEdges = new List<EdgeTile>(_edges)
{
new()
{
Id = "ge:acme:component->component:new",
Kind = "depends_on",
Tenant = "acme",
Source = widget.Id,
Target = "gn:acme:component:newlib",
Attributes = new() { ["scope"] = "runtime" }
}
};
return queryable.Skip(cursorOffset).Take(limit + 1).ToArray();
dict["acme:snapB"] = (updatedNodes, updatedEdges);
return dict;
}
private static bool MatchesQuery(NodeTile node, string query)

View File

@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
@@ -8,39 +9,128 @@ namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphSearchService : IGraphSearchService
{
private readonly InMemoryGraphRepository _repository;
private readonly IMemoryCache _cache;
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public InMemoryGraphSearchService(InMemoryGraphRepository repository)
public InMemoryGraphSearchService(InMemoryGraphRepository repository, IMemoryCache cache)
{
_repository = repository;
_cache = cache;
}
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 cacheKey = BuildCacheKey(tenant, request, limit);
if (_cache.TryGetValue(cacheKey, out string[]? cachedLines))
{
foreach (var cached in cachedLines)
{
yield return cached;
}
yield break;
}
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 cursorOffset = CursorCodec.Decode(request.Cursor);
var results = _repository.Query(tenant, request).ToArray();
var total = results.Length;
var scored = results
.Select(n => (Node: n, Score: Score(n, request)))
.OrderByDescending(t => t.Score)
.ThenBy(t => t.Node.Id, StringComparer.Ordinal)
.ToArray();
var ordered = request.Ordering switch
{
"id" => scored.OrderBy(t => t.Node.Id, StringComparer.Ordinal).ToArray(),
_ => scored
};
var page = ordered.Skip(cursorOffset).Take(limit).ToArray();
var remaining = Math.Max(0, total - cursorOffset - page.Length);
var hasMore = total > cursorOffset + page.Length || total > limit;
if (!hasMore && remaining <= 0 && total > limit)
{
hasMore = true;
remaining = Math.Max(1, total - limit);
}
var cost = new CostBudget(limit, remaining, page.Length);
var seq = 0;
foreach (var item in items)
var lines = new List<string>();
foreach (var item in page)
{
var envelope = new TileEnvelope("node", seq++, item, cost);
yield return JsonSerializer.Serialize(envelope, Options);
var envelope = new TileEnvelope("node", seq++, item.Node, cost);
lines.Add(JsonSerializer.Serialize(envelope, Options));
}
if (remaining > 0)
if (hasMore)
{
var nextCursor = CursorCodec.Encode(CursorCodec.Decode(request.Cursor) + items.Length);
var nextCursor = CursorCodec.Encode(cursorOffset + page.Length);
var cursorTile = new TileEnvelope("cursor", seq++, new CursorTile(nextCursor, $"https://gateway.local/api/graph/search?cursor={nextCursor}"));
yield return JsonSerializer.Serialize(cursorTile, Options);
lines.Add(JsonSerializer.Serialize(cursorTile, Options));
}
await Task.CompletedTask;
_cache.Set(cacheKey, lines.ToArray(), new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
});
foreach (var line in lines)
{
yield return line;
}
}
private static string BuildCacheKey(string tenant, GraphSearchRequest request, int limit)
{
var filters = request.Filters is null
? string.Empty
: string.Join(";", request.Filters.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)
.Select(kvp => $"{kvp.Key}={kvp.Value}"));
var kinds = request.Kinds is null ? string.Empty : string.Join(",", request.Kinds.OrderBy(k => k, StringComparer.OrdinalIgnoreCase));
return $"{tenant}|{kinds}|{request.Query}|{limit}|{request.Ordering}|{request.Cursor}|{filters}";
}
private static int Score(NodeTile node, GraphSearchRequest request)
{
var score = 0;
if (!string.IsNullOrWhiteSpace(request.Query))
{
var query = request.Query!;
score += MatchScore(node.Id, query, exact: 100, prefix: 80, contains: 50);
foreach (var value in node.Attributes.Values.OfType<string>())
{
score += MatchScore(value, query, exact: 70, prefix: 40, contains: 25);
}
}
if (request.Filters is not null)
{
foreach (var filter in request.Filters)
{
if (node.Attributes.TryGetValue(filter.Key, out var value) && value is not null && filter.Value is not null)
{
if (value.ToString()!.Equals(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase))
{
score += 5;
}
}
}
}
return score;
}
private static int MatchScore(string candidate, string query, int exact, int prefix, int contains)
{
if (candidate.Equals(query, StringComparison.OrdinalIgnoreCase)) return exact;
if (candidate.StartsWith(query, StringComparison.OrdinalIgnoreCase)) return prefix;
return candidate.Contains(query, StringComparison.OrdinalIgnoreCase) ? contains : 0;
}
}

View File

@@ -0,0 +1,115 @@
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryOverlayService : IOverlayService
{
private readonly IMemoryCache _cache;
private static readonly DateTimeOffset FixedTimestamp = new(2025, 11, 23, 0, 0, 0, TimeSpan.Zero);
private readonly IGraphMetrics _metrics;
public InMemoryOverlayService(IMemoryCache cache, IGraphMetrics metrics)
{
_cache = cache;
_metrics = metrics;
}
public Task<IDictionary<string, Dictionary<string, OverlayPayload>>> GetOverlaysAsync(string tenant, IEnumerable<string> nodeIds, bool sampleExplain, CancellationToken ct = default)
{
var result = new Dictionary<string, Dictionary<string, OverlayPayload>>(StringComparer.Ordinal);
var explainEmitted = false;
foreach (var nodeId in nodeIds)
{
var cacheKey = $"overlay:{tenant}:{nodeId}";
if (!_cache.TryGetValue(cacheKey, out Dictionary<string, OverlayPayload>? cachedBase))
{
_metrics.OverlayCacheMiss.Add(1);
cachedBase = new Dictionary<string, OverlayPayload>(StringComparer.Ordinal)
{
["policy"] = BuildPolicyOverlay(tenant, nodeId, includeExplain: false),
["vex"] = BuildVexOverlay(tenant, nodeId)
};
_cache.Set(cacheKey, cachedBase, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
}
else
{
_metrics.OverlayCacheHit.Add(1);
}
// Always return a fresh copy so we can inject a single explain trace without polluting cache.
var overlays = new Dictionary<string, OverlayPayload>(cachedBase, StringComparer.Ordinal);
if (sampleExplain && !explainEmitted)
{
overlays["policy"] = BuildPolicyOverlay(tenant, nodeId, includeExplain: true);
explainEmitted = true;
}
result[nodeId] = overlays;
}
return Task.FromResult<IDictionary<string, Dictionary<string, OverlayPayload>>>(result);
}
private static OverlayPayload BuildPolicyOverlay(string tenant, string nodeId, bool includeExplain)
{
var overlayId = ComputeOverlayId(tenant, nodeId, "policy");
return new OverlayPayload(
Kind: "policy",
Version: "policy.overlay.v1",
Data: new
{
overlayId,
subject = nodeId,
decision = "warn",
rationale = new[] { "policy-default", "missing VEX waiver" },
inputs = new
{
sbomDigest = "sha256:demo-sbom",
policyVersion = "2025.11.23",
advisoriesDigest = "sha256:demo-advisories"
},
policyVersion = "2025.11.23",
createdAt = FixedTimestamp,
explainTrace = includeExplain
? new[]
{
"matched rule POLICY-ENGINE-30-001",
$"node {nodeId} lacks VEX waiver"
}
: null
});
}
private static OverlayPayload BuildVexOverlay(string tenant, string nodeId)
{
var overlayId = ComputeOverlayId(tenant, nodeId, "vex");
return new OverlayPayload(
Kind: "vex",
Version: "openvex.v1",
Data: new
{
overlayId,
subject = nodeId,
status = "not_affected",
justification = "component_not_present",
issued = FixedTimestamp,
impacts = Array.Empty<string>()
});
}
private static string ComputeOverlayId(string tenant, string nodeId, string overlayKind)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes($"{tenant}|{nodeId}|{overlayKind}");
var hash = sha.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,59 @@
namespace StellaOps.Graph.Api.Services;
/// <summary>
/// Simple fixed-window rate limiter keyed by tenant + route. Designed for in-memory demo usage.
/// </summary>
public interface IRateLimiter
{
bool Allow(string tenant, string route);
}
internal interface IClock
{
DateTimeOffset UtcNow { get; }
}
internal sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
public sealed class RateLimiterService : IRateLimiter
{
private readonly TimeSpan _window;
private readonly int _limit;
private readonly IClock _clock;
private readonly Dictionary<string, (DateTimeOffset WindowStart, int Count)> _state = new(StringComparer.Ordinal);
private readonly object _lock = new();
public RateLimiterService(int limitPerWindow = 120, TimeSpan? window = null, IClock? clock = null)
{
_limit = limitPerWindow;
_window = window ?? TimeSpan.FromMinutes(1);
_clock = clock ?? new SystemClock();
}
public bool Allow(string tenant, string route)
{
var key = $"{tenant}:{route}";
var now = _clock.UtcNow;
lock (_lock)
{
if (_state.TryGetValue(key, out var entry))
{
if (now - entry.WindowStart < _window)
{
if (entry.Count >= _limit)
{
return false;
}
_state[key] = (entry.WindowStart, entry.Count + 1);
return true;
}
}
_state[key] = (now, 1);
return true;
}
}
}

View File

@@ -5,5 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1591</NoWarn>
<!-- Speed up local test builds by skipping static web assets discovery -->
<DisableStaticWebAssets>true</DisableStaticWebAssets>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,30 @@
using System.Linq;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class AuditLoggerTests
{
[Fact]
public void LogsAndCapsSize()
{
var logger = new InMemoryAuditLogger();
for (var i = 0; i < 510; i++)
{
logger.Log(new AuditEvent(
Timestamp: DateTimeOffset.UnixEpoch.AddMinutes(i),
Tenant: "t",
Route: "/r",
Method: "POST",
Actor: "auth",
Scopes: new[] { "graph:query" },
StatusCode: 200,
DurationMs: 5));
}
var recent = logger.GetRecent();
Assert.True(recent.Count <= 100);
Assert.Equal(509, recent.First().Timestamp.Minute);
}
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class DiffServiceTests
{
[Fact]
public async Task DiffAsync_EmitsAddedRemovedChangedAndStats()
{
var repo = new InMemoryGraphRepository();
var service = new InMemoryGraphDiffService(repo);
var request = new GraphDiffRequest
{
SnapshotA = "snapA",
SnapshotB = "snapB",
IncludeEdges = true,
IncludeStats = true
};
var lines = new List<string>();
await foreach (var line in service.DiffAsync("acme", request))
{
lines.Add(line);
}
Assert.Contains(lines, l => l.Contains("\"type\":\"node_added\"") && l.Contains("newlib"));
Assert.Contains(lines, l => l.Contains("\"type\":\"node_changed\"") && l.Contains("widget"));
Assert.Contains(lines, l => l.Contains("\"type\":\"edge_added\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"stats\""));
}
[Fact]
public async Task DiffAsync_WhenSnapshotMissing_ReturnsError()
{
var repo = new InMemoryGraphRepository();
var service = new InMemoryGraphDiffService(repo);
var request = new GraphDiffRequest
{
SnapshotA = "snapA",
SnapshotB = "missing"
};
var lines = new List<string>();
await foreach (var line in service.DiffAsync("acme", request))
{
lines.Add(line);
}
Assert.Single(lines);
Assert.Contains("GRAPH_SNAPSHOT_NOT_FOUND", lines[0]);
}
}

View File

@@ -0,0 +1,58 @@
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class ExportServiceTests
{
[Fact]
public async Task Export_ReturnsManifestAndDownloadablePayload()
{
var repo = new InMemoryGraphRepository();
var metrics = new GraphMetrics();
var export = new InMemoryGraphExportService(repo, metrics);
var req = new GraphExportRequest { Format = "ndjson", IncludeEdges = true };
var job = await export.StartExportAsync("acme", req);
Assert.NotNull(job);
Assert.Equal("ndjson", job.Format, ignoreCase: true);
Assert.True(job.Payload.Length > 0);
Assert.False(string.IsNullOrWhiteSpace(job.Sha256));
var fetched = export.Get(job.JobId);
Assert.NotNull(fetched);
Assert.Equal(job.Sha256, fetched!.Sha256);
}
[Fact]
public async Task Export_IncludesEdgesWhenRequested()
{
var repo = new InMemoryGraphRepository();
var metrics = new GraphMetrics();
var export = new InMemoryGraphExportService(repo, metrics);
var req = new GraphExportRequest { Format = "ndjson", IncludeEdges = true };
var job = await export.StartExportAsync("acme", req);
var text = System.Text.Encoding.UTF8.GetString(job.Payload);
Assert.Contains("\"type\":\"edge\"", text);
}
[Fact]
public async Task Export_RespectsSnapshotSelection()
{
var repo = new InMemoryGraphRepository();
var metrics = new GraphMetrics();
var export = new InMemoryGraphExportService(repo, metrics);
var req = new GraphExportRequest { Format = "ndjson", IncludeEdges = false, SnapshotId = "snapB" };
var job = await export.StartExportAsync("acme", req);
var lines = System.Text.Encoding.UTF8.GetString(job.Payload)
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Contains(lines, l => l.Contains("newlib"));
}
}

View File

@@ -0,0 +1,114 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class LoadTests
{
[Fact]
public async Task DeterministicOrdering_WithSyntheticGraph_RemainsStable()
{
var builder = new SyntheticGraphBuilder(seed: 42, nodeCount: 1000, edgeCount: 2000);
var repo = builder.BuildRepository();
var cache = new MemoryCache(new MemoryCacheOptions());
var metrics = new GraphMetrics();
var overlays = new InMemoryOverlayService(cache, metrics);
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
Query = "pkg:",
IncludeEdges = true,
Limit = 200
};
var linesRun1 = await CollectLines(service, request);
var linesRun2 = await CollectLines(service, request);
Assert.Equal(linesRun1.Count, linesRun2.Count);
Assert.Equal(linesRun1, linesRun2); // strict deterministic ordering
}
[Fact]
public void QueryValidator_FuzzesInvalidInputs()
{
var rand = new Random(123);
for (var i = 0; i < 50; i++)
{
var req = new GraphQueryRequest
{
Kinds = Array.Empty<string>(),
Limit = rand.Next(-10, 0),
Budget = new GraphQueryBudget { Tiles = rand.Next(-50, 0), Nodes = rand.Next(-5, 0), Edges = rand.Next(-5, 0) }
};
var error = QueryValidator.Validate(req);
Assert.NotNull(error);
}
}
private static async Task<List<string>> CollectLines(InMemoryGraphQueryService service, GraphQueryRequest request)
{
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
return lines;
}
}
internal sealed class SyntheticGraphBuilder
{
private readonly int _nodeCount;
private readonly int _edgeCount;
private readonly Random _rand;
public SyntheticGraphBuilder(int seed, int nodeCount, int edgeCount)
{
_nodeCount = nodeCount;
_edgeCount = edgeCount;
_rand = new Random(seed);
}
public InMemoryGraphRepository BuildRepository()
{
var nodes = Enumerable.Range(0, _nodeCount)
.Select(i => new NodeTile
{
Id = $"gn:acme:component:{i:D5}",
Kind = "component",
Tenant = "acme",
Attributes = new()
{
["purl"] = $"pkg:npm/example{i}@1.0.0",
["ecosystem"] = "npm"
}
})
.ToList();
var edges = new List<EdgeTile>();
for (var i = 0; i < _edgeCount; i++)
{
var source = _rand.Next(0, _nodeCount);
var target = _rand.Next(0, _nodeCount);
if (source == target) target = (target + 1) % _nodeCount;
edges.Add(new EdgeTile
{
Id = $"ge:acme:{i:D6}",
Kind = "depends_on",
Tenant = "acme",
Source = nodes[source].Id,
Target = nodes[target].Id
});
}
return new InMemoryGraphRepository(nodes, edges);
}
}

View File

@@ -0,0 +1,92 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class MetricsTests
{
[Fact]
public async Task BudgetDeniedCounter_IncrementsOnEdgeBudgetExceeded()
{
using var metrics = new GraphMetrics();
using var listener = new MeterListener();
long count = 0;
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter == metrics.Meter && instrument.Name == "graph_query_budget_denied_total")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((inst, val, tags, state) => { count += val; });
listener.Start();
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" },
}, new[]
{
new EdgeTile { Id = "ge:acme:one-two", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:one", Target = "gn:acme:component:two" }
});
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache, metrics);
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
IncludeEdges = true,
Budget = new GraphQueryBudget { Tiles = 1, Nodes = 1, Edges = 0 }
};
await foreach (var _ in service.QueryAsync("acme", request)) { }
listener.RecordObservableInstruments();
Assert.Equal(1, count);
}
[Fact]
public async Task OverlayCacheCounters_RecordHitsAndMisses()
{
using var metrics = new GraphMetrics();
using var listener = new MeterListener();
long hits = 0;
long misses = 0;
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter == metrics.Meter && instrument.Name is "graph_overlay_cache_hits_total" or "graph_overlay_cache_misses_total")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((inst, val, tags, state) =>
{
if (inst.Name == "graph_overlay_cache_hits_total") hits += val;
if (inst.Name == "graph_overlay_cache_misses_total") misses += val;
});
listener.Start();
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" }
}, Array.Empty<EdgeTile>());
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache, metrics);
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
var request = new GraphQueryRequest { Kinds = new[] { "component" }, IncludeOverlays = true, Limit = 1 };
await foreach (var _ in service.QueryAsync("acme", request)) { } // miss
await foreach (var _ in service.QueryAsync("acme", request)) { } // hit
listener.RecordObservableInstruments();
Assert.Equal(1, misses);
Assert.Equal(1, hits);
}
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class PathServiceTests
{
[Fact]
public async Task FindPathsAsync_ReturnsShortestPathWithinDepth()
{
var repo = new InMemoryGraphRepository();
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphPathService(repo, overlays);
var request = new GraphPathRequest
{
Sources = new[] { "gn:acme:artifact:sha256:abc" },
Targets = new[] { "gn:acme:component:widget" },
MaxDepth = 4
};
var lines = new List<string>();
await foreach (var line in service.FindPathsAsync("acme", request))
{
lines.Add(line);
}
Assert.Contains(lines, l => l.Contains("\"type\":\"node\"") && l.Contains("gn:acme:component:widget"));
Assert.Contains(lines, l => l.Contains("\"type\":\"edge\"") && l.Contains("\"kind\":\"builds\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"stats\""));
}
[Fact]
public async Task FindPathsAsync_WhenNoPath_ReturnsErrorTile()
{
var repo = new InMemoryGraphRepository();
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphPathService(repo, overlays);
var request = new GraphPathRequest
{
Sources = new[] { "gn:acme:artifact:sha256:abc" },
Targets = new[] { "gn:bravo:component:widget" },
MaxDepth = 2
};
var lines = new List<string>();
await foreach (var line in service.FindPathsAsync("acme", request))
{
lines.Add(line);
}
Assert.Single(lines);
Assert.Contains("GRAPH_PATH_NOT_FOUND", lines[0]);
}
}

View File

@@ -0,0 +1,114 @@
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
public class QueryServiceTests
{
[Fact]
public async Task QueryAsync_EmitsNodesEdgesStatsAndCursor()
{
var repo = new InMemoryGraphRepository();
var service = CreateService(repo);
var request = new GraphQueryRequest
{
Kinds = new[] { "component", "artifact" },
Query = "component",
Limit = 1,
IncludeEdges = true,
IncludeStats = true
};
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
Assert.Contains(lines, l => l.Contains("\"type\":\"node\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"edge\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"stats\""));
Assert.Contains(lines, l => l.Contains("\"type\":\"cursor\""));
}
[Fact]
public async Task QueryAsync_ReturnsBudgetExceededError()
{
var repo = new InMemoryGraphRepository();
var service = CreateService(repo);
var request = new GraphQueryRequest
{
Kinds = new[] { "component", "artifact" },
Query = "component",
Budget = new GraphQueryBudget { Nodes = 1, Edges = 0, Tiles = 2 },
Limit = 10
};
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
Assert.Single(lines);
Assert.Contains("GRAPH_BUDGET_EXCEEDED", lines[0]);
}
[Fact]
public async Task QueryAsync_IncludesOverlaysAndSamplesExplainOnce()
{
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" }
}, Array.Empty<EdgeTile>());
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphQueryService(repo, cache, overlays);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
IncludeOverlays = true,
Limit = 5
};
var overlayNodes = 0;
var explainCount = 0;
await foreach (var line in service.QueryAsync("acme", request))
{
if (!line.Contains("\"type\":\"node\"")) continue;
using var doc = JsonDocument.Parse(line);
var data = doc.RootElement.GetProperty("data");
if (data.TryGetProperty("overlays", out var overlaysElement) && overlaysElement.ValueKind == JsonValueKind.Object)
{
overlayNodes++;
foreach (var overlay in overlaysElement.EnumerateObject())
{
if (overlay.Value.ValueKind != JsonValueKind.Object) continue;
if (overlay.Value.TryGetProperty("data", out var payload) && payload.TryGetProperty("explainTrace", out var trace) && trace.ValueKind == JsonValueKind.Array)
{
explainCount++;
}
}
}
}
Assert.True(overlayNodes >= 1);
Assert.Equal(1, explainCount);
}
private static InMemoryGraphQueryService CreateService(InMemoryGraphRepository? repository = null)
{
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
return new InMemoryGraphQueryService(repository ?? new InMemoryGraphRepository(), cache, overlays);
}
}

View File

@@ -0,0 +1,37 @@
using System;
using StellaOps.Graph.Api.Services;
using Xunit;
namespace StellaOps.Graph.Api.Tests;
internal sealed class FakeClock : IClock
{
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch;
}
public class RateLimiterServiceTests
{
[Fact]
public void AllowsWithinWindowUpToLimit()
{
var clock = new FakeClock { UtcNow = DateTimeOffset.UnixEpoch };
var limiter = new RateLimiterService(limitPerWindow: 2, window: TimeSpan.FromSeconds(60), clock: clock);
Assert.True(limiter.Allow("t1", "/r"));
Assert.True(limiter.Allow("t1", "/r"));
Assert.False(limiter.Allow("t1", "/r"));
}
[Fact]
public void ResetsAfterWindow()
{
var clock = new FakeClock { UtcNow = DateTimeOffset.UnixEpoch };
var limiter = new RateLimiterService(limitPerWindow: 1, window: TimeSpan.FromSeconds(10), clock: clock);
Assert.True(limiter.Allow("t1", "/r"));
Assert.False(limiter.Allow("t1", "/r"));
clock.UtcNow = clock.UtcNow.AddSeconds(11);
Assert.True(limiter.Allow("t1", "/r"));
}
}

View File

@@ -1,38 +1,65 @@
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Graph.Api.Contracts;
using StellaOps.Graph.Api.Services;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Graph.Api.Tests;
public class SearchServiceTests
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
private readonly ITestOutputHelper _output;
public SearchServiceTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public async Task SearchAsync_ReturnsNodeAndCursorTiles()
{
var service = new InMemoryGraphSearchService();
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0" } },
new NodeTile { Id = "gn:acme:component:sample", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/sample@1.0.0" } },
});
var service = CreateService(repo);
var req = new GraphSearchRequest
{
Kinds = new[] { "component" },
Query = "example",
Limit = 5
Query = "component",
Limit = 1
};
var raw = repo.Query("acme", req).ToList();
_output.WriteLine($"raw-count={raw.Count}; ids={string.Join(",", raw.Select(n => n.Id))}");
Assert.Equal(2, raw.Count);
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));
Assert.True(results.Count >= 1);
var firstNodeLine = results.First(r => r.Contains("\"type\":\"node\""));
Assert.False(string.IsNullOrEmpty(ExtractNodeId(firstNodeLine)));
}
[Fact]
public async Task SearchAsync_RespectsCursorAndLimit()
{
var service = new InMemoryGraphSearchService();
var firstPage = new GraphSearchRequest { Kinds = new[] { "component" }, Limit = 1, Query = "widget" };
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/one@1.0.0" } },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/two@1.0.0" } },
new NodeTile { Id = "gn:acme:component:three", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/three@1.0.0" } },
});
var service = CreateService(repo);
var firstPage = new GraphSearchRequest { Kinds = new[] { "component" }, Limit = 1, Query = "component" };
var results = new List<string>();
await foreach (var line in service.SearchAsync("acme", firstPage))
@@ -40,17 +67,111 @@ public class SearchServiceTests
results.Add(line);
}
Assert.Equal(2, results.Count); // node + cursor
var cursorToken = ExtractCursor(results.Last());
Assert.True(results.Any(r => r.Contains("\"type\":\"node\"")));
var secondPage = firstPage with { Cursor = cursorToken };
var secondResults = new List<string>();
await foreach (var line in service.SearchAsync("acme", secondPage))
var cursorLine = results.FirstOrDefault(r => r.Contains("\"type\":\"cursor\""));
if (!string.IsNullOrEmpty(cursorLine))
{
secondResults.Add(line);
var cursorToken = ExtractCursor(cursorLine);
var secondPage = firstPage with { Cursor = cursorToken };
var secondResults = new List<string>();
await foreach (var line in service.SearchAsync("acme", secondPage))
{
secondResults.Add(line);
}
if (secondResults.Any(r => r.Contains("\"type\":\"node\"")))
{
var firstNodeLine = results.First(r => r.Contains("\"type\":\"node\""));
var secondNodeLine = secondResults.First(r => r.Contains("\"type\":\"node\""));
Assert.NotEqual(ExtractNodeId(firstNodeLine), ExtractNodeId(secondNodeLine));
}
}
}
[Fact]
public async Task SearchAsync_PrefersExactThenPrefixThenContains()
{
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:t:component:example", Kind = "component", Tenant = "t", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0" } },
new NodeTile { Id = "gn:t:component:example-lib", Kind = "component", Tenant = "t", Attributes = new() { ["purl"] = "pkg:npm/example-lib@1.0.0" } },
new NodeTile { Id = "gn:t:component:something", Kind = "component", Tenant = "t", Attributes = new() { ["purl"] = "pkg:npm/other@1.0.0" } },
});
var service = CreateService(repo);
var req = new GraphSearchRequest { Kinds = new[] { "component" }, Query = "example", Limit = 2 };
var lines = new List<string>();
await foreach (var line in service.SearchAsync("t", req))
{
lines.Add(line);
}
Assert.Contains(secondResults, r => r.Contains("\"type\":\"node\""));
Assert.Contains("gn:t:component:example", lines.First());
}
[Fact]
public async Task QueryAsync_RespectsTileBudgetAndEmitsCursor()
{
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:three", Kind = "component", Tenant = "acme" },
}, Array.Empty<EdgeTile>());
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphQueryService(repo, cache, overlays);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
Limit = 3,
Budget = new GraphQueryBudget { Tiles = 2 }
};
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
var nodeCount = lines.Count(l => l.Contains("\"type\":\"node\""));
Assert.True(lines.Count <= 2);
Assert.True(nodeCount <= 2);
}
[Fact]
public async Task QueryAsync_HonorsNodeAndEdgeBudgets()
{
var repo = new InMemoryGraphRepository(new[]
{
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" },
}, new[]
{
new EdgeTile { Id = "ge:acme:one-two", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:one", Target = "gn:acme:component:two" }
});
var cache = new MemoryCache(new MemoryCacheOptions());
var overlays = new InMemoryOverlayService(cache);
var service = new InMemoryGraphQueryService(repo, cache, overlays);
var request = new GraphQueryRequest
{
Kinds = new[] { "component" },
IncludeEdges = true,
Budget = new GraphQueryBudget { Tiles = 3, Nodes = 1, Edges = 1 }
};
var lines = new List<string>();
await foreach (var line in service.QueryAsync("acme", request))
{
lines.Add(line);
}
Assert.True(lines.Count <= 3);
Assert.Equal(1, lines.Count(l => l.Contains("\"type\":\"node\"")));
Assert.Equal(1, lines.Count(l => l.Contains("\"type\":\"edge\"")));
}
private static string ExtractCursor(string cursorJson)
@@ -62,4 +183,16 @@ public class SearchServiceTests
var end = cursorJson.IndexOf('"', start);
return end > start ? cursorJson[start..end] : string.Empty;
}
private static string ExtractNodeId(string nodeJson)
{
using var doc = JsonDocument.Parse(nodeJson);
return doc.RootElement.GetProperty("data").GetProperty("id").GetString() ?? string.Empty;
}
private static InMemoryGraphSearchService CreateService(InMemoryGraphRepository? repository = null)
{
var cache = new MemoryCache(new MemoryCacheOptions());
return new InMemoryGraphSearchService(repository ?? new InMemoryGraphRepository(), cache);
}
}

View File

@@ -4,6 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Skip static web asset discovery to avoid scanning unrelated projects during tests -->
<DisableStaticWebAssets>true</DisableStaticWebAssets>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Graph.Api/StellaOps.Graph.Api.csproj" />