Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ deploy-log.txt

# VM-specific documentation (internal/personal notes)
docs/vm-docs/

# Table app build output (embedded as resource during dotnet build)
core/Microsoft.Mcp.Core/src/Services/Pagination/table-app/dist/
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,96 @@ protected async Task<ResourceQueryResults<T>> ExecuteResourceQueryAsync<T>(
return result.Results.FirstOrDefault();
}

/// <summary>
/// Executes a paged Resource Graph query and returns results with a skip token for continuation.
/// Uses <see cref="ResourceQueryRequestOptions.Top"/> for page size and supports skip-token-based pagination.
/// </summary>
/// <typeparam name="T">The type to convert each resource to</typeparam>
/// <param name="resourceType">The Azure resource type to query for (e.g., "Microsoft.Storage/storageAccounts")</param>
/// <param name="resourceGroup">The resource group name to filter by (null to query all resource groups)</param>
/// <param name="subscription">The subscription ID or name</param>
/// <param name="retryPolicy">Optional retry policy configuration</param>
/// <param name="converter">Function to convert JsonElement to the target type</param>
/// <param name="tableName">Optional table name to query (default: "resources")</param>
/// <param name="additionalFilter">Optional additional KQL filter condition</param>
/// <param name="pageSize">Maximum number of results per page (default: 10)</param>
/// <param name="skipToken">Optional skip token for pagination continuation</param>
/// <param name="tenant">Optional tenant to use for the query</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Paged results with items and an optional skip token for the next page</returns>
protected async Task<PagedResourceQueryResults<T>> ExecutePagedResourceQueryAsync<T>(
string resourceType,
string? resourceGroup,
string subscription,
RetryPolicyOptions? retryPolicy,
Func<JsonElement, T> converter,
string? tableName = "resources",
string? additionalFilter = null,
int pageSize = 10,
string? skipToken = null,
string? tenant = null,
CancellationToken cancellationToken = default)
{
ValidateRequiredParameters((nameof(resourceType), resourceType), (nameof(subscription), subscription));
ArgumentNullException.ThrowIfNull(converter);

if (!string.IsNullOrEmpty(additionalFilter) && additionalFilter.Contains('|'))
{
throw new ArgumentException(
"additionalFilter must not contain the pipe operator '|' to prevent KQL injection.",
nameof(additionalFilter));
}

var results = new List<T>();

var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken);
var tenantResource = await GetTenantResourceAsync(subscriptionResource!.Data.TenantId, cancellationToken);

var queryFilter = $"{tableName} | where type =~ '{EscapeKqlString(resourceType)}'";
if (!string.IsNullOrEmpty(resourceGroup))
{
if (!await ValidateResourceGroupExistsAsync(subscriptionResource, resourceGroup, cancellationToken))
{
throw new KeyNotFoundException($"Resource group '{resourceGroup}' does not exist in subscription '{subscriptionResource.Data.SubscriptionId}'");
}
queryFilter += $" and resourceGroup =~ '{EscapeKqlString(resourceGroup)}'";
}
if (!string.IsNullOrEmpty(additionalFilter))
{
queryFilter += $" and {additionalFilter}";
}

var queryContent = new ResourceQueryContent(queryFilter)
{
Subscriptions = { subscriptionResource.Data.SubscriptionId },
Options = new ResourceQueryRequestOptions
{
Top = pageSize,
},
};

if (!string.IsNullOrEmpty(skipToken))
{
queryContent.Options.SkipToken = skipToken;
}

ResourceQueryResult result = await tenantResource.GetResourcesAsync(queryContent, cancellationToken);
if (result != null && result.Count > 0)
{
using var jsonDocument = JsonDocument.Parse(result.Data);
var dataArray = jsonDocument.RootElement;
if (dataArray.ValueKind == JsonValueKind.Array)
{
foreach (var item in dataArray.EnumerateArray())
{
results.Add(converter(item));
}
}
}

return new PagedResourceQueryResults<T>(results, result?.SkipToken);
}

/// <summary>
/// Create an <see cref="ArmClient"/> with the specified API version set for the given resource type.
/// This wraps <see cref="BaseAzureService.CreateArmClientAsync"/> and configures the <see cref="ArmClientOptions"/> appropriately.
Expand Down Expand Up @@ -242,3 +332,5 @@ protected async Task<GenericResource> CreateOrUpdateGenericResourceAsync<T>(ArmC
}

public sealed record ResourceQueryResults<T>(List<T> Results, bool AreResultsTruncated);

public sealed record PagedResourceQueryResults<T>(List<T> Results, string? SkipToken);
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Mcp.Core.Services.Pagination;

namespace Azure.Mcp.Core.Services.Pagination;

/// <summary>
/// Pagination provider adapter for Azure SDK <see cref="AsyncPageable{T}"/> sources.
/// Uses the SDK continuation token as the native continuation state.
/// </summary>
/// <typeparam name="TSource">The Azure SDK item type returned by the pageable.</typeparam>
/// <typeparam name="TResult">The domain model type exposed to callers.</typeparam>
public sealed class AsyncPageablePaginationAdapter<TSource, TResult>(
Func<string?, int?, Task<AsyncPageablePaginationAdapter<TSource, TResult>.PageableResult>> pageableFactory,
Func<TSource, TResult> converter,
int pageSize = 10) : IPaginationProviderAdapter<TResult>
where TSource : notnull
{
public const string ProviderName = "asyncpageable";

public string Provider => ProviderName;

public async Task<PageResult<TResult>> FetchFirstPageAsync(CancellationToken cancellationToken = default)
{
return await FetchPageAsync(null, cancellationToken);
}

public async Task<PageResult<TResult>> FetchNextPageAsync(string nativeState, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(nativeState);
return await FetchPageAsync(nativeState, cancellationToken);
}

private async Task<PageResult<TResult>> FetchPageAsync(string? continuationToken, CancellationToken cancellationToken)
{
var result = await pageableFactory(continuationToken, pageSize);
var items = new List<TResult>();
string? nextContinuationToken = null;

await foreach (var page in result.Pageable.AsPages(continuationToken, pageSize).WithCancellation(cancellationToken))
{
foreach (var item in page.Values)
{
items.Add(converter(item));
}

nextContinuationToken = page.ContinuationToken;
break; // Only fetch one page
}

return new PageResult<TResult>(items, nextContinuationToken);
}

/// <summary>
/// Wraps the <see cref="AsyncPageable{T}"/> returned by a service call.
/// </summary>
/// <param name="Pageable">The async pageable to iterate over.</param>
public sealed record PageableResult(AsyncPageable<TSource> Pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Mcp.Core.Services.Azure;
using Microsoft.Mcp.Core.Services.Pagination;

namespace Azure.Mcp.Core.Services.Pagination;

/// <summary>
/// Pagination provider adapter for Azure Resource Graph (KQL) queries.
/// Uses the Resource Graph <c>skipToken</c> as the native continuation state.
/// </summary>
/// <typeparam name="T">The type of items returned per page.</typeparam>
public sealed class KqlPaginationAdapter<T>(
Func<string?, Task<PagedResourceQueryResults<T>>> queryExecutor) : IPaginationProviderAdapter<T>
{
public const string ProviderName = "kql";

public string Provider => ProviderName;

public async Task<PageResult<T>> FetchFirstPageAsync(CancellationToken cancellationToken = default)
{
var result = await queryExecutor(null);
return new PageResult<T>(result.Results, result.SkipToken);
}

public async Task<PageResult<T>> FetchNextPageAsync(string nativeState, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(nativeState);

var result = await queryExecutor(nativeState);
return new PageResult<T>(result.Results, result.SkipToken);
}
}
Loading