# Step 16: GraphQL Handler Implementation **Phase 4: Handler Plugins** **Estimated Complexity:** High **Dependencies:** Step 10 (Microservice Handler) --- ## Overview The GraphQL handler routes GraphQL queries, mutations, and subscriptions to appropriate microservices based on schema analysis. It supports schema stitching, query splitting, and federated execution across multiple services. --- ## Goals 1. Route GraphQL operations to appropriate backend services 2. Support schema federation/stitching across microservices 3. Handle batched queries with DataLoader patterns 4. Support subscriptions via WebSocket upgrade 5. Provide introspection proxying and schema caching --- ## Core Architecture ``` ┌──────────────────────────────────────────────────────────────────┐ │ GraphQL Handler │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ HTTP Request │ │ │ │ │ ▼ │ │ ┌───────────────┐ │ │ │ Query Parser │──► Extract operation type & fields │ │ └───────┬───────┘ │ │ │ │ │ ▼ │ │ ┌───────────────┐ ┌─────────────────┐ │ │ │ Query Planner │───►│ Schema Registry │ │ │ └───────┬───────┘ └─────────────────┘ │ │ │ │ │ ▼ │ │ ┌───────────────┐ │ │ │Query Executor │──► Split & dispatch to services │ │ └───────┬───────┘ │ │ │ │ │ ▼ │ │ ┌───────────────┐ │ │ │Result Merger │──► Combine partial results │ │ └───────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────┘ ``` --- ## Configuration ```csharp namespace StellaOps.Router.Handlers.GraphQL; public class GraphQLHandlerConfig { /// Path prefix for GraphQL endpoint. public string Path { get; set; } = "/graphql"; /// Whether to enable introspection queries. public bool EnableIntrospection { get; set; } = true; /// Whether to enable subscriptions. public bool EnableSubscriptions { get; set; } = true; /// Maximum query depth to prevent DOS. public int MaxQueryDepth { get; set; } = 15; /// Maximum query complexity score. public int MaxQueryComplexity { get; set; } = 1000; /// Timeout for query execution. public TimeSpan ExecutionTimeout { get; set; } = TimeSpan.FromSeconds(30); /// Cache duration for schema introspection. public TimeSpan SchemaCacheDuration { get; set; } = TimeSpan.FromMinutes(5); /// Whether to enable query batching. public bool EnableBatching { get; set; } = true; /// Maximum batch size. public int MaxBatchSize { get; set; } = 10; /// Registered GraphQL services and their type ownership. public Dictionary Services { get; set; } = new(); } public class GraphQLServiceConfig { /// Service name for routing. public required string ServiceName { get; set; } /// Root types this service handles (Query, Mutation, Subscription). public HashSet RootTypes { get; set; } = new(); /// Specific fields this service owns. public Dictionary> OwnedFields { get; set; } = new(); /// Whether this service provides the full schema. public bool IsSchemaProvider { get; set; } } ``` --- ## Core Types ```csharp namespace StellaOps.Router.Handlers.GraphQL; /// /// Parsed GraphQL request. /// public sealed class GraphQLRequest { public required string Query { get; init; } public string? OperationName { get; init; } public Dictionary? Variables { get; init; } public Dictionary? Extensions { get; init; } } /// /// GraphQL response format. /// public sealed class GraphQLResponse { public object? Data { get; set; } public List? Errors { get; set; } public Dictionary? Extensions { get; set; } } public sealed class GraphQLError { public required string Message { get; init; } public List? Locations { get; init; } public List? Path { get; init; } public Dictionary? Extensions { get; init; } } public sealed class GraphQLLocation { public int Line { get; init; } public int Column { get; init; } } /// /// Represents a planned query execution. /// public sealed class QueryPlan { public GraphQLOperationType OperationType { get; init; } public List Nodes { get; init; } = new(); } public sealed class QueryPlanNode { public string ServiceName { get; init; } = ""; public string SubQuery { get; init; } = ""; public List RequiredFields { get; init; } = new(); public List DependsOn { get; init; } = new(); } public enum GraphQLOperationType { Query, Mutation, Subscription } ``` --- ## GraphQL Handler Implementation ```csharp namespace StellaOps.Router.Handlers.GraphQL; public sealed class GraphQLHandler : IRouteHandler { public string HandlerType => "GraphQL"; public int Priority => 100; private readonly GraphQLHandlerConfig _config; private readonly IGraphQLParser _parser; private readonly IQueryPlanner _planner; private readonly IQueryExecutor _executor; private readonly ISchemaRegistry _schemaRegistry; private readonly ILogger _logger; public GraphQLHandler( IOptions config, IGraphQLParser parser, IQueryPlanner planner, IQueryExecutor executor, ISchemaRegistry schemaRegistry, ILogger logger) { _config = config.Value; _parser = parser; _planner = planner; _executor = executor; _schemaRegistry = schemaRegistry; _logger = logger; } public bool CanHandle(RouteMatchResult match) { return match.Handler == "GraphQL" || match.Route.Path.StartsWith(_config.Path, StringComparison.OrdinalIgnoreCase); } public async Task HandleAsync( HttpContext context, RouteMatchResult match, IReadOnlyDictionary claims, CancellationToken cancellationToken) { try { // Handle WebSocket upgrade for subscriptions if (context.WebSockets.IsWebSocketRequest && _config.EnableSubscriptions) { return await HandleSubscriptionAsync(context, claims, cancellationToken); } // Parse GraphQL request var request = await ParseRequestAsync(context, cancellationToken); // Validate query var validationResult = _parser.Validate( request.Query, _config.MaxQueryDepth, _config.MaxQueryComplexity); if (!validationResult.IsValid) { return CreateErrorResponse(validationResult.Errors); } // Parse and analyze query var operation = _parser.Parse(request.Query, request.OperationName); // Check if introspection if (operation.IsIntrospection) { if (!_config.EnableIntrospection) { return CreateErrorResponse(new[] { "Introspection is disabled" }); } return await HandleIntrospectionAsync(request, cancellationToken); } // Plan query execution var plan = _planner.CreatePlan(operation, _config.Services); _logger.LogDebug( "Query plan created: {NodeCount} nodes for {OperationType}", plan.Nodes.Count, plan.OperationType); // Execute plan var result = await _executor.ExecuteAsync( plan, request, claims, _config.ExecutionTimeout, cancellationToken); return CreateSuccessResponse(result); } catch (GraphQLParseException ex) { return CreateErrorResponse(new[] { ex.Message }); } catch (Exception ex) { _logger.LogError(ex, "GraphQL execution error"); return CreateErrorResponse(new[] { "Internal server error" }, 500); } } private async Task ParseRequestAsync( HttpContext context, CancellationToken cancellationToken) { if (context.Request.Method == "GET") { return new GraphQLRequest { Query = context.Request.Query["query"].ToString(), OperationName = context.Request.Query["operationName"].ToString(), Variables = ParseVariables(context.Request.Query["variables"].ToString()) }; } var body = await JsonSerializer.DeserializeAsync( context.Request.Body, cancellationToken: cancellationToken); return body ?? throw new GraphQLParseException("Invalid request body"); } private Dictionary? ParseVariables(string? json) { if (string.IsNullOrEmpty(json)) return null; return JsonSerializer.Deserialize>(json); } private async Task HandleIntrospectionAsync( GraphQLRequest request, CancellationToken cancellationToken) { var schema = await _schemaRegistry.GetMergedSchemaAsync(cancellationToken); var result = await _executor.ExecuteIntrospectionAsync(schema, request, cancellationToken); return CreateSuccessResponse(result); } private async Task HandleSubscriptionAsync( HttpContext context, IReadOnlyDictionary claims, CancellationToken cancellationToken) { var webSocket = await context.WebSockets.AcceptWebSocketAsync("graphql-transport-ws"); await _executor.HandleSubscriptionAsync(webSocket, claims, cancellationToken); return new RouteHandlerResult { Handled = true, StatusCode = 101 // Switching Protocols }; } private RouteHandlerResult CreateSuccessResponse(GraphQLResponse response) { return new RouteHandlerResult { Handled = true, StatusCode = 200, ContentType = "application/json", Body = JsonSerializer.SerializeToUtf8Bytes(response) }; } private RouteHandlerResult CreateErrorResponse(IEnumerable messages, int statusCode = 200) { var response = new GraphQLResponse { Errors = messages.Select(m => new GraphQLError { Message = m }).ToList() }; return new RouteHandlerResult { Handled = true, StatusCode = statusCode, ContentType = "application/json", Body = JsonSerializer.SerializeToUtf8Bytes(response) }; } } ``` --- ## Query Planner ```csharp namespace StellaOps.Router.Handlers.GraphQL; public interface IQueryPlanner { QueryPlan CreatePlan( ParsedOperation operation, Dictionary services); } public sealed class QueryPlanner : IQueryPlanner { private readonly ILogger _logger; public QueryPlanner(ILogger logger) { _logger = logger; } public QueryPlan CreatePlan( ParsedOperation operation, Dictionary services) { var plan = new QueryPlan { OperationType = operation.OperationType }; // Group fields by owning service var fieldsByService = new Dictionary>(); foreach (var field in operation.SelectionSet) { var service = FindOwningService(operation.OperationType, field.Name, services); if (!fieldsByService.ContainsKey(service)) { fieldsByService[service] = new List(); } fieldsByService[service].Add(field); } // Create execution nodes foreach (var (serviceName, fields) in fieldsByService) { var subQuery = BuildSubQuery(operation, fields); plan.Nodes.Add(new QueryPlanNode { ServiceName = serviceName, SubQuery = subQuery, RequiredFields = fields.Select(f => f.Name).ToList() }); } // For mutations, nodes must execute sequentially if (operation.OperationType == GraphQLOperationType.Mutation) { for (int i = 1; i < plan.Nodes.Count; i++) { plan.Nodes[i].DependsOn.Add(plan.Nodes[i - 1]); } } return plan; } private string FindOwningService( GraphQLOperationType opType, string fieldName, Dictionary services) { var rootType = opType switch { GraphQLOperationType.Query => "Query", GraphQLOperationType.Mutation => "Mutation", GraphQLOperationType.Subscription => "Subscription", _ => "Query" }; foreach (var (name, config) in services) { if (config.OwnedFields.TryGetValue(rootType, out var fields) && fields.Contains(fieldName)) { return name; } if (config.RootTypes.Contains(rootType)) { return name; } } throw new GraphQLExecutionException($"No service found for field: {rootType}.{fieldName}"); } private string BuildSubQuery(ParsedOperation operation, List fields) { var sb = new StringBuilder(); sb.Append(operation.OperationType.ToString().ToLower()); if (!string.IsNullOrEmpty(operation.Name)) { sb.Append(' ').Append(operation.Name); } if (operation.Variables.Count > 0) { sb.Append('('); sb.Append(string.Join(", ", operation.Variables.Select(v => $"${v.Name}: {v.Type}"))); sb.Append(')'); } sb.Append(" { "); foreach (var field in fields) { AppendField(sb, field); } sb.Append(" }"); return sb.ToString(); } private void AppendField(StringBuilder sb, FieldSelection field) { if (!string.IsNullOrEmpty(field.Alias)) { sb.Append(field.Alias).Append(": "); } sb.Append(field.Name); if (field.Arguments.Count > 0) { sb.Append('('); sb.Append(string.Join(", ", field.Arguments.Select(a => $"{a.Key}: {FormatValue(a.Value)}"))); sb.Append(')'); } if (field.SelectionSet.Count > 0) { sb.Append(" { "); foreach (var subField in field.SelectionSet) { AppendField(sb, subField); sb.Append(' '); } sb.Append('}'); } sb.Append(' '); } private string FormatValue(object? value) { return value switch { null => "null", string s => $"\"{s}\"", bool b => b.ToString().ToLower(), _ => value.ToString() ?? "null" }; } } ``` --- ## Query Executor ```csharp namespace StellaOps.Router.Handlers.GraphQL; public interface IQueryExecutor { Task ExecuteAsync( QueryPlan plan, GraphQLRequest request, IReadOnlyDictionary claims, TimeSpan timeout, CancellationToken cancellationToken); Task ExecuteIntrospectionAsync( GraphQLSchema schema, GraphQLRequest request, CancellationToken cancellationToken); Task HandleSubscriptionAsync( WebSocket webSocket, IReadOnlyDictionary claims, CancellationToken cancellationToken); } public sealed class QueryExecutor : IQueryExecutor { private readonly ITransportClientFactory _transportFactory; private readonly IPayloadSerializer _serializer; private readonly ILogger _logger; public QueryExecutor( ITransportClientFactory transportFactory, IPayloadSerializer serializer, ILogger logger) { _transportFactory = transportFactory; _serializer = serializer; _logger = logger; } public async Task ExecuteAsync( QueryPlan plan, GraphQLRequest request, IReadOnlyDictionary claims, TimeSpan timeout, CancellationToken cancellationToken) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(timeout); var results = new ConcurrentDictionary(); var errors = new ConcurrentBag(); // Execute nodes respecting dependencies await ExecuteNodesAsync(plan.Nodes, request, claims, results, errors, cts.Token); // Merge results var data = MergeResults(plan.Nodes, results); return new GraphQLResponse { Data = data, Errors = errors.Any() ? errors.ToList() : null }; } private async Task ExecuteNodesAsync( List nodes, GraphQLRequest request, IReadOnlyDictionary claims, ConcurrentDictionary results, ConcurrentBag errors, CancellationToken cancellationToken) { // Group nodes by dependency level var executed = new HashSet(); while (executed.Count < nodes.Count) { var ready = nodes .Where(n => !executed.Contains(n)) .Where(n => n.DependsOn.All(d => executed.Contains(d))) .ToList(); if (ready.Count == 0) { throw new GraphQLExecutionException("Circular dependency in query plan"); } // Execute ready nodes in parallel await Parallel.ForEachAsync(ready, cancellationToken, async (node, ct) => { try { var result = await ExecuteNodeAsync(node, request, claims, ct); MergeNodeResult(results, result); } catch (Exception ex) { _logger.LogError(ex, "Error executing node for service {Service}", node.ServiceName); errors.Add(new GraphQLError { Message = $"Error from {node.ServiceName}: {ex.Message}", Path = node.RequiredFields.Cast().ToList() }); } }); foreach (var node in ready) { executed.Add(node); } } } private async Task ExecuteNodeAsync( QueryPlanNode node, GraphQLRequest request, IReadOnlyDictionary claims, CancellationToken cancellationToken) { var client = _transportFactory.GetClient(node.ServiceName); var payload = new RequestPayload { Method = "POST", Path = "/graphql", Headers = new Dictionary { ["Content-Type"] = "application/json" }, Claims = claims.ToDictionary(x => x.Key, x => x.Value), Body = JsonSerializer.SerializeToUtf8Bytes(new { query = node.SubQuery, variables = request.Variables, operationName = request.OperationName }) }; var response = await client.SendRequestAsync( node.ServiceName, payload, TimeSpan.FromSeconds(30), cancellationToken); if (response.Body == null) { throw new GraphQLExecutionException($"Empty response from {node.ServiceName}"); } return JsonSerializer.Deserialize(response.Body) ?? throw new GraphQLExecutionException($"Invalid response from {node.ServiceName}"); } private void MergeNodeResult(ConcurrentDictionary results, GraphQLResponse response) { if (response.Data is JsonElement element && element.ValueKind == JsonValueKind.Object) { foreach (var property in element.EnumerateObject()) { results[property.Name] = property.Value.Clone(); } } } private object? MergeResults(List nodes, ConcurrentDictionary results) { return results.ToDictionary(x => x.Key, x => x.Value); } public Task ExecuteIntrospectionAsync( GraphQLSchema schema, GraphQLRequest request, CancellationToken cancellationToken) { // Execute introspection against merged schema var result = schema.ExecuteIntrospection(request); return Task.FromResult(result); } public async Task HandleSubscriptionAsync( WebSocket webSocket, IReadOnlyDictionary claims, CancellationToken cancellationToken) { var buffer = new byte[4096]; try { while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { var result = await webSocket.ReceiveAsync(buffer, cancellationToken); if (result.MessageType == WebSocketMessageType.Close) { await webSocket.CloseAsync( WebSocketCloseStatus.NormalClosure, "Closed by client", cancellationToken); break; } var message = Encoding.UTF8.GetString(buffer, 0, result.Count); await HandleSubscriptionMessageAsync(webSocket, message, claims, cancellationToken); } } catch (WebSocketException ex) { _logger.LogWarning(ex, "WebSocket error in subscription"); } } private async Task HandleSubscriptionMessageAsync( WebSocket webSocket, string message, IReadOnlyDictionary claims, CancellationToken cancellationToken) { // Implement graphql-transport-ws protocol var msg = JsonSerializer.Deserialize(message); switch (msg?.Type) { case "connection_init": await SendAsync(webSocket, new { type = "connection_ack" }, cancellationToken); break; case "subscribe": // Start subscription break; case "complete": // End subscription break; } } private async Task SendAsync(WebSocket webSocket, object message, CancellationToken cancellationToken) { var bytes = JsonSerializer.SerializeToUtf8Bytes(message); await webSocket.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken); } } internal class SubscriptionMessage { public string? Type { get; set; } public string? Id { get; set; } public GraphQLRequest? Payload { get; set; } } ``` --- ## Schema Registry ```csharp namespace StellaOps.Router.Handlers.GraphQL; public interface ISchemaRegistry { Task GetMergedSchemaAsync(CancellationToken cancellationToken); void InvalidateCache(); } public sealed class SchemaRegistry : ISchemaRegistry { private readonly GraphQLHandlerConfig _config; private readonly ITransportClientFactory _transportFactory; private readonly ILogger _logger; private GraphQLSchema? _cachedSchema; private DateTimeOffset _cacheExpiry; private readonly SemaphoreSlim _lock = new(1, 1); public SchemaRegistry( IOptions config, ITransportClientFactory transportFactory, ILogger logger) { _config = config.Value; _transportFactory = transportFactory; _logger = logger; } public async Task GetMergedSchemaAsync(CancellationToken cancellationToken) { if (_cachedSchema != null && DateTimeOffset.UtcNow < _cacheExpiry) { return _cachedSchema; } await _lock.WaitAsync(cancellationToken); try { if (_cachedSchema != null && DateTimeOffset.UtcNow < _cacheExpiry) { return _cachedSchema; } var schemas = new List(); foreach (var (name, config) in _config.Services) { if (config.IsSchemaProvider) { var schema = await FetchSchemaAsync(config.ServiceName, cancellationToken); schemas.Add(schema); } } _cachedSchema = MergeSchemas(schemas); _cacheExpiry = DateTimeOffset.UtcNow.Add(_config.SchemaCacheDuration); _logger.LogInformation("Schema cache refreshed, expires at {Expiry}", _cacheExpiry); return _cachedSchema; } finally { _lock.Release(); } } private async Task FetchSchemaAsync(string serviceName, CancellationToken cancellationToken) { var client = _transportFactory.GetClient(serviceName); var introspectionQuery = @" query IntrospectionQuery { __schema { types { ...FullType } queryType { name } mutationType { name } subscriptionType { name } } } fragment FullType on __Type { kind name description fields(includeDeprecated: true) { name description args { ...InputValue } type { ...TypeRef } isDeprecated deprecationReason } } fragment InputValue on __InputValue { name description type { ...TypeRef } } fragment TypeRef on __Type { kind name ofType { kind name ofType { kind name ofType { kind name } } } }"; var payload = new RequestPayload { Method = "POST", Path = "/graphql", Headers = new Dictionary { ["Content-Type"] = "application/json" }, Claims = new Dictionary(), Body = JsonSerializer.SerializeToUtf8Bytes(new { query = introspectionQuery }) }; var response = await client.SendRequestAsync( serviceName, payload, TimeSpan.FromSeconds(30), cancellationToken); return Encoding.UTF8.GetString(response.Body ?? Array.Empty()); } private GraphQLSchema MergeSchemas(List schemas) { // Merge multiple introspection results into unified schema return new GraphQLSchema(schemas); } public void InvalidateCache() { _cachedSchema = null; _cacheExpiry = DateTimeOffset.MinValue; } } ``` --- ## YAML Configuration ```yaml GraphQL: Path: "/graphql" EnableIntrospection: true EnableSubscriptions: true MaxQueryDepth: 15 MaxQueryComplexity: 1000 ExecutionTimeout: "00:00:30" SchemaCacheDuration: "00:05:00" EnableBatching: true MaxBatchSize: 10 Services: users: ServiceName: "user-service" RootTypes: - Query - Mutation OwnedFields: Query: - user - users - me Mutation: - createUser - updateUser IsSchemaProvider: true billing: ServiceName: "billing-service" OwnedFields: Query: - invoices - subscription Mutation: - createInvoice IsSchemaProvider: true ``` --- ## Deliverables 1. `StellaOps.Router.Handlers.GraphQL/GraphQLHandler.cs` 2. `StellaOps.Router.Handlers.GraphQL/GraphQLHandlerConfig.cs` 3. `StellaOps.Router.Handlers.GraphQL/IGraphQLParser.cs` 4. `StellaOps.Router.Handlers.GraphQL/IQueryPlanner.cs` 5. `StellaOps.Router.Handlers.GraphQL/QueryPlanner.cs` 6. `StellaOps.Router.Handlers.GraphQL/IQueryExecutor.cs` 7. `StellaOps.Router.Handlers.GraphQL/QueryExecutor.cs` 8. `StellaOps.Router.Handlers.GraphQL/ISchemaRegistry.cs` 9. `StellaOps.Router.Handlers.GraphQL/SchemaRegistry.cs` 10. Unit tests for query planning 11. Integration tests for federated execution 12. Subscription handling tests --- ## Next Step Proceed to [Step 17: S3/Storage Handler Implementation](17-Step.md) to implement the storage route handler.