ChilliCream / graphql-platform

Welcome to the home of the Hot Chocolate GraphQL server for .NET, the Strawberry Shake GraphQL client for .NET and Banana Cake Pop the awesome Monaco based GraphQL IDE.
https://chillicream.com
MIT License
5.26k stars 746 forks source link

Strawberry Shake request context data. #3467

Open michaelstaib opened 3 years ago

michaelstaib commented 3 years ago

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.

jorrit commented 3 years ago

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?

michaelstaib commented 3 years ago

Why do you not use the HttpClientFactory?

jorrit commented 3 years ago

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.

michaelstaib commented 3 years ago

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

sberube commented 3 years ago

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

jorrit commented 3 years ago

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);
Euphoric commented 2 years ago

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.

JarrydVanHoy commented 2 years ago

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

David-Moreira commented 2 years ago

Hello,

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

Any suggestions?

leniency commented 1 year ago

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.

danny-zegel-zocdoc commented 1 year ago

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

sabbadino commented 10 months ago

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

I need that as well !

Socolin commented 9 months ago

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