Skip to content

Commit 15895ce

Browse files
authored
Improve Source Schema HTTP client on hot path (#9442)
1 parent 32131a4 commit 15895ce

25 files changed

Lines changed: 455 additions & 425 deletions

File tree

.github/workflows/benchmarks.yml

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,7 @@
11
name: Benchmarks
22

33
on:
4-
pull_request:
5-
types: [opened, synchronize, reopened, ready_for_review, closed]
6-
branches:
7-
- main
8-
- main-version-*
9-
paths:
10-
- 'src/HotChocolate/Fusion/**'
11-
- '.github/workflows/benchmarks.yml'
12-
push:
13-
branches:
14-
- main
15-
paths:
16-
- 'src/HotChocolate/Fusion/**'
17-
- '.github/workflows/benchmarks.yml'
4+
workflow_dispatch: {}
185

196
concurrency:
207
group: benchmarks-${{ github.event.pull_request.number || github.ref }}

src/HotChocolate/AspNetCore/src/Transport.Http/DefaultGraphQLHttpClient.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,14 @@ private async Task<GraphQLHttpResponse> ExecuteInternalAsync(
111111
using var arrayWriter = new PooledArrayWriter();
112112
using var requestMessage = CreateRequestMessage(arrayWriter, request, requestUri);
113113

114+
#if FUSION
115+
if (request.State is { } state)
116+
{
117+
request.OnMessageCreated?.Invoke(request, requestMessage, state);
118+
}
119+
#else
114120
request.OnMessageCreated?.Invoke(request, requestMessage, request.State);
121+
#endif
115122

116123
requestMessage.Version = _http.DefaultRequestVersion;
117124
requestMessage.VersionPolicy = _http.DefaultVersionPolicy;
@@ -120,7 +127,14 @@ private async Task<GraphQLHttpResponse> ExecuteInternalAsync(
120127
.SendAsync(requestMessage, ResponseHeadersRead, ct)
121128
.ConfigureAwait(false);
122129

130+
#if FUSION
131+
if (request.State is { } receivedState)
132+
{
133+
request.OnMessageReceived?.Invoke(request, responseMessage, receivedState);
134+
}
135+
#else
123136
request.OnMessageReceived?.Invoke(request, responseMessage, request.State);
137+
#endif
124138

125139
return new GraphQLHttpResponse(responseMessage);
126140
}

src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpRequest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Immutable;
22
using System.Net.Http.Headers;
33
#if FUSION
4+
using HotChocolate.Fusion.Execution.Clients;
45
using HotChocolate.Transport;
56
using HotChocolate.Transport.Http;
67
#endif
@@ -188,7 +189,11 @@ public GraphQLHttpRequest(OperationBatchRequest body, Uri? requestUri = null)
188189
/// <summary>
189190
/// Allows to specify some custom request state, that will be passed into the request hooks.
190191
/// </summary>
192+
#if FUSION
193+
public RequestCallbackState? State { get; set; }
194+
#else
191195
public object? State { get; set; }
196+
#endif
192197

193198
public static implicit operator GraphQLHttpRequest(OperationRequest body) => new(body);
194199

src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,74 @@ private static ReadOnlySpan<char> TrimWhiteSpace(ReadOnlySpan<char> value)
233233

234234
return value[start..(end + 1)];
235235
}
236+
237+
/// <summary>
238+
/// Extracts the media type and charset from the raw Content-Type header
239+
/// without allocating a <see cref="MediaTypeHeaderValue"/>.
240+
/// </summary>
241+
private bool TryGetRawMediaTypeAndCharSet(
242+
out ReadOnlySpan<char> mediaType,
243+
out string? charSet)
244+
{
245+
if (!_message.Content.Headers.NonValidated.TryGetValues(ContentTypeHeaderName, out var values))
246+
{
247+
mediaType = default;
248+
charSet = null;
249+
return false;
250+
}
251+
252+
var enumerator = values.GetEnumerator();
253+
if (!enumerator.MoveNext())
254+
{
255+
mediaType = default;
256+
charSet = null;
257+
return false;
258+
}
259+
260+
var rawValue = enumerator.Current.AsSpan();
261+
262+
// Some handlers may emit media type and charset as separate values.
263+
if (enumerator.MoveNext())
264+
{
265+
mediaType = NormalizeMediaType(rawValue);
266+
var charsetValue = enumerator.Current.AsSpan();
267+
charSet = IsUtf8(charsetValue) ? Utf8 : charsetValue.Trim().ToString();
268+
return true;
269+
}
270+
271+
// Single header value — split on ';' to separate media type from parameters.
272+
var semicolonIndex = rawValue.IndexOf(';');
273+
if (semicolonIndex < 0)
274+
{
275+
mediaType = TrimWhiteSpace(rawValue);
276+
charSet = null;
277+
return true;
278+
}
279+
280+
mediaType = TrimWhiteSpace(rawValue[..semicolonIndex]);
281+
var parameters = rawValue[(semicolonIndex + 1)..];
282+
283+
// Extract charset from parameters (e.g., " charset=utf-8").
284+
var charsetIndex = parameters.IndexOf(CharsetPrefix, StringComparison.OrdinalIgnoreCase);
285+
if (charsetIndex >= 0)
286+
{
287+
var charsetSpan = TrimWhiteSpace(parameters[(charsetIndex + CharsetPrefix.Length)..]);
288+
289+
// Strip quotes if present.
290+
if (charsetSpan.Length > 1 && charsetSpan[0] == '"' && charsetSpan[^1] == '"')
291+
{
292+
charsetSpan = charsetSpan[1..^1];
293+
}
294+
295+
charSet = charsetSpan.Equals(Utf8, StringComparison.OrdinalIgnoreCase) ? Utf8 : charsetSpan.ToString();
296+
}
297+
else
298+
{
299+
charSet = null;
300+
}
301+
302+
return true;
303+
}
236304
#endif
237305

238306
/// <summary>
@@ -258,6 +326,32 @@ private static ReadOnlySpan<char> TrimWhiteSpace(ReadOnlySpan<char> value)
258326
/// to read the <see cref="SourceResultDocument"/> from the underlying <see cref="HttpResponseMessage"/>.
259327
/// </returns>
260328
public ValueTask<SourceResultDocument> ReadAsResultAsync(CancellationToken cancellationToken = default)
329+
{
330+
if (!TryGetRawMediaTypeAndCharSet(out var mediaType, out var charSet))
331+
{
332+
_message.EnsureSuccessStatusCode();
333+
throw new InvalidOperationException("Received a successful response with an unexpected content type.");
334+
}
335+
336+
// The server supports the newer graphql-response+json media type, and users are free
337+
// to use status codes.
338+
if (mediaType.Equals(ContentType.GraphQL, StringComparison.OrdinalIgnoreCase))
339+
{
340+
return ReadAsResultInternalAsync(charSet, cancellationToken);
341+
}
342+
343+
// The server supports the older application/json media type, and the status code
344+
// is expected to be a 2xx for a valid GraphQL response.
345+
if (mediaType.Equals(ContentType.Json, StringComparison.OrdinalIgnoreCase))
346+
{
347+
_message.EnsureSuccessStatusCode();
348+
return ReadAsResultInternalAsync(charSet, cancellationToken);
349+
}
350+
351+
_message.EnsureSuccessStatusCode();
352+
353+
throw new InvalidOperationException("Received a successful response with an unexpected content type.");
354+
}
261355
#else
262356
/// <summary>
263357
/// Reads the GraphQL response as a <see cref="OperationResult"/>.
@@ -270,7 +364,6 @@ public ValueTask<SourceResultDocument> ReadAsResultAsync(CancellationToken cance
270364
/// to read the <see cref="OperationResult"/> from the underlying <see cref="HttpResponseMessage"/>.
271365
/// </returns>
272366
public ValueTask<OperationResult> ReadAsResultAsync(CancellationToken cancellationToken = default)
273-
#endif
274367
{
275368
var contentType = _message.Content.Headers.ContentType;
276369

@@ -293,6 +386,7 @@ public ValueTask<OperationResult> ReadAsResultAsync(CancellationToken cancellati
293386

294387
throw new InvalidOperationException("Received a successful response with an unexpected content type.");
295388
}
389+
#endif
296390

297391
#if FUSION
298392
private async ValueTask<SourceResultDocument> ReadAsResultInternalAsync(string? charSet, CancellationToken ct)
@@ -462,6 +556,43 @@ private async ValueTask<OperationResult> ReadAsResultInternalAsync(string? charS
462556
/// <see cref="HttpResponseMessage"/>.
463557
/// </returns>
464558
public IAsyncEnumerable<SourceResultDocument> ReadAsResultStreamAsync()
559+
{
560+
if (!TryGetRawMediaTypeAndCharSet(out var mediaType, out var charSet))
561+
{
562+
_message.EnsureSuccessStatusCode();
563+
throw new InvalidOperationException("Received a successful response with an unexpected content type.");
564+
}
565+
566+
if (mediaType.Equals(ContentType.EventStream, StringComparison.OrdinalIgnoreCase))
567+
{
568+
return new SseReader(_message);
569+
}
570+
571+
if (mediaType.Equals(ContentType.GraphQLJsonLine, StringComparison.OrdinalIgnoreCase)
572+
|| mediaType.Equals(ContentType.JsonLine, StringComparison.OrdinalIgnoreCase))
573+
{
574+
return new JsonLinesReader(_message);
575+
}
576+
577+
// The server supports the newer graphql-response+json media type, and users are free
578+
// to use status codes.
579+
if (mediaType.Equals(ContentType.GraphQL, StringComparison.OrdinalIgnoreCase))
580+
{
581+
return new GraphQLHttpSingleResultEnumerable(
582+
ct => ReadAsResultInternalAsync(charSet, ct));
583+
}
584+
585+
_message.EnsureSuccessStatusCode();
586+
587+
// The server supports the older application/json media type, and the status code
588+
// is expected to be a 2xx for a valid GraphQL response.
589+
if (mediaType.Equals(ContentType.Json, StringComparison.OrdinalIgnoreCase))
590+
{
591+
return new JsonResultEnumerable(_message, charSet);
592+
}
593+
594+
throw new InvalidOperationException("Received a successful response with an unexpected content type.");
595+
}
465596
#else
466597
/// <summary>
467598
/// Reads the GraphQL response as a <see cref="IAsyncEnumerable{T}"/> of <see cref="OperationResult"/>.
@@ -472,7 +603,6 @@ public IAsyncEnumerable<SourceResultDocument> ReadAsResultStreamAsync()
472603
/// <see cref="HttpResponseMessage"/>.
473604
/// </returns>
474605
public IAsyncEnumerable<OperationResult> ReadAsResultStreamAsync()
475-
#endif
476606
{
477607
var contentType = _message.Content.Headers.ContentType;
478608

@@ -508,6 +638,7 @@ public IAsyncEnumerable<OperationResult> ReadAsResultStreamAsync()
508638

509639
throw new InvalidOperationException("Received a successful response with an unexpected content type.");
510640
}
641+
#endif
511642

512643
/// <summary>
513644
/// Disposes the underlying <see cref="HttpResponseMessage"/>.

src/HotChocolate/AspNetCore/src/Transport.Http/OnHttpRequestMessageCreated.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#if FUSION
2+
using HotChocolate.Fusion.Execution.Clients;
3+
24
namespace HotChocolate.Fusion.Transport.Http;
35
#else
46
namespace HotChocolate.Transport.Http;
@@ -10,4 +12,8 @@ namespace HotChocolate.Transport.Http;
1012
public delegate void OnHttpRequestMessageCreated(
1113
GraphQLHttpRequest request,
1214
HttpRequestMessage requestMessage,
15+
#if FUSION
16+
RequestCallbackState state);
17+
#else
1318
object? state);
19+
#endif

src/HotChocolate/AspNetCore/src/Transport.Http/OnHttpResponseMessageReceived.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#if FUSION
2+
using HotChocolate.Fusion.Execution.Clients;
3+
24
namespace HotChocolate.Fusion.Transport.Http;
35
#else
46
namespace HotChocolate.Transport.Http;
@@ -10,4 +12,8 @@ namespace HotChocolate.Transport.Http;
1012
public delegate void OnHttpResponseMessageReceived(
1113
GraphQLHttpRequest request,
1214
HttpResponseMessage responseMessage,
15+
#if FUSION
16+
RequestCallbackState state);
17+
#else
1318
object? state);
19+
#endif
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using HotChocolate.Fusion.Execution.Nodes;
2+
3+
namespace HotChocolate.Fusion.Execution.Clients;
4+
5+
/// <summary>
6+
/// Carries the context needed by the transport-level request hooks
7+
/// (<see cref="SourceSchemaHttpClientConfiguration.OnBeforeSend"/> and
8+
/// <see cref="SourceSchemaHttpClientConfiguration.OnAfterReceive"/>).
9+
/// Stored on <see cref="Transport.Http.GraphQLHttpRequest.State"/>
10+
/// so the static hook delegates can access it without capturing.
11+
/// </summary>
12+
public readonly struct RequestCallbackState
13+
{
14+
public RequestCallbackState(
15+
OperationPlanContext context,
16+
ExecutionNode node,
17+
SourceSchemaHttpClientConfiguration configuration)
18+
{
19+
Context = context;
20+
Node = node;
21+
Configuration = configuration;
22+
}
23+
24+
public OperationPlanContext Context { get; }
25+
26+
public ExecutionNode Node { get; }
27+
28+
public SourceSchemaHttpClientConfiguration Configuration { get; }
29+
}

0 commit comments

Comments
 (0)