Skip to content

Commit 9f15d38

Browse files
committed
Fix query string contents in nextLink for REST (remove dupe $after query parameter) (#2006)
# Fix query string contents in nextLink for REST ## Why? The change committed in #1895 was written to fix how DAB writes the `nextLink` value in a response body to a GET request using pagination. The `nextLink` contents is a URL which includes a query parameter `$after` which contains base64 encoded pagination metadata. #1895 inadvertently triggered the `nextLink` creation process to append an additional `$after` query parameter instead of overwriting the `$after` query parameter present in the request. Sample request: ```https GET https://localhost:5001/api/Bookmarks?$first=1 ``` Response: ```json { "value": [ { "id": 1, "bkname": "Test Item #1" } ], "nextLink": "https://localhost:5001/api/Bookmarks?$first=1&$after=W3siRW50aXR5TmFtZSI6IkJvb2ttYXJrcyIsIkZpZWxkTmFtZSI6ImlkIiwiRmllbGRWYWx1ZSI6MSwiRGlyZWN0aW9uIjowfV0=" } ``` The `$after` query parameter's value is a properly formatted base64 encoded string as fixed in #1895. The value is an opaque string and from the above sample response translates to: ```json [{"EntityName":"Bookmarks","FieldName":"id","FieldValue":1,"Direction":0}] ``` However, once the `nextLink` is used to fetch another page, the next page's results include an invalid response body because the `nextLink` is improperly formed: ```https GET https://localhost:5001/api/Bookmarks?$first=1&$after=W3siRW50aXR5TmFtZSI6IkJvb2ttYXJrcyIsIkZpZWxkTmFtZSI6ImlkIiwiRmllbGRWYWx1ZSI6MSwiRGlyZWN0aW9uIjowfV0= ``` ```json { "value": [ { "id": 2, "bkname": "Test Item #2" } ], "nextLink": "https://localhost:5001/api/Bookmarks?$first=1&$after=W3siRW50aXR5TmFtZSI6IkJvb2ttYXJrcyIsIkZpZWxkTmFtZSI6ImlkIiwiRmllbGRWYWx1ZSI6MSwiRGlyZWN0aW9uIjowfV0%3D&$after=W3siRW50aXR5TmFtZSI6IkJvb2ttYXJrcyIsIkZpZWxkTmFtZSI6ImlkIiwiRmllbGRWYWx1ZSI6MiwiRGlyZWN0aW9uIjowfV0=" } ``` The invalid and and unexpected value is: `$after=W3siRW50aXR5TmFtZSI6IkJvb2ttYXJrcyIsIkZpZWxkTmFtZSI6ImlkIiwiRmllbGRWYWx1ZSI6MSwiRGlyZWN0aW9uIjowfV0%3D` Not only is it URL escaped, but it is a duplicate value that shouldn't be present. ## What is this change? This change essentially removes the old `$after` query parameter (key and value) from the queryStringParameters NamedValueCollection passed in to `CreateNextLink(...)`: ```csharp public static JsonElement CreateNextLink(string path, NameValueCollection? queryStringParameters, string after){} ``` Due the NamedValueCollection being a special object created by `System.Web.HttpUtility.HttpQSCollection` > The ParseQueryString method uses UTF8 format to parse the query string In the returned NameValueCollection, URL encoded characters are decoded and multiple occurrences of the same query string parameter are listed as a single entry with a comma separating each value. The values are URI escaped and this change purges that uri escaped value: ```csharp // Purge old $after value so this function can replace it. queryStringParameters.Remove("$after"); ``` ```csharp string queryString = FormatQueryString(queryStringParameters: queryStringParameters); if (!string.IsNullOrWhiteSpace(after)) { string afterPrefix = string.IsNullOrWhiteSpace(queryString) ? "?" : "&"; queryString += $"{afterPrefix}{RequestParser.AFTER_URL}={after}"; } ``` The above code: 1. Creates an initial `queryString` where the values are URL encoded. 2. Checks if a new after link is available to inject into query params 3. APPENDS a NEW and unique `$after` query param. (old code simply appended an `$after` query parameter even if one already existed.) 4. JSON Serialize and then JSON Deserialize probably to get rid of some sort of unexpected formatting. ## How was this change tested? Added integration test to ensure nextLink creation doesn't regress and to exercise acquiring the `nextLink` from subsequent pages. The test setup makes an initial request formed to invoke DAB to return a `nextLink` in the response. The assertions occur on the **2nd request** which uses the `nextLink` returned from the first request: 1. Response is 200 -> 400 would indicate issue with query parameter payload 1. Ensures `nextLink` value different from the value returned on the first request used during test setup -> ensures DAB is not recycling a single $after query parameter/nextLink value. 1. Ensures `nextLink` query parameter `$after` does not have a comma, which would indicate that parsing of the query string detected two instance of the `$after` query parameter, which per dotnet behavior, concatenates the values and separates them with a comma. 1. Ensure `$after` value is base64encoded and not URI encoded. ## Sample request As illustrated earlier: ```https GET https://localhost:5001/api/Bookmarks?$first=1 ``` Use the `nextLink` returned to send a follow up pagination request: ```https GET https://localhost:5001/api/Bookmarks?$first=1&$after=W3siRW50aXR5TmFtZSI6IkJvb2ttYXJrcyIsIkZpZWxkTmFtZSI6ImlkIiwiRmllbGRWYWx1ZSI6MSwiRGlyZWN0aW9uIjowfV0= ```
1 parent 0dd4259 commit 9f15d38

File tree

2 files changed

+117
-7
lines changed

2 files changed

+117
-7
lines changed

src/Core/Resolvers/SqlPaginationUtil.cs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -520,22 +520,40 @@ public static string Base64Decode(string base64EncodedData)
520520
/// <summary>
521521
/// Create the URL that will provide for the next page of results
522522
/// using the same query options.
523+
/// Return value formatted as a JSON array: [{"nextLink":"[base]/api/[entity]?[queryParams_URIescaped]$after=[base64encodedPaginationToken]"}]
523524
/// </summary>
524-
/// <param name="path">The request path.</param>
525-
/// <param name="queryStringParameters">Collection of query string parameters.</param>
526-
/// <param name="after">The values needed for next page.</param>
527-
/// <returns>The string representing nextLink.</returns>
528-
public static JsonElement CreateNextLink(string path, NameValueCollection? queryStringParameters, string after)
525+
/// <param name="path">The request path excluding query parameters (e.g. https://localhost/api/myEntity)</param>
526+
/// <param name="queryStringParameters">Collection of query string parameters that are URI escaped.</param>
527+
/// <param name="newAfterPayload">The contents to add to the $after query parameter. Should be base64 encoded pagination token.</param>
528+
/// <returns>JSON element - array with nextLink.</returns>
529+
public static JsonElement CreateNextLink(string path, NameValueCollection? queryStringParameters, string newAfterPayload)
529530
{
531+
if (queryStringParameters is null)
532+
{
533+
queryStringParameters = new();
534+
}
535+
else
536+
{
537+
// Purge old $after value so this function can replace it.
538+
queryStringParameters.Remove("$after");
539+
}
540+
541+
// To prevent regression of current behavior, retain the call to FormatQueryString
542+
// which URI escapes other query parameters. Since $after has been removed,
543+
// this will not affect the base64 encoded paging token.
530544
string queryString = FormatQueryString(queryStringParameters: queryStringParameters);
531-
if (!string.IsNullOrWhiteSpace(after))
545+
546+
// When a new $after payload is provided, append it to the query string with the
547+
// appropriate prefix: ? if $after is the only query parameter. & if $after is one of many query parameters.
548+
if (!string.IsNullOrWhiteSpace(newAfterPayload))
532549
{
533550
string afterPrefix = string.IsNullOrWhiteSpace(queryString) ? "?" : "&";
534-
queryString += $"{afterPrefix}{RequestParser.AFTER_URL}={after}";
551+
queryString += $"{afterPrefix}{RequestParser.AFTER_URL}={newAfterPayload}";
535552
}
536553

537554
// ValueKind will be array so we can differentiate from other objects in the response
538555
// to be returned.
556+
// [{"nextLink":"[base]/api/[entity]?[queryParams_URIescaped]$after=[base64encodedPaginationToken]"}]
539557
string jsonString = JsonSerializer.Serialize(new[]
540558
{
541559
new
@@ -580,6 +598,7 @@ public static string FormatQueryString(NameValueCollection? queryStringParameter
580598

581599
foreach (string key in queryStringParameters)
582600
{
601+
// Whitespace or empty string query paramters are not supported.
583602
if (string.IsNullOrWhiteSpace(key))
584603
{
585604
continue;

src/Service.Tests/Configuration/ConfigurationTests.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Collections.Specialized;
67
using System.IdentityModel.Tokens.Jwt;
78
using System.IO;
89
using System.IO.Abstractions;
@@ -16,6 +17,7 @@
1617
using System.Text.Json;
1718
using System.Threading;
1819
using System.Threading.Tasks;
20+
using System.Web;
1921
using Azure.DataApiBuilder.Config;
2022
using Azure.DataApiBuilder.Config.ObjectModel;
2123
using Azure.DataApiBuilder.Core.AuthenticationHelpers;
@@ -2717,6 +2719,95 @@ public async Task OpenApi_EntityLevelRestEndpoint()
27172719
Assert.IsFalse(componentSchemasElement.TryGetProperty("Publisher", out _));
27182720
}
27192721

2722+
/// <summary>
2723+
/// This test validates that DAB properly creates and returns a nextLink with a single $after
2724+
/// query parameter when sending paging requests.
2725+
/// The first request initiates a paging workload, meaning the response is expected to have a nextLink.
2726+
/// The validation occurs after the second request which uses the previously acquired nextLink
2727+
/// This test ensures that the second request's response body contains the expected nextLink which:
2728+
/// - is base64 encoded and NOT URI escaped e.g. the trailing "==" are not URI escaped to "%3D%3D"
2729+
/// - is not the same as the first response's nextLink -> DAB is properly injecting a new $after query param
2730+
/// and updating the new nextLink
2731+
/// - does not contain a comma (,) indicating that the URI namevaluecollection tracking the query parameters
2732+
/// did not come across two $after query parameters. This addresses a customer raised issue where two $after
2733+
/// query parameters were returned by DAB.
2734+
/// </summary>
2735+
[TestMethod]
2736+
[TestCategory(TestCategory.MSSQL)]
2737+
public async Task ValidateNextLinkUsage()
2738+
{
2739+
// Arrange - Setup test server with entity that has >1 record so that results can be paged.
2740+
// A short cut to using an entity with >100 records is to just include the $first=1 filter
2741+
// as done in this test, so that paging behavior can be invoked.
2742+
2743+
const string ENTITY_NAME = "Bookmark";
2744+
2745+
// At least one entity is required in the runtime config for the engine to start.
2746+
// Even though this entity is not under test, it must be supplied to the config
2747+
// file creation function.
2748+
Entity requiredEntity = new(
2749+
Source: new("bookmarks", EntitySourceType.Table, null, null),
2750+
Rest: new(Enabled: true),
2751+
GraphQL: new(Singular: "", Plural: "", Enabled: false),
2752+
Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) },
2753+
Relationships: null,
2754+
Mappings: null);
2755+
2756+
Dictionary<string, Entity> entityMap = new()
2757+
{
2758+
{ ENTITY_NAME, requiredEntity }
2759+
};
2760+
2761+
CreateCustomConfigFile(globalRestEnabled: true, entityMap);
2762+
2763+
string[] args = new[]
2764+
{
2765+
$"--ConfigFileName={CUSTOM_CONFIG_FILENAME}"
2766+
};
2767+
2768+
using TestServer server = new(Program.CreateWebHostBuilder(args));
2769+
using HttpClient client = server.CreateClient();
2770+
2771+
// Setup and send GET request
2772+
HttpRequestMessage initialPaginationRequest = new(HttpMethod.Get, $"{RestRuntimeOptions.DEFAULT_PATH}/{ENTITY_NAME}?$first=1");
2773+
HttpResponseMessage initialPaginationResponse = await client.SendAsync(initialPaginationRequest);
2774+
2775+
// Process response body for first request and get the nextLink to use on subsequent request
2776+
// which represents what this test is validating.
2777+
string responseBody = await initialPaginationResponse.Content.ReadAsStringAsync();
2778+
Dictionary<string, JsonElement> responseProperties = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(responseBody);
2779+
string nextLinkUri = responseProperties["nextLink"].ToString();
2780+
2781+
// Act - Submit request with nextLink uri as target and capture response
2782+
2783+
HttpRequestMessage followNextLinkRequest = new(HttpMethod.Get, nextLinkUri);
2784+
HttpResponseMessage followNextLinkResponse = await client.SendAsync(followNextLinkRequest);
2785+
2786+
// Assert
2787+
2788+
Assert.AreEqual(HttpStatusCode.OK, followNextLinkResponse.StatusCode, message: "Expected request to succeed.");
2789+
2790+
// Process the response body and inspect the "nextLink" property for expected contents.
2791+
string followNextLinkResponseBody = await followNextLinkResponse.Content.ReadAsStringAsync();
2792+
Dictionary<string, JsonElement> followNextLinkResponseProperties = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(followNextLinkResponseBody);
2793+
2794+
string followUpResponseNextLink = followNextLinkResponseProperties["nextLink"].ToString();
2795+
Uri nextLink = new(uriString: followUpResponseNextLink);
2796+
NameValueCollection parsedQueryParameters = HttpUtility.ParseQueryString(query: nextLink.Query);
2797+
Assert.AreEqual(expected: false, actual: parsedQueryParameters["$after"].Contains(','), message: "nextLink erroneously contained two $after query parameters that were joined by HttpUtility.ParseQueryString(queryString).");
2798+
Assert.AreNotEqual(notExpected: nextLinkUri, actual: followUpResponseNextLink, message: "The follow up request erroneously returned the same nextLink value.");
2799+
2800+
// Do not use SqlPaginationUtils.Base64Encode()/Decode() here to eliminate test dependency on engine code to perform an assert.
2801+
try
2802+
{
2803+
Convert.FromBase64String(parsedQueryParameters["$after"]);
2804+
}
2805+
catch (FormatException)
2806+
{
2807+
Assert.Fail(message: "$after query parameter was not a valid base64 encoded value.");
2808+
}
2809+
}
2810+
27202811
/// <summary>
27212812
/// Helper function to write custom configuration file. with minimal REST/GraphQL global settings
27222813
/// using the supplied entities.

0 commit comments

Comments
 (0)