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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/http-client-csharp"
---

Use `Utf8JsonReader` for untyped `CollectionResult` to efficiently read the continuation token or next link from the response body without fully deserializing the envelope model type. Nested property paths are supported.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.TypeSpec.Generator.ClientModel.Snippets;
using Microsoft.TypeSpec.Generator.ClientModel.Utilities;
Expand Down Expand Up @@ -337,8 +338,6 @@ private MethodBodyStatement[] BuildGetContinuationToken()
switch (NextPageLocation)
{
case InputResponseLocation.Body:
var resultExpression = GetPropertyExpression(nextPagePropertySegments!, PageParameter.AsVariable());

var ifElseStatement = nextPageType.Equals(typeof(Uri))
? new IfElseStatement(new IfStatement(nextPageVariable.NotEqual(Null))
{
Expand All @@ -357,6 +356,16 @@ private MethodBodyStatement[] BuildGetContinuationToken()
},
Return(Null));

// For the untyped CollectionResult (no item model type), use Utf8JsonReader to read only
// the specific properties needed from the response, avoiding full model deserialization.
if (ItemModelType == null)
{
var contentExpression = PageParameter.ToApi<ClientResponseApi>().GetRawResponse().Content();
var readStatements = BuildReadNextPageWithUtf8JsonReader(nextPageVariable, contentExpression, nextPagePropertySegments!, declareVariable: true);
return [.. readStatements, ifElseStatement];
}

var resultExpression = GetPropertyExpression(nextPagePropertySegments!, PageParameter.AsVariable());
return
[
Declare(nextPageVariable, resultExpression),
Expand All @@ -380,6 +389,127 @@ private MethodBodyStatement[] BuildGetContinuationToken()
}
}

/// <summary>
/// Builds the statements to read the next page value from a JSON response using Utf8JsonReader,
/// navigating through the given property segments path. This avoids deserializing the entire
/// response into the model type when only specific properties are needed.
/// </summary>
/// <param name="nextPageVar">The variable to assign the next page value to.</param>
/// <param name="contentExpression">The expression to get the response content from.</param>
/// <param name="segments">The JSON property path segments to navigate.</param>
/// <param name="declareVariable">
/// When <c>true</c>, the first statement declares the variable with a null initializer.
/// When <c>false</c>, the first statement is an assignment to null (variable already declared).
/// </param>
private MethodBodyStatement[] BuildReadNextPageWithUtf8JsonReader(
VariableExpression nextPageVar,
ValueExpression contentExpression,
IReadOnlyList<string> segments,
bool declareVariable = false)
{
var dataVar = new VariableExpression(typeof(BinaryData), "data");
var readerVar = new VariableExpression(typeof(Utf8JsonReader), "reader");

MethodBodyStatement initStatement = declareVariable
? Declare(nextPageVar, Null)
: nextPageVar.Assign(Null).Terminate();

return
[
// Initialize next page variable to null
initStatement,
// Declare data from the response content
Declare(dataVar, contentExpression),
// Declare reader from the data span
Declare(readerVar, New.Instance(typeof(Utf8JsonReader), ReadOnlyMemorySnippets.Span(dataVar.As<BinaryData>().ToMemory()))),
// Read past the root StartObject token
readerVar.Read().Terminate(),
// Navigate the JSON path and assign the next page value
BuildReaderLoopForSegments(readerVar, nextPageVar, segments, 0)
];
}

/// <summary>
/// Recursively builds a while loop that navigates a JSON reader through the given property segments
/// and assigns the found value to the target variable.
/// </summary>
private WhileStatement BuildReaderLoopForSegments(
VariableExpression reader,
VariableExpression targetVar,
IReadOnlyList<string> segments,
int segmentIndex)
{
var propertyName = segments[segmentIndex];
bool isLastSegment = segmentIndex == segments.Count - 1;

// Condition: reader.TokenType == PropertyName && reader.ValueTextEquals(propertyName)
var isPropertyMatch = reader.TokenType().Equal(JsonTokenTypeSnippets.PropertyName)
.And(reader.ValueTextEquals(propertyName));

var loop = new WhileStatement(reader.Read());

// Build the "found property" handler
var foundPropertyStatement = new IfStatement(isPropertyMatch);
if (isLastSegment)
{
// Read the value and assign to target
foreach (var stmt in BuildTerminalPropertyReadStatements(reader, targetVar))
foundPropertyStatement.Add(stmt);
}
else
{
// Navigate into the nested object
var innerLoop = BuildReaderLoopForSegments(reader, targetVar, segments, segmentIndex + 1);
foundPropertyStatement.Add(reader.Read().Terminate());
foundPropertyStatement.Add(new IfStatement(reader.TokenType().Equal(JsonTokenTypeSnippets.StartObject)) { innerLoop });
foundPropertyStatement.Add(Break);
}

loop.Add(foundPropertyStatement);

// Skip nested objects/arrays that appear as property values to avoid false property name matches
loop.Add(new IfStatement(
reader.TokenType().Equal(JsonTokenTypeSnippets.StartObject)
.Or(reader.TokenType().Equal(JsonTokenTypeSnippets.StartArray)))
{
reader.Skip().Terminate()
});

return loop;
}

/// <summary>
/// Builds the statements to read the terminal property value from the reader and assign it to the target variable.
/// </summary>
private static MethodBodyStatement[] BuildTerminalPropertyReadStatements(
VariableExpression reader,
VariableExpression targetVar)
{
if (targetVar.Type.Equals(typeof(Uri)))
{
// Read the string value and convert to Uri if non-empty
var strVar = new VariableExpression(typeof(string), "nextPageStr");
return
[
reader.Read().Terminate(),
Declare(strVar, reader.GetString()),
new IfStatement(Not(Static<string>().Invoke(nameof(string.IsNullOrEmpty), strVar)))
{
targetVar.Assign(New.Instance<Uri>(strVar, FrameworkEnumValue(UriKind.RelativeOrAbsolute))).Terminate()
},
Break
];
}

// Read the string value directly
return
[
reader.Read().Terminate(),
targetVar.Assign(reader.GetString()).Terminate(),
Break
];
}

private MethodBodyStatement[] BuildGetRawPagesForNextLink()
{
var nextPageVariable = new VariableExpression(typeof(Uri), "nextPageUri");
Expand Down Expand Up @@ -482,6 +612,21 @@ protected MethodBodyStatement[] AssignAndCheckNextPageVariable(ClientResponseApi
switch (NextPageLocation)
{
case InputResponseLocation.Body:
// For the untyped CollectionResult (no item model type), use Utf8JsonReader to read only
// the specific properties needed from the response, avoiding full model deserialization.
if (ItemModelType == null)
{
var contentExpression = result.GetRawResponse().Content();
var readStatements = BuildReadNextPageWithUtf8JsonReader(nextPage, contentExpression, NextPagePropertySegments);

IfStatement nullCheckCondition = nextPage.Type.Equals(typeof(Uri))
? new IfStatement(nextPage.Equal(Null))
: new IfStatement(Static<string>().Invoke(nameof(string.IsNullOrEmpty), nextPage));
nullCheckCondition.Add(YieldBreak());

return [.. readStatements, nullCheckCondition];
}

var resultExpression = BuildGetPropertyExpression(NextPagePropertySegments, responseModel);
if (Paging.ContinuationToken != null || NextPagePropertyType.Equals(typeof(Uri)))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ internal static class JsonTokenTypeSnippets
public static ValueExpression StartObject => FrameworkEnumValue(JsonTokenType.StartObject);
public static ValueExpression EndObject => FrameworkEnumValue(JsonTokenType.EndObject);
public static ValueExpression Null => FrameworkEnumValue(JsonTokenType.Null);
public static ValueExpression PropertyName => FrameworkEnumValue(JsonTokenType.PropertyName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,11 @@ public static InvokeMethodExpression GetDateTimeOffset(this VariableExpression r

public static InvokeMethodExpression GetGuid(this VariableExpression reader)
=> reader.Invoke(nameof(Utf8JsonReader.GetGuid));

public static InvokeMethodExpression ValueTextEquals(this VariableExpression reader, string text)
=> reader.Invoke(nameof(Utf8JsonReader.ValueTextEquals), Literal(text));

public static InvokeMethodExpression Skip(this VariableExpression reader)
=> reader.Invoke(nameof(Utf8JsonReader.Skip));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using Sample.Models;
using System.Text.Json;

namespace Sample
{
Expand All @@ -32,7 +32,23 @@ public CatClientGetCatsCollectionResult(global::Sample.CatClient client, string
global::System.ClientModel.ClientResult result = global::System.ClientModel.ClientResult.FromResponse(_client.Pipeline.ProcessMessage(message, _options));
yield return result;

nextToken = ((global::Sample.Models.Page)result).NextPage;
nextToken = null;
global::System.BinaryData data = result.GetRawResponse().Content;
global::System.Text.Json.Utf8JsonReader reader = new global::System.Text.Json.Utf8JsonReader(data.ToMemory().Span);
reader.Read();
while (reader.Read())
{
if (((reader.TokenType == global::System.Text.Json.JsonTokenType.PropertyName) && reader.ValueTextEquals("nextPage")))
{
reader.Read();
nextToken = reader.GetString();
break;
}
if (((reader.TokenType == global::System.Text.Json.JsonTokenType.StartObject) || (reader.TokenType == global::System.Text.Json.JsonTokenType.StartArray)))
{
reader.Skip();
}
}
if (string.IsNullOrEmpty(nextToken))
{
yield break;
Expand All @@ -43,7 +59,23 @@ public CatClientGetCatsCollectionResult(global::Sample.CatClient client, string

public override global::System.ClientModel.ContinuationToken GetContinuationToken(global::System.ClientModel.ClientResult page)
{
string nextPage = ((global::Sample.Models.Page)page).NextPage;
string nextPage = null;
global::System.BinaryData data = page.GetRawResponse().Content;
global::System.Text.Json.Utf8JsonReader reader = new global::System.Text.Json.Utf8JsonReader(data.ToMemory().Span);
reader.Read();
while (reader.Read())
{
if (((reader.TokenType == global::System.Text.Json.JsonTokenType.PropertyName) && reader.ValueTextEquals("nextPage")))
{
reader.Read();
nextPage = reader.GetString();
break;
}
if (((reader.TokenType == global::System.Text.Json.JsonTokenType.StartObject) || (reader.TokenType == global::System.Text.Json.JsonTokenType.StartArray)))
{
reader.Skip();
}
}
if (!string.IsNullOrEmpty(nextPage))
{
return global::System.ClientModel.ContinuationToken.FromBytes(global::System.BinaryData.FromString(nextPage));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using Sample.Models;
using System.Text.Json;

namespace Sample
{
Expand All @@ -32,7 +32,23 @@ public CatClientGetCatsAsyncCollectionResult(global::Sample.CatClient client, st
global::System.ClientModel.ClientResult result = global::System.ClientModel.ClientResult.FromResponse(await _client.Pipeline.ProcessMessageAsync(message, _options).ConfigureAwait(false));
yield return result;

nextToken = ((global::Sample.Models.Page)result).NextPage;
nextToken = null;
global::System.BinaryData data = result.GetRawResponse().Content;
global::System.Text.Json.Utf8JsonReader reader = new global::System.Text.Json.Utf8JsonReader(data.ToMemory().Span);
reader.Read();
while (reader.Read())
{
if (((reader.TokenType == global::System.Text.Json.JsonTokenType.PropertyName) && reader.ValueTextEquals("nextPage")))
{
reader.Read();
nextToken = reader.GetString();
break;
}
if (((reader.TokenType == global::System.Text.Json.JsonTokenType.StartObject) || (reader.TokenType == global::System.Text.Json.JsonTokenType.StartArray)))
{
reader.Skip();
}
}
if (string.IsNullOrEmpty(nextToken))
{
yield break;
Expand All @@ -43,7 +59,23 @@ public CatClientGetCatsAsyncCollectionResult(global::Sample.CatClient client, st

public override global::System.ClientModel.ContinuationToken GetContinuationToken(global::System.ClientModel.ClientResult page)
{
string nextPage = ((global::Sample.Models.Page)page).NextPage;
string nextPage = null;
global::System.BinaryData data = page.GetRawResponse().Content;
global::System.Text.Json.Utf8JsonReader reader = new global::System.Text.Json.Utf8JsonReader(data.ToMemory().Span);
reader.Read();
while (reader.Read())
{
if (((reader.TokenType == global::System.Text.Json.JsonTokenType.PropertyName) && reader.ValueTextEquals("nextPage")))
{
reader.Read();
nextPage = reader.GetString();
break;
}
if (((reader.TokenType == global::System.Text.Json.JsonTokenType.StartObject) || (reader.TokenType == global::System.Text.Json.JsonTokenType.StartArray)))
{
reader.Skip();
}
}
if (!string.IsNullOrEmpty(nextPage))
{
return global::System.ClientModel.ContinuationToken.FromBytes(global::System.BinaryData.FromString(nextPage));
Expand Down
Loading
Loading