b17s.Porta

GraphQL Backend Support

A REST facade over GraphQL backends

The library supports calling GraphQL backends while exposing REST endpoints to clients. This is a common BFF pattern - REST facade over GraphQL.

Basic Usage

public class ProductTransformer : TransformerBase<ProductDto>
{
    private const string GetProductQuery = """
        query GetProduct($id: ID!) {
            product(id: $id) {
                id
                name
                slug
                featuredAsset { preview }
                variants { id name price }
            }
        }
        """;

    public override async Task<ProductDto> TransformAsync(TransformerContext context)
    {
        var productId = GetRouteValue(context, "id");

        // The configured backend (from ToGraphQL(...)) is exposed on the context
        // under the "BackendRequest" property.
        var backendRequest = (BackendRequest)context.Properties["BackendRequest"];

        // CallGraphQLAsync handles:
        // - Wrapping query + variables into { "query": "...", "variables": {...} }
        // - Unwrapping response from { "data": { "product": {...} } }
        // - Extracting errors from GraphQL errors array
        var result = await context.BackendCaller.CallGraphQLAsync<Product>(
            backendRequest,
            GetProductQuery,
            variables: new { id = productId },
            dataPath: "product",  // extracts data.product
            cancellationToken: context.CancellationToken
        );

        if (!result.IsSuccess)
        {
            // Surfaces the GraphQL-mapped status (404, 401, 403, ...) verbatim. Use this rather
            // than WriteBackendErrorResponseAsync for GraphQL results: an application-level auth
            // error (UNAUTHENTICATED/FORBIDDEN over HTTP 200) is the user's, and must reach the
            // client as 401/403 instead of the 502 the backend writer applies.
            await WriteGraphQLErrorResponseAsync(context, result);
            return default!;
        }

        return MapToDto(result.Data);
    }
}

// Endpoint registration - client sees REST
app.MapTransformer<ProductTransformer, ProductDto>()
    .FromGet("/api/products/{id}")
    .ToGraphQL("https://graphql.internal/my-api")  // GraphQL endpoint
    .WithBackendAuth(BackendAuthPolicies.BearerToken)
    .Build();

Features

FeatureDescription
CallGraphQLAsync<T>()Wraps/unwraps GraphQL request/response
ToGraphQL(url)Builder method for GraphQL backend (sets POST, content-type)
dataPath parameterExtracts nested data (e.g., "product"data.product)
Error handlingGraphQL errors array → GraphQLResult.Error

Error Handling

GraphQL returns HTTP 200 even with errors. The library handles this:

// GraphQL response with errors
{
    "data": null,
    "errors": [
        { "message": "Product not found", "extensions": { "code": "NOT_FOUND" } }
    ]
}

// Becomes GraphQLResult with:
// - IsSuccess = false
// - MappedStatusCode = 404 (mapped from extensions.code)
// - Error = "Product not found" (the GraphQL error's message, verbatim)

Error Code Mapping

GraphQL Error CodeHTTP Status
NOT_FOUND404
UNAUTHORIZED, UNAUTHENTICATED401
FORBIDDEN, ACCESS_DENIED403
BAD_REQUEST, VALIDATION_ERROR400
INTERNAL_SERVER_ERROR500
TIMEOUT, GATEWAY_TIMEOUT504
TOO_MANY_REQUESTS429

Relaying GraphQL errors: use WriteGraphQLErrorResponseAsync(context, result) to surface these mapped statuses to the client. It relays the mapped status verbatim — including a genuine user-facing 401/403 from an application-level UNAUTHENTICATED/FORBIDDEN error — while masking 5xx detail. Do not route GraphQL results through WriteBackendErrorResponseAsync: that writer is for relayed backend transport statuses and remaps 401/403 to 502 (a BFF-credential failure), which would hide the documented GraphQL auth statuses. A transport-level backend 401/403 is already neutralized to 502 before it reaches your transformer — even when the backend attaches a GraphQL errors envelope to that 401/403 response (a custom IBackendErrorMapper that passes the status through re-enables the envelope's code → status mapping).

Parallel GraphQL Queries

public class DashboardTransformer : TransformerBase<DashboardDto>
{
    public override async Task<DashboardDto> TransformAsync(TransformerContext context)
    {
        var backendRequest = (BackendRequest)context.Properties["BackendRequest"];

        // Parallel GraphQL queries
        var productsTask = context.BackendCaller.CallGraphQLAsync<List<Product>>(
            backendRequest,
            ProductsQuery, variables: null, dataPath: "products");
        var ordersTask = context.BackendCaller.CallGraphQLAsync<List<Order>>(
            backendRequest,
            OrdersQuery, variables: new { customerId = context.UserId }, dataPath: "orders");

        await Task.WhenAll(productsTask, ordersTask);

        return new DashboardDto
        {
            Products = productsTask.Result.Data,
            Orders = ordersTask.Result.Data
        };
    }
}

Mutations

GraphQL mutations work the same way:

public class CreateOrderTransformer : TransformerBase<CreateOrderRequest, OrderDto>
{
    private const string CreateOrderMutation = """
        mutation CreateOrder($input: CreateOrderInput!) {
            createOrder(input: $input) {
                id
                code
                state
                total
            }
        }
        """;

    public override async Task<OrderDto> TransformAsync(
        CreateOrderRequest? request,
        TransformerContext context)
    {
        var backendRequest = (BackendRequest)context.Properties["BackendRequest"];

        var result = await context.BackendCaller.CallGraphQLAsync<Order>(
            backendRequest,
            CreateOrderMutation,
            variables: new { input = request },
            dataPath: "createOrder",
            cancellationToken: context.CancellationToken
        );

        if (!result.IsSuccess)
        {
            await WriteGraphQLErrorResponseAsync(context, result);
            return default!;
        }

        return MapToDto(result.Data);
    }
}

// POST endpoint for mutation
app.MapTransformer<CreateOrderTransformer, CreateOrderRequest, OrderDto>()
    .FromPost("/api/orders")
    .ToGraphQL("https://graphql.internal/my-api")
    .WithBackendAuth(BackendAuthPolicies.BearerToken)
    .Build();