graphql-platform
graphql-platform copied to clipboard
Strawberry Shake request context data.
Transport Context Data
In some cases, we want to pass in extra information with a GraphQL request that can be used in the client middleware to enrich the request.
Since Strawberry Shake supports multiple transports we want to do it in a way that does not bind this to a specific transport technology.
Context Directives
This is where context directives come in that we can use on operation definitions.
Let's say we have the following query request where we want to pass along some extra context-data that we can use in the request pipeline to either enrich the transport or even to enrich local processing.
query GetSessions {
sessions {
nodes {
... SessionInfo
}
}
}
fragment SessionInfo on Session {
id
title
}
In our example, we want to pass in an object that shall be used to create request headers when this request is executed over HTTP.
For this we will introduce a directive and an input type in the schema.extensions.graphql.
directive @myCustomHeaders(input: MyCustomHeaders!) on QUERY
input MyCustomHeaders {
someProp: String!
}
This new directive can now be used on queries and allows us to tell the Strawberry Shake compiler to generate the C# request in a way that we need to pass in the extra information.
query GetSessions($headers: MyCustomHeaders!) @myCustomHeaders(input: $headers) {
sessions {
nodes {
... SessionInfo
}
}
}
fragment SessionInfo on Session {
id
title
}
This will result in the generation of a required new parameter on the client.
await conferenceClient.GetSessions.ExecuteAsync(new MyCustomHeaders { SomeProp = "Hello" });
This proposal is dependant on work to make the middleware accessible by the user.
I need to pass an Accept-Language header to translate the response. Right now I can't find a mechanism to pass the value for this header when invoking the operation. Would this proposal allow me to do that?
Why do you not use the HttpClientFactory?
I could, but it is cumbersome to pass data from the caller of the ExecuteAsync method to the ConfigureHttpClient method, as there is no opportunity to pass context. I now use a static AsyncLocal variable, but I think that that is not pretty.
In the previous version of StrawBerryShake there was a partial parameter to each operation method that I could extend. Also, it had support for middleware that could access this operation parameter and read my extensions.
Anyway, AsyncLocal is sufficient for now, but the previous solution was more elegant.
But to your question, this proposal would allow you to do that.
@jorrit do you have a snippet on how you used the AsyncLocal? Is it used within the .ConfigureHttpClient of the IHttpClientFactory? Thanks!
I have this utility class:
using StrawberryShake;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
#nullable enable
namespace ANWBReizen.Travelhome.API.ApiClients.Product.Client
{
public static class LanguageOperation
{
private static readonly AsyncLocal<string> Store = new AsyncLocal<string>();
private static readonly string[] Languages = new[] { "en", "nl" };
public static string? Value => Store.Value;
public static async Task<IOperationResult<TResultData>> Run<TResultData>(string language, Func<CancellationToken, Task<IOperationResult<TResultData>>> implementation)
where TResultData : class
{
Store.Value = language;
return await implementation(CancellationToken.None);
}
public static async Task<IDictionary<string, TResultData>> RunAll<TResultData>(Func<CancellationToken, Task<IOperationResult<TResultData>>> implementation)
where TResultData : class
{
var tasks = Languages.ToDictionary(lang => lang, lang => Run(lang, implementation));
await Task.WhenAll(tasks.Values);
return tasks.ToDictionary(t => t.Key, t =>
{
var result = t.Value.Result;
result.EnsureNoErrors();
if (result.Data == null)
{
throw new Exception("No data");
}
return result.Data;
});
}
}
}
In Startup.cs I have:
var builder = services.AddProductApi()
.ConfigureHttpClient((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<ProductOptions>>();
client.BaseAddress = options.Value.Url;
client.Timeout = timeout;
var language = LanguageOperation.Value;
if (language != null)
{
client.DefaultRequestHeaders.Add("Accept-Language", language);
}
});
I call it like this:
var results = await LanguageOperation.RunAll(_apiClient.VehicleFacets.ExecuteAsync);
Missing ability to set per-request HTTP headers is deal-breaker for me using Strawbery Shake. I think it is such basic feature I'm bafled how could it ship without it. And the workaround using AsyncLocal is iffy at best.
I still have a need for this even though the stale bot doesn't think so.
Hello,
Same boat here, same problem as in #3533. No apparent good way to obtain a token from a scoped service.
Any suggestions?
This would be very useful in multi-tenant situations where the client reads from multiple instances of the same service, but different access tokens and base url (ie, the Canvas LMS API) for each individual request.
as I commented in https://github.com/ChilliCream/graphql-platform/issues/6426 I would also appreciate the ability to specify custom headers per request.
as I commented in #6426 I would also appreciate the ability to specify custom headers per request.
I need that as well !
Hello,
I'm also interested in this, I need it the context of a Shopify App where multiple shop are managed and each one have a different access token.
I found a workaround if anyone need this until something better is added in Strawberry Shake, it's based on the previous work here https://github.com/ChilliCream/graphql-platform/issues/6446#issuecomment-1681161634
Using AsyncLocal we can pass some context through multiple calls (kind of like if we added a new parameter)
So first define a context to store the addtional data you need
public record MyCustomContext(string CustomUrlPart, string CustomHeaderValue);
Then create a context accessor (same as IHttpContextAccessor in ASP.NET Core)
public interface IMyCustomContextAccessor
{
MyCustomContext Current { get; set; }
}
public class MyCustomContextAccessor : IMyCustomContextAccessor
{
public static readonly AsyncLocal<MyCustomContext> Context = new();
public MyCustomContext Current
{
get => Context.Value ?? throw new Exception("Call `IMyCustomContextAccessor.Current = ` before calling .ExecuteAsync()");
set => Context.Value = value;
}
}
public class CustomHeaderGraphQlDelegationHandler(IMyCustomContextAccessor MyCustomContextAccessor) : DelegatingHandler
{
private void ConfigureCustomHeader(HttpRequestMessage request)
{
request.RequestUri = new Uri($"https://{MyCustomContextAccessor.Current.CustomUrlPart}/graphql.json");
request.Headers.Add("X-My-Custom-Header", MyCustomContextAccessor.Current.CustomHeaderValue);
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
ConfigureCustomHeader(request);
return await base.SendAsync(request, cancellationToken);
}
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
{
ConfigureCustomHeader(request);
return base.Send(request, cancellationToken);
}
}
Register everything
services.AddSingleton<IMyCustomContextAccessor, MyCustomContextAccessor>();
services.AddTransient<CustomHeaderGraphQlDelegationHandler>();
services.AddShopifyGraphQlClient().ConfigureHttpClient(
client => client.BaseAddress = new Uri("https://ignored.com"), // This is just a random URL which will be overwritten by the handler
builder => builder.AddHttpMessageHandler<CustomHeaderGraphQlDelegationHandler>());
Then you just need to inject IMyCustomContextAccessor and then call ExecuteAsync().
private readonly IMyCustomContextAccessor accessor;
...
public Task DoSomething()
{
accessor.Current = new ...
client.Xxxx.ExecuteAsync()
}
You can also add an extension method to simplify usage
public static class OperationRequestFactoryExtensions
{
public static Task<TResult> ExecuteAsAsync<T, TResult>(
this T factory,
MyCustomContext auth,
Func<T, Task<TResult>> execute
) where T : IOperationRequestFactory
{
MyCustomContextAccessor.Context.Value = auth;
return execute(factory);
}
}
and then
var authContext = new MyCustomContext("abc", "def");
var result = await client.SomeMutation.ExecuteAsAsync(authContext, f => f.ExecuteAsync(new object(), cancellationToken));
@Socolin, I am trying your solution, however I keep getting "The 'InnerHandler' property must be null. 'DelegatingHandler' instances provided to 'HttpMessageHandlerBuilder' must not be reused or cached"
After some googling, I saw that we should register the HttpMessageHandler as transient. However, this doesn't work with the singleton registration of the strawberry-generated client. Did you face this issue? Is there a solution to this now?
@Socolin, I am trying your solution, however I keep getting "The 'InnerHandler' property must be null. 'DelegatingHandler' instances provided to 'HttpMessageHandlerBuilder' must not be reused or cached"
After some googling, I saw that we should register the HttpMessageHandler as transient. However, this doesn't work with the singleton registration of the strawberry-generated client. Did you face this issue? Is there a solution to this now?
I just checked, in my code the handler is registered as Transient (I will update my message) the Accessor is registered as singleton.
Even if the HttpClient is a Singleton, it will create handler for each request dynamically https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientBuilderExtensions.cs#L81
In the ClientFactory it create a new scope each time: https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpClientFactory.cs#L139 https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpClientFactory.cs#L172
What error do you have with the Handler as Transient ?
@Socolin, I am trying your solution, however I keep getting "The 'InnerHandler' property must be null. 'DelegatingHandler' instances provided to 'HttpMessageHandlerBuilder' must not be reused or cached" After some googling, I saw that we should register the HttpMessageHandler as transient. However, this doesn't work with the singleton registration of the strawberry-generated client. Did you face this issue? Is there a solution to this now?
I just checked, in my code the handler is registered as Transient (I will update my message) the Accessor is registered as singleton.
Even if the HttpClient is a Singleton, it will create handler for each request dynamically https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientBuilderExtensions.cs#L81
In the ClientFactory it create a new scope each time: https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpClientFactory.cs#L139 https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Http/src/DefaultHttpClientFactory.cs#L172
What error do you have with the Handler as Transient ?
@Socolin Accessor is registered as singleton, wouldn't that be a problem that those requests could modify the single instance simultaneously and the corresponding value gets messed up upon calling ExecuteAsync method.
No, the magic is around AsyncLocal https://learn.microsoft.com/en-us/dotnet/api/system.threading.asynclocal-1?view=net-9.0
If you check this variable is static anyway in the Accessor. Behind the variable is in the task context
Technically the injection is just here for mocking in the unit test you could directly access the static variable.
hey @michaelstaib, we also need this functionality, and without it, we can't use the StrawberryShake Client SDK in our project (we are using the server). Adding this feature would make a big difference and let us use the SDK. We mainly need to pass extra query string parameters per requests, but we can adapt our code to use headers as well.
Please try StrawberryShake v15.1.5 #8296
Yes, this is now implemented. However we did not go down the path of directives for this yet. The way it works is with a new with API.
var result = await query
.WithRequestUri(new Uri("https://localhost:5001/graphql"))
.WithHttpClient(new HttpClient())
.ExecuteAsync();
On the query you can manipulate the internal StrawberryShake request and enrich it with context.
var result = await query
.With(r => r.ContextData["foo"] = "bar")
.ExecuteAsync();
Ideally you create some extension methods like we did for WithRequestUri or WithHttpClient.
On the transport side you can now override the transport which for HTTP requests is the HttpConnection, if we for instance wanted to pick up foo and make it a header this would look like the following:
public class CustomHttpConnection : HttpConnection
{
public CustomHttpConnection(
Func<HttpClient> createClient)
: base(createClient)
{
}
public CustomHttpConnection(
Func<OperationRequest, object?,
HttpClient> clientFactory,
object? clientFactoryState = null)
: base(clientFactory, clientFactoryState)
{
}
protected override GraphQLHttpRequest CreateHttpRequest(OperationRequest request)
{
var graphQLHttpRequest = base.CreateHttpRequest(request);
if (request.ContextData.TryGetValue("Foo", out var value))
{
graphQLHttpRequest.OnMessageCreated =
(_, message) => message.Headers.Add("foo", value.ToString());
}
return graphQLHttpRequest;
}
}
In the HttpConnection we have three extension points that you can override to change the execution flow:
CreateHttpRequestCreateClientCreateResponse
CreateResponse for instance is helpful if you want to map response headers back to the GraphQL response that the enduser is able to intercept.
Hey @michaelstaib thanks for the update, it came at the perfect time 😄.
From your description, I'm only missing the point where I would register the CustomHttpConnection. Could you please shed some light on that?
@ChaosHelme here is how you can do it:
services
.AddConferenceClient()
.AddCustomHttpConnection();
public static class ClientBuilderExtensions
{
public static StrawberryShake.IClientBuilder<ConferenceClientStoreAccessor> AddCustomHttpConnection(
this StrawberryShake.IClientBuilder<ConferenceClientStoreAccessor> builder)
{
// First we remove the default HttpConnection.
builder.ClientServices.RemoveAll<IHttpConnection>();
// Then we add our custom HttpConnection.
builder.ClientServices.AddSingleton<IHttpConnection>(sp =>
{
var clientFactory = sp.GetRequiredService<IHttpClientFactory>();
return new CustomHttpConnection(() => clientFactory.CreateClient("ConferenceClient"));
});
return builder;
}
}
Hey @michaelstaib - I'm not sure if I'm doing something wrong. I'm using Strawberry Shake Client in my integration tests to test my GraphQL service.
For this, I'm using the WebApplicationFactory like this:
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
public IBookstoreClient CreateBookstoreClient()
{
var serviceCollection = new ServiceCollection();
serviceCollection
.AddBookstoreClient()
.AddCustomHttpConnection()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri(Server.BaseAddress, "graphql");
},
c =>
{
c.ConfigurePrimaryHttpMessageHandler(() => Server.CreateHandler());
});
return serviceCollection
.BuildServiceProvider()
.GetRequiredService<IBookstoreClient>();
}
}
My test driver class uses the generated strawberry client like this:
public class BookQueriesDriver(CustomWebApplicationFactory factory)
{
private StrawberryShake.IOperationResult<IAllBooksIncludingAllFieldsResult>? _allBooksResult;
private readonly IBookstoreClient _client = factory.CreateBookstoreClient();
public async Task QueryAllBooksIncludingAllFields()
{
_allBooksResult = await _client.AllBooksIncludingAllFields.With(d => d.ContextData["Accept-Language"] = "de").ExecuteAsync();
}
public void ValidateBooksWithAllFields(List<IAllBooksIncludingAllFields_Books_Items_Book> expectedBooks)
{
_allBooksResult.Should().NotBeNull();
_allBooksResult.Errors.Should().BeEmpty();
_allBooksResult.Data.Should().NotBeNull();
_allBooksResult.Data.Books.Should().NotBeNull();
_allBooksResult.Data.Books.Items.Should().BeEquivalentTo(expectedBooks);
}
}
So far, so good. The AddCustomHttpConnection() extension method registers my CustomHttpConnection
public static StrawberryShake.IClientBuilder<BookstoreClientStoreAccessor> AddCustomHttpConnection(
this StrawberryShake.IClientBuilder<BookstoreClientStoreAccessor> builder)
{
// Here, there is actually no IHttpConnection registered, because the generated code for creating the client did not yet run
builder.ClientServices.RemoveAll<IHttpConnection>();
// Then we add our custom HttpConnection.
builder.ClientServices.AddSingleton<IHttpConnection>(sp =>
{
var clientFactory = sp.GetRequiredService<IHttpClientFactory>();
return new CustomHttpConnection(() => clientFactory.CreateClient("BookstoreClient"));
});
return builder;
}
What confues me is the fact, that this code is getting executed before the generated code in AddBookstoreClient where the HttpConnection is configured in ConfigureClient(sp, serviceCollection, strategy).
This results in my CustomHttpConnection never being used, because ConfigureClient confirues the standard HttpConnection.
Am I missing something?
Thats a bug I can reproduce it. I will issue a patch for this tonight.
hello, any releases with the fix ? or workaround on how to add custom headers ? thanks
Hi, any news on this one? I'd like to be able to set a custom authorization header per-request and I'm having the same issue where my custom HTTP connection isn't being instantiated.
Spent a full day adopting the approach from @michaelstaib using 15.1.7 but failed. Similar to what @ChaosHelme mentioned above, the CustomHttpConnection is not reached.
Will come back and check out 15.1.8 later as it is now slowly being released https://github.com/ChilliCream/graphql-platform/releases/tag/15.1.8-p.1
I am using a workaround like this which uses IHttpContextAccessor for user initiated requests (safe) and AsyncLocal for backgroundjobs. For the background jobs I then need to make sure they dont run in parallel and use proper SetToken/ Clear as in SignIn/ SignOut actions to ensure a token is never reused across requests. Not great but it works for my use case.
public interface ITokenResolver
{
void SetToken(string token);
string? GetToken();
void Clear();
}
// Hybrid resolver: HttpContext when available, AsyncLocal otherwise
public sealed class AmbientTokenResolver(IHttpContextAccessor accessor) : ITokenResolver
{
private const string Key = "GraphUserToken";
private static readonly AsyncLocal<string?> _ambient = new();
private readonly IHttpContextAccessor _accessor = accessor;
public void SetToken(string token)
{
if (string.IsNullOrEmpty(token)) throw new ArgumentException("Token is required", nameof(token));
var ctx = _accessor.HttpContext;
if (ctx is not null)
{
ctx.Items[Key] = token; // per-web-request storage
return; // best to not allow AsyncLocal if IHttpContextAccessor is available
}
_ambient.Value = token;
}
public string? GetToken()
{
var ctx = _accessor.HttpContext;
return (ctx?.Items[Key] as string) ?? _ambient.Value;
}
public void Clear()
{
var ctx = _accessor.HttpContext;
ctx?.Items.Remove(Key);
_ambient.Value = null;
}
}
public class TokenHandler(ILogger<TokenHandler> logger, ITokenResolver tokenResolver) : DelegatingHandler
{
private readonly ILogger<TokenHandler> _logger = logger;
private readonly ITokenResolver _resolver = tokenResolver;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
string? token = _resolver.GetToken();
if (string.IsNullOrEmpty(token))
{
throw new ArgumentException("unauthorized request, no token set", nameof(token));
}
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, ct);
}
}
and then register the client like this and using _resolver.SetToken once per request.
services.AddGraphClient();
services.AddHttpClient(GraphClient.GraphClient.ClientName, c =>
{
c.BaseAddress = new Uri("https://...../graphql");
}).AddHttpMessageHandler<TokenHandler>();