From 83be32c3d2feaf4e632df61fe686d66b57db61c2 Mon Sep 17 00:00:00 2001 From: jay23606 Date: Wed, 11 Nov 2026 05:38:28 -0500 Subject: [PATCH] Return host-relative nextLink for legacy Datasync v6 clients --- .../Controllers/TableController.Query.cs | 36 +++++++++++++++---- .../Service/Query_Tests.cs | 15 ++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs index 5fc8dc1d..4191d701 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs @@ -105,7 +105,7 @@ public virtual async Task QueryAsync(CancellationToken cancellati } // Produce the paged result. - PagedResult result = BuildPagedResult(queryOptions, entitiesInResultSet.ApplyODataSelect(queryOptions.SelectExpand, querySettings), filteredCount); + PagedResult result = BuildPagedResult(queryOptions, entitiesInResultSet.ApplyODataSelect(queryOptions.SelectExpand, querySettings), filteredCount, IsLegacyClient(Request)); Logger.LogInformation("Query: {Count} items being returned", entitiesInResultSet.Count); return Ok(result); } @@ -140,22 +140,40 @@ protected static IServiceProvider BuildServiceProvider(HttpRequest request) return provider; } + /// + /// Determines whether the request came from a legacy (Datasync/Zumo v6 or earlier) client, which + /// indicates via the X-ZUMO-OPTIONS header that it expects a relative nextLink. + /// + /// The to check. + /// true if the request contains nextLink=path in the X-ZUMO-OPTIONS header; otherwise false. + [NonAction] + internal static bool IsLegacyClient(HttpRequest request) + { + if (!request.Headers.TryGetValue("X-ZUMO-OPTIONS", out StringValues values)) + { + return false; + } + + return values.Any(value => value is not null && value.Contains("nextLink=path", StringComparison.OrdinalIgnoreCase)); + } + /// /// Creates a object from the results of a query. /// /// The OData query options to use in constructing the result. /// The set of results. /// The count of items to be returned by the query without paging. + /// If true, the nextLink is returned as a path relative to the host root. /// A object. [NonAction] - internal PagedResult BuildPagedResult(ODataQueryOptions queryOptions, IEnumerable? results, int count) + internal PagedResult BuildPagedResult(ODataQueryOptions queryOptions, IEnumerable? results, int count, bool legacyClient = false) { int resultCount = results?.Count() ?? 0; int skip = (queryOptions.Skip?.Value ?? 0) + resultCount; int top = (queryOptions.Top?.Value ?? 0) - resultCount; // Internal function to create the nextLink property for the paged result. - static string CreateNextLink(HttpRequest request, int skip = 0, int top = 0) + static string CreateNextLink(HttpRequest request, bool legacyClient, int skip = 0, int top = 0) { Dictionary query = QueryHelpers.ParseNullableQuery(request.QueryString.Value) ?? []; if (skip > 0) @@ -168,17 +186,23 @@ static string CreateNextLink(HttpRequest request, int skip = 0, int top = 0) query[TopParameterName] = top.ToString(CultureInfo.InvariantCulture); } - return QueryHelpers.AddQueryString(string.Empty, query).TrimStart('?'); + string queryString = QueryHelpers.AddQueryString(string.Empty, query).TrimStart('?'); + if (legacyClient) + { + return ((request.PathBase + request.Path).Value ?? string.Empty).Trim('/') + "?" + queryString; + } + + return queryString; } PagedResult result = new(results ?? []) { Count = queryOptions.Count != null ? count : null }; if (queryOptions.Top is not null) { - result.NextLink = skip >= count || top <= 0 ? null : CreateNextLink(Request, skip, top); + result.NextLink = skip >= count || top <= 0 ? null : CreateNextLink(Request, legacyClient, skip, top); } else { - result.NextLink = skip >= count ? null : CreateNextLink(Request, skip, 0); + result.NextLink = skip >= count ? null : CreateNextLink(Request, legacyClient, skip, 0); } return result; diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Service/Query_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Service/Query_Tests.cs index 4f99e8e4..f619284a 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Service/Query_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Service/Query_Tests.cs @@ -45,6 +45,21 @@ await MovieQueryTest( ); } + [Fact] + public async Task Query_LegacyClient_NextLinkIsRelativePath() + { + using HttpRequestMessage request = new(HttpMethod.Get, this.factory.MovieEndpoint); + request.Headers.Add("X-ZUMO-OPTIONS", "nextLink=path"); + HttpResponseMessage response = await this.client.SendAsync(request, TestContext.Current.CancellationToken); + string content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + PageOfItems result = JsonSerializer.Deserialize>(content, this.serializerOptions); + Assert.NotNull(result?.NextLink); + Assert.StartsWith(this.factory.MovieEndpoint.TrimStart('/') + "?", result.NextLink); + Assert.Contains("$skip=100", result.NextLink); + } + [Fact] public async Task Query_Test_002() {