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.27k stars 748 forks source link

Ability to Add HttpMessageHandler for Better Client Behavior Adjustment #6446

Closed borissedov closed 1 year ago

borissedov commented 1 year ago

Product

Strawberry Shake

Is your feature request related to a problem?

Hello StrawberryShake maintainers and community,

While integrating StrawberryShake into an ASP.NET Core project, I encountered a limitation in the current client configuration system. Specifically, I found it challenging to adjust the client's behavior based on the authenticated user's information. This adjustment often involves setting a specific URL and authentication headers.

Currently, StrawberryShake offers the ConfigureHttpClient mechanism. While this provides a level of configuration, it's cumbersome in scenarios where the HttpClient requires more dynamic behavior based on runtime data or other services.

Current Scenario:

// In program.cs
builder.Services.AddShopifyAdminGraphQLClient()
    .ConfigureHttpClient((serviceProvider, client) =>
    {
        using var scope = serviceProvider.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<WorkContext>();
        var currentStore = context.GetCurrentStoreAsync().Result;
        client.BaseAddress = new Uri($"https://{currentStore.Shop}/admin/api/2023-07/graphql.json");
        client.DefaultRequestHeaders.Add("X-Shopify-Access-Token", currentStore.AccessToken);
    });

The solution you'd like

I propose adding support for HttpMessageHandler in StrawberryShake's client configuration. This would offer developers a straightforward way to inject services and adjust the client's behavior dynamically.

public class ShopifyGraphQLClientHandler : DelegatingHandler
{
    private readonly WorkContext _workContext;

    public ShopifyGraphQLClientHandler(WorkContext workContext)
    {
        _workContext = workContext;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var currentStore = await _workContext.GetCurrentStoreAsync();
        request.RequestUri = new Uri($"https://{currentStore.Shop}/admin/api/2023-07/graphql.json");
        request.Headers.Add("X-Shopify-Access-Token", currentStore.AccessToken);

        return await base.SendAsync(request, cancellationToken);
    }
}
// In program.cs
builder.Services.AddTransient<ShopifyGraphQLClientHandler>();
builder.Services.AddShopifyAdminGraphQLClient()
    .AddHttpMessageHandler<ShopifyGraphQLClientHandler>();

Benefits:

  1. Dynamic Configuration: With HttpMessageHandler, developers can change the client's behavior based on runtime data, authenticated user information, and other contextual details.
  2. Service Injection: HttpMessageHandler allows developers to inject and utilize other services seamlessly, enabling more complex setups and adjustments.
  3. Consistency with ASP.NET Core Patterns: Utilizing HttpMessageHandler aligns with standard ASP.NET Core patterns, making StrawberryShake's integration more intuitive for developers familiar with this pattern.

I believe this enhancement will make StrawberryShake more flexible and even more powerful in various scenarios. Looking forward to your thoughts on this proposal.

Thank you for your hard work on StrawberryShake, and I appreciate your consideration of this request.

michaelstaib commented 1 year ago

This already works Today the configure method is just a helper you can just do it all on your own with AddHttpClient. Further the ConfigureHttpClient has another overload.

// In program.cs
builder.Services.AddShopifyAdminGraphQLClient()
    .ConfigureHttpClient((serviceProvider, client, clientBuilder) =>
    {
        using var scope = serviceProvider.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<WorkContext>();
        var currentStore = context.GetCurrentStoreAsync().Result;
        client.BaseAddress = new Uri($"https://{currentStore.Shop}/admin/api/2023-07/graphql.json");
        client.DefaultRequestHeaders.Add("X-Shopify-Access-Token", currentStore.AccessToken);
        clientBuilder.AddHttpMessageHandler<ShopifyGraphQLClientHandler>();
    });

Is that what you were looking for?

borissedov commented 1 year ago

@michaelstaib Thanks for pointing that out.

I am currently utilizing the second overload, but it's proving to be a bit problematic. By creating a service scope, I'm encountering situations where some service members are being modified during the request execution. This alteration results in inconsistent data across different scopes, which obviously isn't ideal. It's a delicate balance, and I'm trying to find the sweet spot to ensure reliable behavior without adding unnecessary complexity.

If you have any further insights or suggestions on how to effectively tackle this, I'd greatly appreciate it!

borissedov commented 1 year ago

Sorry for closing accidentally.

I did try out the approach to use the generated client name with the standard AddHttpClient method:

serviceCollection.AddTransient<ShopifyGraphQLClientHandler>();
serviceCollection.AddHttpClient(ShopifyAdminGraphQLClient.ClientName) 
    .AddHttpMessageHandler<ShopifyGraphQLClientHandler>();

However, I ran into issues where the custom message handler wasn't being invoked as expected, and in some instances, the client wasn't able to properly resolve dependencies.

To explore potential workarounds, I attempted a more embedded approach:

public static IClientBuilder<ShopifyAdminGraphQLClientStoreAccessor> AddCustomShopifyAdminGraphQLClient(this IServiceCollection services, ExecutionStrategy strategy = ExecutionStrategy.NetworkOnly)
{
    // Call the original method
    var clientBuilder = services.AddShopifyAdminGraphQLClient(strategy);

    // Assuming you have a method to get the internal service collection from the clientBuilder
    var internalServiceCollection = clientBuilder.ClientServices;

    // Now add your custom configurations to the internal service collection
    internalServiceCollection.AddTransient<ShopifyGraphQLClientHandler>();
    internalServiceCollection.AddHttpClient(ShopifyAdminGraphQLClient.ClientName) 
        .AddHttpMessageHandler<ShopifyGraphQLClientHandler>();

    internalServiceCollection.BuildServiceProvider();

    return clientBuilder;
}

However that leads to entire failure of the services registration flow inside the client service provider and it falls with the error: System.InvalidOperationException: No service for type '[System.Net](http://system.net/).Http.IHttpClientFactory' has been registered.

michaelstaib commented 1 year ago

This is not really an issue of strawberry shake as it has more todo with how you register the services.

why do internalServiceCollection.BuildServiceProvider(); and do nothing with it.

If you have questions stick to slack or GitHub discussions as this is geared toward enhancements and bugs.

michaelstaib commented 1 year ago

internalServiceCollection.AddTransient(); internalServiceCollection.AddHttpClient(ShopifyAdminGraphQLClient.ClientName) .AddHttpMessageHandler();

I would not use the client services to be honest as the HttpClient always sits on the outer scope. With version 14 we will switch to the new keyed services.

borissedov commented 1 year ago

I was finally able to achieve the desired behaviour with the following code:

//ShopifyGraphQLClientHandler.cs
public class ShopifyGraphQLClientHandler : DelegatingHandler
{
    private void ConfigureShopifyStoreRequest(HttpRequestMessage request)
    {
        var currentStore = new
        {
            Shop = "quickstart-my.myshopify.com",
            AccessToken = "my-access-token"
        };
        request.RequestUri = new Uri($"https://{currentStore.Shop}/admin/api/2023-07/graphql.json");
        request.Headers.Add("X-Shopify-Access-Token", currentStore.AccessToken);
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        ConfigureShopifyStoreRequest(request);
        return await base.SendAsync(request, cancellationToken);
    }

    protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        ConfigureShopifyStoreRequest(request);
        return base.Send(request, cancellationToken);
    }
}

//Program.cs
var serviceCollection = new ServiceCollection();

serviceCollection.AddTransient<ShopifyGraphQLClientHandler>();

serviceCollection.AddShopifyAdminGraphQLClient().ConfigureHttpClient(
    client =>
        client.BaseAddress = new Uri("https://www.microsoft.com"), // This is just a random URL which will be overwritten by the handler
    builder =>
        builder.AddHttpMessageHandler<ShopifyGraphQLClientHandler>());

IServiceProvider services = serviceCollection.BuildServiceProvider();

My mistake was to not set the BaseAddress in the ConfigureHttpClient so the HttpMessageHandler has never been invoked because of request validation error.