graphql-platform icon indicating copy to clipboard operation
graphql-platform copied to clipboard

Strawberry Shake request context data.

Open michaelstaib opened this issue 4 years ago • 24 comments

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.

michaelstaib avatar Apr 09 '21 14:04 michaelstaib

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?

jorrit avatar May 28 '21 14:05 jorrit

Why do you not use the HttpClientFactory?

michaelstaib avatar Jun 15 '21 11:06 michaelstaib

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.

jorrit avatar Jun 17 '21 06:06 jorrit

But to your question, this proposal would allow you to do that.

michaelstaib avatar Jun 17 '21 22:06 michaelstaib

@jorrit do you have a snippet on how you used the AsyncLocal? Is it used within the .ConfigureHttpClient of the IHttpClientFactory? Thanks!

sberube avatar Oct 12 '21 14:10 sberube

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);

jorrit avatar Oct 13 '21 07:10 jorrit

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.

Euphoric avatar Jan 06 '22 14:01 Euphoric

I still have a need for this even though the stale bot doesn't think so.

JarrydVanHoy avatar May 11 '22 03:05 JarrydVanHoy

Hello,

Same boat here, same problem as in #3533. No apparent good way to obtain a token from a scoped service.

Any suggestions?

David-Moreira avatar Nov 10 '22 11:11 David-Moreira

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.

leniency avatar Apr 18 '23 04:04 leniency

as I commented in https://github.com/ChilliCream/graphql-platform/issues/6426 I would also appreciate the ability to specify custom headers per request.

danny-zegel-zocdoc avatar Aug 28 '23 12:08 danny-zegel-zocdoc

as I commented in #6426 I would also appreciate the ability to specify custom headers per request.

I need that as well !

sabbadino avatar Jan 11 '24 17:01 sabbadino

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 avatar Feb 24 '24 02:02 Socolin

@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?

ayuksekkaya avatar Jan 12 '25 22:01 ayuksekkaya

@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 avatar Jan 13 '25 00:01 Socolin

@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.

jarvisdevvv avatar Feb 06 '25 21:02 jarvisdevvv

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.

Socolin avatar Feb 07 '25 23:02 Socolin

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.

valentinmarinro avatar Mar 22 '25 05:03 valentinmarinro

Please try StrawberryShake v15.1.5 #8296

sunghwan2789 avatar May 21 '25 04:05 sunghwan2789

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:

  • CreateHttpRequest
  • CreateClient
  • CreateResponse

CreateResponse for instance is helpful if you want to map response headers back to the GraphQL response that the enduser is able to intercept.

michaelstaib avatar May 22 '25 11:05 michaelstaib

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 avatar May 23 '25 05:05 ChaosHelme

@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;
    }
}

michaelstaib avatar May 23 '25 06:05 michaelstaib

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?

ChaosHelme avatar May 23 '25 11:05 ChaosHelme

Thats a bug I can reproduce it. I will issue a patch for this tonight.

michaelstaib avatar May 23 '25 11:05 michaelstaib

hello, any releases with the fix ? or workaround on how to add custom headers ? thanks

lghinet avatar Jun 20 '25 08:06 lghinet

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.

matt-thomson avatar Jul 14 '25 14:07 matt-thomson

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

tenglongroy avatar Jul 21 '25 06:07 tenglongroy

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>();

borrmann avatar Jul 28 '25 01:07 borrmann