How-to guide

Call another GraphQL server

Source Trax.Api.GraphQL.Client/, Trax.Api.GraphQL.Client.Trax/, Trax.Api.GraphQL.Client.Typed/

Trax.Api.GraphQL.Client sends hand-written queries to a GraphQL endpoint and checks each one against the server's schema before it goes out. It works in any .NET application; nothing in it needs trains, the mediator or a database. Why it validates instead of generating code is in How the GraphQL client checks queries.

1. Install the packages

dotnet add package Trax.Api.GraphQL.Client
dotnet add package Trax.Api.GraphQL.Client.Trax    # optional: UseAssemblySchema, UseStartupValidation
dotnet add package Trax.Api.GraphQL.Client.Typed   # optional: queries generated from a POCO

2. Write a request

A request is a class implementing IGraphQLClientRequest<TResponse>: the query text, its variables, and the type the result field is read into.

using Trax.Api.GraphQL.Client;
 
public sealed record PlayerProfile(string Id, string Name, int Level);
 
public sealed class GetPlayerRequest : IGraphQLClientRequest<PlayerProfile>
{
    public required string Id { get; init; }
 
    public string Query =>
        """
        query GetPlayer($id: String!) {
          player(id: $id) { id name level }
        }
        """;
 
    public object Variables => new { id = Id };
}

A document holds exactly one operation; fragments may come before or after it. Two other ways to write the same request:

  • Put the query in a .graphql file compiled as an EmbeddedResource and derive from GraphQLResourceRequest, which also describes how the file is found.
  • Derive from TypedRequest<TResponse> (in .Typed) and let the query be generated from the response type's properties. See Typed requests.

Nested envelopes (Path)

A Trax server puts a namespaced train's field under discover { <namespace> { ... } }. A typed request reaches it with Path = "discover.<namespace>" on [GraphQLOperation]; see GraphQLOperation properties.

3. Register the client and pick a schema source

using Trax.Api.GraphQL.Client;
 
builder
    .Services.AddTraxGraphQLClient(new Uri("https://players.example.com/graphql"))
    .UseFileSchema("schemas/players.graphql");

Schema providers

Choose where the schema comes from:

CallUse it when
none, or UseIntrospection()The endpoint answers introspection from this caller. A Trax server does so only in Development, unless its host admits the caller through AllowIntrospection.
UseFileSchema(path)You check in an SDL snapshot of the server. The usual choice against a production Trax server.
UseAssemblySchema(configure) (.Trax)The server's HotChocolate configuration is in a .NET assembly you can reference.

The calls are on TraxGraphQLClientBuilder.

4. Add authentication and timeouts

The client sends through a named HttpClient from IHttpClientFactory. Configure it through the builder's HttpClientBuilder as you would any named client:

using Trax.Api.GraphQL.Client;
 
builder
    .Services.AddTraxGraphQLClient(new Uri("https://players.example.com/graphql"))
    .UseFileSchema("schemas/players.graphql")
    .HttpClientBuilder.AddHttpMessageHandler<BearerTokenHandler>();

BearerTokenHandler stands for your own DelegatingHandler, registered in the container. A timeout goes on the same IHttpClientBuilder, with ConfigureHttpClient(http => http.Timeout = TimeSpan.FromSeconds(30)). Nothing is retried by default, because every request is a POST and a mutation is not safe to repeat. If you add AddStandardResilienceHandler() from Microsoft.Extensions.Http.Resilience, call DisableForUnsafeHttpMethods() on its retry options, or add it only to clients that send queries.

5. Validate requests at startup

With .Trax, chain UseStartupValidation and the host refuses to start when a request no longer matches the schema:

using Trax.Api.GraphQL.Client;
using Trax.Api.GraphQL.Client.Trax;
 
builder
    .Services.AddTraxGraphQLClient(new Uri("https://players.example.com/graphql"))
    .UseFileSchema("schemas/players.graphql")
    .UseStartupValidation(typeof(Program).Assembly);

Without .Trax, call it yourself after the app is built, or from a test:

using Trax.Api.GraphQL.Client;
 
var app = builder.Build();
await app.Services.ValidateGraphQLClientAssembliesAsync(typeof(Program).Assembly);

Which request types each call checks, and what it throws, is on ValidateGraphQLClientAssembliesAsync.

6. Run a request

Inject IGraphQLClientExecutor and pass it the request:

using Trax.Api.GraphQL.Client;
 
public sealed class PlayerLookup(IGraphQLClientExecutor graphql)
{
    public Task<PlayerProfile> Find(string id, CancellationToken cancellationToken) =>
        graphql.Run(new GetPlayerRequest { Id = id }, cancellationToken);
}

A query the schema rejects throws GraphQLValidationException before any HTTP call. The other exceptions Run throws are listed on IGraphQLClientRequest.

7. Call several servers

Register each server under a key with AddKeyedTraxGraphQLClient, and mark each request with the key of the client it belongs to, so each client validates only its own requests:

using Trax.Api.GraphQL.Client;
using Trax.Api.GraphQL.Client.Trax;
 
builder
    .Services.AddKeyedTraxGraphQLClient("billing", new Uri("https://billing.example.com/graphql"))
    .UseFileSchema("schemas/billing.graphql")
    .UseStartupValidation(typeof(Invoice).Assembly);
 
builder
    .Services.AddKeyedTraxGraphQLClient("shipping", new Uri("https://shipping.example.com/graphql"))
    .UseFileSchema("schemas/shipping.graphql")
    .UseStartupValidation(typeof(Invoice).Assembly);
using Trax.Api.GraphQL.Client;
 
public sealed record Invoice(string Id, int TotalCents);
 
[GraphQLClient("billing")]
public sealed class GetInvoiceRequest : IGraphQLClientRequest<Invoice>
{
    public required string Id { get; init; }
 
    public string Query => "query GetInvoice($id: String!) { invoice(id: $id) { id totalCents } }";
 
    public object Variables => new { id = Id };
}
 
public sealed class InvoiceLookup([FromKeyedServices("billing")] IGraphQLClientExecutor billing)
{
    public Task<Invoice> Find(string id, CancellationToken cancellationToken) =>
        billing.Run(new GetInvoiceRequest { Id = id }, cancellationToken);
}

Each key gets its own configuration, HttpClient, schema provider and validator cache. A second unkeyed AddTraxGraphQLClient call replaces the first rather than adding a client. Validation refuses to start when a client finds no request marked for it, or when a request is marked with a key no client is registered under; see AddKeyedTraxGraphQLClient.

8. Catch response drift

To fail when the response and the response type disagree:

using Trax.Api.GraphQL.Client;
 
builder
    .Services.AddTraxGraphQLClient(new Uri("https://players.example.com/graphql"))
    .WithStrictness(ResponseStrictness.ThrowOnDrift);

WarnOnDrift logs instead of throwing. What is compared is on ResponseStrictness.

9. Point the client at a test server

In an end-to-end test, send through the HttpClient a WebApplicationFactory creates:

services
    .AddTraxGraphQLClient(new Uri("http://localhost/trax/graphql"))
    .ConfigureHttpClient(factory.CreateClient());

Requests still go to the address passed to AddTraxGraphQLClient, whatever the HttpClient's BaseAddress. The GraphQL Client sample does this in its E2E suite, against two Trax servers with a keyed client each.

SDK Reference

AddTraxGraphQLClient | AddKeyedTraxGraphQLClient | TraxGraphQLClientBuilder | ValidateGraphQLClientAssembliesAsync | IGraphQLClientRequest | GraphQLResourceRequest | ResponseStrictness | Typed requests