Build with JTL

JTL ERP GraphQL SDK for .NET

Strongly typed GraphQL client and OAuth handler pipeline for ASP.NET Core. No public NuGet release for the PaCon hackathon — wire it up locally with the packages you've been handed.

Overview

The JTL ERP SDK ships as two libraries that work together. The GraphQL SDK (JTL.Platform.Graphql) is a compile-time, strongly typed GraphQL client built on ZeroQL. The Auth SDK (JTL.Platform.Auth) handles OAuth 2.0 client credentials, tenant ID injection and JWT session verification. The Auth SDK configures the HTTP client pipeline with delegating handlers that automatically inject bearer tokens and tenant headers into every API call.

NuGet setup

Add the following package references to your project:

<ItemGroup>
  <PackageReference Include="ZeroQL" />
  <PackageReference Include="ZeroQL.Runtime" />
  <PackageReference Include="JTL.Platform.Auth" />
</ItemGroup>

<ItemGroup>
  <!-- thats the name of the graphql assembly in your project -->
  <Reference Include="JTL.Platform.Graphql" />
</ItemGroup>

ZeroQL provides the source generator that compiles your query lambdas at build time. ZeroQL.Runtime is the runtime counterpart. JTL.Platform.Auth is on NuGet. JTL.Platform.Graphql is the generated client and lives in your own assembly (see the ZeroQL section below).

Program.cs wiring

The SDKs register services through a specific sequence of extension methods. Order matters because later steps depend on services registered by earlier ones.

using JTL.Platform.Auth.Context;
using JTL.Platform.Auth.Extensions;
using JTL.Platform.Auth.Handlers;
using JTL.Platform.Graphql;

var builder = WebApplication.CreateBuilder(args);

// Step 1: Register OAuth 2.0 authentication with client credentials.
// Must come first - the HTTP client and tenant context both depend on auth services.
// Read credentials from whatever source suits your app (configuration, dotnet
// user-secrets, env vars, etc.).
builder.Services.AddErpAuth(auth => {
    auth.ClientId     = builder.Configuration["CloudApp:ClientId"]     ?? throw new InvalidOperationException("CloudApp:ClientId not configured");
    auth.ClientSecret = builder.Configuration["CloudApp:ClientSecret"] ?? throw new InvalidOperationException("CloudApp:ClientSecret not configured");
    // auth.TokenUrl = ...; // Optional - a default token URL is used if omitted
    auth.RenewBeforeExpiry = TimeSpan.FromMinutes(5);
});

// Step 2: Register tenant context resolution.
// Configures how the tenant ID is extracted from incoming requests.
builder.Services.AddHttpContextTenantContext(options => {
    options.RouteParameterName = "tenantId";    // Route: /api/{tenantId}/...
    options.HeaderName         = "X-Tenant-ID"; // HTTP header: X-Tenant-ID
    options.ClaimType          = "tenant_id";   // JWT claim: tenant_id
    // Priority order: Route > Header > Claim
});

// Step 3: Register session verifier (optional).
// Validates JTL session JWT tokens. Must come after Step 1.
builder.Services.AddSessionVerifier();

// Step 4: Configure the named HttpClient with the authentication + tenant handler pipeline.
var clientName = "wawi-graphql-client";
builder.Services.AddHttpClient(clientName)
    .AddHttpMessageHandler<AccessTokenRetryHandler>()
    .AddHttpMessageHandler<BearerAuthenticationProvider>()
    .AddHttpMessageHandler<TenantIdHeaderHandler>();

// Step 5: Register the ZeroQL-generated GraphQL service as a singleton.
// Safe as a singleton because the tenant ID is resolved per-request through the handlers.
var graphqlApiUrl = builder.Configuration["Wawi:GraphQLApi"] ?? "https://api.jtl-cloud.com/erp/v2/graphql";
builder.Services.AddSingleton<JTLErpGraphqlService>(sp => {
    var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient(clientName);
    httpClient.BaseAddress = new Uri(graphqlApiUrl);
    return new JTLErpGraphqlService(httpClient);
});

var app = builder.Build();
app.Run();

Configuration

Store non-sensitive settings in appsettings.json. For credentials, use whichever secret store fits your environment: dotnet user-secrets for local development, environment variables for containers/CI, or a secrets manager (e.g. Azure Key Vault) for production.

appsettings.json (non-sensitive values)

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Wawi": {
    "GraphQLApi": "https://api.jtl-cloud.com/erp/v2/graphql"
  }
}

dotnet user-secrets (recommended for local development)

dotnet user-secrets set "CloudApp:ClientId"     "your-client-id"
dotnet user-secrets set "CloudApp:ClientSecret" "your-client-secret"

Environment variables (alternative, e.g. containers)

CloudApp__ClientId=your-client-id
CloudApp__ClientSecret=your-client-secret
ASP.NET Core's configuration system merges all sources automatically. IConfiguration reads the same keys regardless of whether they come from appsettings.json, user-secrets, or environment variables.

Tenant ID injection

The tenant ID identifies which JTL Wawi instance the API call targets. The Auth SDK injects it into every outgoing HTTP request via TenantIdHeaderHandler in the HTTP client pipeline.

How it works

  1. An incoming request arrives at your endpoint.
  2. HttpContextTenantContext resolves the tenant ID from the request (Route → Header → Claim).
  3. The tenant ID is stored in a scoped service for the duration of the request.
  4. When your code calls JTLErpGraphqlService, the TenantIdHeaderHandler injects the X-Tenant-ID header into the outgoing GraphQL request automatically.

Example: route parameter

app.MapGet("/api/{tenantId}/items/{id}", async (
    string tenantId,
    string id,
    JTLErpGraphqlService graphqlService,
    CancellationToken cancellationToken) => {

    // tenantId from the route is automatically picked up by the tenant context.
    // No need to pass it manually to the GraphQL call.
    var result = await graphqlService.Query(
        q => q.GetItemById(id: id, selector: item => new { item.Id, item.ProductGroupId }),
        cancellationToken: cancellationToken);

    return Results.Ok(result.Data);
});

Example: HTTP header

app.MapGet("/api/items/{id}", async (
    string id,
    JTLErpGraphqlService graphqlService,
    CancellationToken cancellationToken) => {

    // Tenant ID is read from the X-Tenant-ID header.
    var result = await graphqlService.Query(
        q => q.GetItemById(id: id, selector: item => new { item.Id, item.ProductGroupId }),
        cancellationToken: cancellationToken);

    return Results.Ok(result.Data);
});
// Client request: GET /api/items/42
// Headers: X-Tenant-ID: 12345

Single-tenant applications

If your application serves only one tenant, replace AddHttpContextTenantContext with AddFixedTenantContext:

builder.Services.AddFixedTenantContext("your-fixed-tenant-id");

This removes per-request tenant resolution and is suitable for background workers or single-customer deployments.

ZeroQL: how it works

ZeroQL is a compile-time, strongly typed GraphQL client for C#. Instead of sending raw query strings, you write LINQ-style lambda expressions that ZeroQL's Roslyn source generator compiles into GraphQL operations at build time.

Code generation

The schema and client settings are configured in a *.zeroql.json file in the project that owns the generated client:

{
  "$schema": "https://raw.githubusercontent.com/byme8/ZeroQL/main/schema.verified.json",
  "graphql": "path/to/schema.graphql",
  "namespace": "JTL.Platform.Graphql",
  "clientName": "JTLErpGraphqlService"
}

The ZeroQL CLI tool (pinned in dotnet-tools.json) generates the JTLErpGraphqlService client class from the schema. Run it once whenever the schema changes:

dotnet tool restore
zeroql generate --config graphqlapi.zeroql.json

The "not bootstrapped" exception

ZeroQL pre-compiles lambda expressions into a static lookup table during the build of the assembly that contains the source-generated client code. If you write a query lambda in a different assembly — one that was not compiled by the ZeroQL source generator against the same schema — ZeroQL cannot find the pre-compiled operation and throws a ZeroQLNotBootstrappedException at runtime.

All queries and mutations must therefore reside in the same assembly that owns the generated JTLErpGraphqlService client (i.e. the project with the *.zeroql.json config and the ZeroQL package reference).

Example

// Must live in the assembly compiled by the ZeroQL source generator
var result = await graphqlService.Query(
    q => q.GetItemById(
        id: "42",
        selector: item => new { item.Id, item.ProductGroupId }),
    cancellationToken: cancellationToken);

var item = result.Data;