grpc: implement BrowseChildren handler + metadata:read scope
This commit is contained in:
@@ -26,6 +26,8 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
|
||||
private const int DefaultDiscoverPageSize = 1000;
|
||||
private const int MaxDiscoverPageSize = 5000;
|
||||
private const int DefaultBrowsePageSize = 500;
|
||||
// MaxBrowsePageSize reuses MaxDiscoverPageSize (5000) — same cap.
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<TestConnectionReply> TestConnection(
|
||||
@@ -107,6 +109,62 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<BrowseChildrenReply> BrowseChildren(
|
||||
BrowseChildrenRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
|
||||
if (!entry.HasData)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.Unavailable,
|
||||
ResolveUnavailableMessage(entry)));
|
||||
}
|
||||
|
||||
int pageSize = ResolveBrowsePageSize(request.PageSize);
|
||||
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
|
||||
|
||||
// Resolve the parent id once so the page-token signature can include it
|
||||
// and the projector sees the same resolved id when memoizing.
|
||||
int parentId = ResolveParentIdForToken(entry, request);
|
||||
string filterSignature = GalaxyDb.GalaxyBrowseProjector.ComputeFilterSignature(
|
||||
request, browseSubtrees, parentId);
|
||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||
|
||||
GalaxyDb.GalaxyBrowseChildrenResult result = GalaxyDb.GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
request,
|
||||
browseSubtrees,
|
||||
pageToken.Offset,
|
||||
pageSize);
|
||||
|
||||
if (pageToken.Offset > result.TotalChildCount)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"BrowseChildren page_token is outside the current children set."));
|
||||
}
|
||||
|
||||
BrowseChildrenReply reply = new()
|
||||
{
|
||||
TotalChildCount = result.TotalChildCount,
|
||||
CacheSequence = (ulong)entry.Sequence,
|
||||
};
|
||||
reply.Children.Add(result.Children);
|
||||
reply.ChildHasChildren.Add(result.ChildHasChildren);
|
||||
|
||||
int nextOffset = pageToken.Offset + result.Children.Count;
|
||||
if (nextOffset < result.TotalChildCount)
|
||||
{
|
||||
reply.NextPageToken = FormatPageToken(entry.Sequence, result.FilterSignature, nextOffset);
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task WatchDeployEvents(
|
||||
WatchDeployEventsRequest request,
|
||||
@@ -213,6 +271,44 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||
}
|
||||
|
||||
private static int ResolveBrowsePageSize(int requested)
|
||||
{
|
||||
if (requested < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"BrowseChildren page_size must be greater than zero when provided."));
|
||||
}
|
||||
int pageSize = requested == 0 ? DefaultBrowsePageSize : requested;
|
||||
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||
}
|
||||
|
||||
// Lightweight parent resolver used only for signature computation. Re-throws
|
||||
// NotFound consistently with the projector so the error surface matches.
|
||||
private static int ResolveParentIdForToken(
|
||||
GalaxyDb.GalaxyHierarchyCacheEntry entry,
|
||||
BrowseChildrenRequest request)
|
||||
{
|
||||
return request.ParentCase switch
|
||||
{
|
||||
BrowseChildrenRequest.ParentOneofCase.None => 0,
|
||||
BrowseChildrenRequest.ParentOneofCase.ParentGobjectId =>
|
||||
request.ParentGobjectId == 0 ? 0
|
||||
: entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId)
|
||||
? request.ParentGobjectId
|
||||
: throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
||||
BrowseChildrenRequest.ParentOneofCase.ParentTagName =>
|
||||
entry.Index.ObjectViews.FirstOrDefault(
|
||||
v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
|
||||
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
||||
BrowseChildrenRequest.ParentOneofCase.ParentContainedPath =>
|
||||
entry.Index.ObjectViews.FirstOrDefault(
|
||||
v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
|
||||
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> ResolveBrowseSubtrees()
|
||||
{
|
||||
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||
|
||||
@@ -23,7 +23,8 @@ public sealed class GatewayGrpcScopeResolver
|
||||
TestConnectionRequest or
|
||||
GetLastDeployTimeRequest or
|
||||
DiscoverHierarchyRequest or
|
||||
WatchDeployEventsRequest => GatewayScopes.MetadataRead,
|
||||
WatchDeployEventsRequest or
|
||||
BrowseChildrenRequest => GatewayScopes.MetadataRead,
|
||||
_ => GatewayScopes.Admin
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Grpc;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
|
||||
@@ -335,4 +336,155 @@ public sealed class GalaxyRepositoryGrpcServiceTests
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BrowseChildren returns root objects and the current cache sequence when called with no parent.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_RootCall_ReturnsRootsWithCacheSequence()
|
||||
{
|
||||
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
|
||||
|
||||
BrowseChildrenReply reply = await service.BrowseChildren(
|
||||
new BrowseChildrenRequest(),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(2, reply.Children.Count);
|
||||
Assert.Equal("Area1", reply.Children[0].TagName);
|
||||
Assert.Equal("Other_001", reply.Children[1].TagName);
|
||||
Assert.Equal(7UL, reply.CacheSequence);
|
||||
Assert.Equal(2, reply.TotalChildCount);
|
||||
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that BrowseChildren returns Unavailable when the cache's first load never completes.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_FirstLoadNotComplete_ReturnsUnavailable()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
|
||||
};
|
||||
GalaxyRepositoryGrpcService service = new(
|
||||
new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
|
||||
new NeverLoadsHierarchyCache(),
|
||||
new GalaxyDeployNotifier(),
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
NullLogger<GalaxyRepositoryGrpcService>.Instance);
|
||||
|
||||
// No caller-supplied CT so WaitForCacheBootstrap exits via its 5s internal budget
|
||||
// (instead of re-throwing OperationCanceledException from the caller's CT). The
|
||||
// handler then sees Status=Unknown and returns Unavailable.
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.BrowseChildren(
|
||||
new BrowseChildrenRequest(),
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a page token bound to a stale cache sequence is rejected with InvalidArgument.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_StaleToken_ReturnsInvalidArgument()
|
||||
{
|
||||
GalaxyRepositoryGrpcService firstService = CreateService(CreateEntry(CreateFilterObjects()));
|
||||
BrowseChildrenReply firstReply = await firstService.BrowseChildren(
|
||||
new BrowseChildrenRequest { PageSize = 1 },
|
||||
new TestServerCallContext());
|
||||
Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken));
|
||||
|
||||
GalaxyHierarchyCacheEntry newerEntry = CreateEntry(CreateFilterObjects()) with { Sequence = 8 };
|
||||
GalaxyRepositoryGrpcService secondService = CreateService(newerEntry);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await secondService.BrowseChildren(
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
PageSize = 1,
|
||||
PageToken = firstReply.NextPageToken,
|
||||
},
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Contains("stale", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that switching filters between paged BrowseChildren calls is rejected.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_FilterChangeBetweenPages_ReturnsInvalidArgument()
|
||||
{
|
||||
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
|
||||
BrowseChildrenReply firstReply = await service.BrowseChildren(
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 2,
|
||||
PageSize = 1,
|
||||
},
|
||||
new TestServerCallContext());
|
||||
Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken));
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.BrowseChildren(
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 2,
|
||||
PageSize = 1,
|
||||
PageToken = firstReply.NextPageToken,
|
||||
TagNameGlob = "Pump*",
|
||||
},
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an ApiKeyIdentity browse-subtrees constraint that matches nothing produces an empty child list.</summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_BrowseSubtreesConstraint_FiltersChildren()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
|
||||
};
|
||||
GatewayRequestIdentityAccessor identityAccessor = new();
|
||||
GalaxyRepositoryGrpcService service = new(
|
||||
new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
|
||||
new StubGalaxyHierarchyCache(CreateEntry(CreateFilterObjects())),
|
||||
new GalaxyDeployNotifier(),
|
||||
identityAccessor,
|
||||
NullLogger<GalaxyRepositoryGrpcService>.Instance);
|
||||
|
||||
// Sanity: with no identity pushed, both Pump and Valve come back under Line3 (id=2).
|
||||
BrowseChildrenReply unconstrained = await service.BrowseChildren(
|
||||
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
||||
new TestServerCallContext());
|
||||
Assert.Equal(2, unconstrained.Children.Count);
|
||||
|
||||
ApiKeyIdentity identity = new(
|
||||
KeyId: "test-key",
|
||||
KeyPrefix: "mxgw_test",
|
||||
DisplayName: "constraint-only",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { GatewayScopes.MetadataRead },
|
||||
Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = new[] { "NonExistent" } });
|
||||
|
||||
using (identityAccessor.Push(identity))
|
||||
{
|
||||
BrowseChildrenReply constrained = await service.BrowseChildren(
|
||||
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
||||
new TestServerCallContext());
|
||||
Assert.Empty(constrained.Children);
|
||||
Assert.Equal(0, constrained.TotalChildCount);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NeverLoadsHierarchyCache : IGalaxyHierarchyCache
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public GalaxyHierarchyCacheEntry Current { get; } =
|
||||
GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Unknown };
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) =>
|
||||
Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class GatewayGrpcScopeResolverTests
|
||||
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
|
||||
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
|
||||
[InlineData(typeof(WatchDeployEventsRequest), GatewayScopes.MetadataRead)]
|
||||
[InlineData(typeof(BrowseChildrenRequest), GatewayScopes.MetadataRead)]
|
||||
public void ResolveRequiredScope_KnownRpcRequest_ReturnsExpectedScope(
|
||||
Type requestType,
|
||||
string expectedScope)
|
||||
|
||||
Reference in New Issue
Block a user