Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
995 lines
31 KiB
Markdown
995 lines
31 KiB
Markdown
# 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
|
|
{
|
|
/// <summary>Path prefix for GraphQL endpoint.</summary>
|
|
public string Path { get; set; } = "/graphql";
|
|
|
|
/// <summary>Whether to enable introspection queries.</summary>
|
|
public bool EnableIntrospection { get; set; } = true;
|
|
|
|
/// <summary>Whether to enable subscriptions.</summary>
|
|
public bool EnableSubscriptions { get; set; } = true;
|
|
|
|
/// <summary>Maximum query depth to prevent DOS.</summary>
|
|
public int MaxQueryDepth { get; set; } = 15;
|
|
|
|
/// <summary>Maximum query complexity score.</summary>
|
|
public int MaxQueryComplexity { get; set; } = 1000;
|
|
|
|
/// <summary>Timeout for query execution.</summary>
|
|
public TimeSpan ExecutionTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>Cache duration for schema introspection.</summary>
|
|
public TimeSpan SchemaCacheDuration { get; set; } = TimeSpan.FromMinutes(5);
|
|
|
|
/// <summary>Whether to enable query batching.</summary>
|
|
public bool EnableBatching { get; set; } = true;
|
|
|
|
/// <summary>Maximum batch size.</summary>
|
|
public int MaxBatchSize { get; set; } = 10;
|
|
|
|
/// <summary>Registered GraphQL services and their type ownership.</summary>
|
|
public Dictionary<string, GraphQLServiceConfig> Services { get; set; } = new();
|
|
}
|
|
|
|
public class GraphQLServiceConfig
|
|
{
|
|
/// <summary>Service name for routing.</summary>
|
|
public required string ServiceName { get; set; }
|
|
|
|
/// <summary>Root types this service handles (Query, Mutation, Subscription).</summary>
|
|
public HashSet<string> RootTypes { get; set; } = new();
|
|
|
|
/// <summary>Specific fields this service owns.</summary>
|
|
public Dictionary<string, HashSet<string>> OwnedFields { get; set; } = new();
|
|
|
|
/// <summary>Whether this service provides the full schema.</summary>
|
|
public bool IsSchemaProvider { get; set; }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Core Types
|
|
|
|
```csharp
|
|
namespace StellaOps.Router.Handlers.GraphQL;
|
|
|
|
/// <summary>
|
|
/// Parsed GraphQL request.
|
|
/// </summary>
|
|
public sealed class GraphQLRequest
|
|
{
|
|
public required string Query { get; init; }
|
|
public string? OperationName { get; init; }
|
|
public Dictionary<string, object?>? Variables { get; init; }
|
|
public Dictionary<string, object?>? Extensions { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// GraphQL response format.
|
|
/// </summary>
|
|
public sealed class GraphQLResponse
|
|
{
|
|
public object? Data { get; set; }
|
|
public List<GraphQLError>? Errors { get; set; }
|
|
public Dictionary<string, object?>? Extensions { get; set; }
|
|
}
|
|
|
|
public sealed class GraphQLError
|
|
{
|
|
public required string Message { get; init; }
|
|
public List<GraphQLLocation>? Locations { get; init; }
|
|
public List<object>? Path { get; init; }
|
|
public Dictionary<string, object?>? Extensions { get; init; }
|
|
}
|
|
|
|
public sealed class GraphQLLocation
|
|
{
|
|
public int Line { get; init; }
|
|
public int Column { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a planned query execution.
|
|
/// </summary>
|
|
public sealed class QueryPlan
|
|
{
|
|
public GraphQLOperationType OperationType { get; init; }
|
|
public List<QueryPlanNode> Nodes { get; init; } = new();
|
|
}
|
|
|
|
public sealed class QueryPlanNode
|
|
{
|
|
public string ServiceName { get; init; } = "";
|
|
public string SubQuery { get; init; } = "";
|
|
public List<string> RequiredFields { get; init; } = new();
|
|
public List<QueryPlanNode> 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<GraphQLHandler> _logger;
|
|
|
|
public GraphQLHandler(
|
|
IOptions<GraphQLHandlerConfig> config,
|
|
IGraphQLParser parser,
|
|
IQueryPlanner planner,
|
|
IQueryExecutor executor,
|
|
ISchemaRegistry schemaRegistry,
|
|
ILogger<GraphQLHandler> 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<RouteHandlerResult> HandleAsync(
|
|
HttpContext context,
|
|
RouteMatchResult match,
|
|
IReadOnlyDictionary<string, string> 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<GraphQLRequest> 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<GraphQLRequest>(
|
|
context.Request.Body,
|
|
cancellationToken: cancellationToken);
|
|
|
|
return body ?? throw new GraphQLParseException("Invalid request body");
|
|
}
|
|
|
|
private Dictionary<string, object?>? ParseVariables(string? json)
|
|
{
|
|
if (string.IsNullOrEmpty(json))
|
|
return null;
|
|
|
|
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
|
|
}
|
|
|
|
private async Task<RouteHandlerResult> 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<RouteHandlerResult> HandleSubscriptionAsync(
|
|
HttpContext context,
|
|
IReadOnlyDictionary<string, string> 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<string> 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<string, GraphQLServiceConfig> services);
|
|
}
|
|
|
|
public sealed class QueryPlanner : IQueryPlanner
|
|
{
|
|
private readonly ILogger<QueryPlanner> _logger;
|
|
|
|
public QueryPlanner(ILogger<QueryPlanner> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
public QueryPlan CreatePlan(
|
|
ParsedOperation operation,
|
|
Dictionary<string, GraphQLServiceConfig> services)
|
|
{
|
|
var plan = new QueryPlan
|
|
{
|
|
OperationType = operation.OperationType
|
|
};
|
|
|
|
// Group fields by owning service
|
|
var fieldsByService = new Dictionary<string, List<FieldSelection>>();
|
|
|
|
foreach (var field in operation.SelectionSet)
|
|
{
|
|
var service = FindOwningService(operation.OperationType, field.Name, services);
|
|
|
|
if (!fieldsByService.ContainsKey(service))
|
|
{
|
|
fieldsByService[service] = new List<FieldSelection>();
|
|
}
|
|
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<string, GraphQLServiceConfig> 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<FieldSelection> 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<GraphQLResponse> ExecuteAsync(
|
|
QueryPlan plan,
|
|
GraphQLRequest request,
|
|
IReadOnlyDictionary<string, string> claims,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken);
|
|
|
|
Task<GraphQLResponse> ExecuteIntrospectionAsync(
|
|
GraphQLSchema schema,
|
|
GraphQLRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
Task HandleSubscriptionAsync(
|
|
WebSocket webSocket,
|
|
IReadOnlyDictionary<string, string> claims,
|
|
CancellationToken cancellationToken);
|
|
}
|
|
|
|
public sealed class QueryExecutor : IQueryExecutor
|
|
{
|
|
private readonly ITransportClientFactory _transportFactory;
|
|
private readonly IPayloadSerializer _serializer;
|
|
private readonly ILogger<QueryExecutor> _logger;
|
|
|
|
public QueryExecutor(
|
|
ITransportClientFactory transportFactory,
|
|
IPayloadSerializer serializer,
|
|
ILogger<QueryExecutor> logger)
|
|
{
|
|
_transportFactory = transportFactory;
|
|
_serializer = serializer;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<GraphQLResponse> ExecuteAsync(
|
|
QueryPlan plan,
|
|
GraphQLRequest request,
|
|
IReadOnlyDictionary<string, string> claims,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
cts.CancelAfter(timeout);
|
|
|
|
var results = new ConcurrentDictionary<string, object?>();
|
|
var errors = new ConcurrentBag<GraphQLError>();
|
|
|
|
// 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<QueryPlanNode> nodes,
|
|
GraphQLRequest request,
|
|
IReadOnlyDictionary<string, string> claims,
|
|
ConcurrentDictionary<string, object?> results,
|
|
ConcurrentBag<GraphQLError> errors,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Group nodes by dependency level
|
|
var executed = new HashSet<QueryPlanNode>();
|
|
|
|
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<object>().ToList()
|
|
});
|
|
}
|
|
});
|
|
|
|
foreach (var node in ready)
|
|
{
|
|
executed.Add(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<GraphQLResponse> ExecuteNodeAsync(
|
|
QueryPlanNode node,
|
|
GraphQLRequest request,
|
|
IReadOnlyDictionary<string, string> claims,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var client = _transportFactory.GetClient(node.ServiceName);
|
|
|
|
var payload = new RequestPayload
|
|
{
|
|
Method = "POST",
|
|
Path = "/graphql",
|
|
Headers = new Dictionary<string, string>
|
|
{
|
|
["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<GraphQLResponse>(response.Body)
|
|
?? throw new GraphQLExecutionException($"Invalid response from {node.ServiceName}");
|
|
}
|
|
|
|
private void MergeNodeResult(ConcurrentDictionary<string, object?> 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<QueryPlanNode> nodes, ConcurrentDictionary<string, object?> results)
|
|
{
|
|
return results.ToDictionary(x => x.Key, x => x.Value);
|
|
}
|
|
|
|
public Task<GraphQLResponse> 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<string, string> 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<string, string> claims,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Implement graphql-transport-ws protocol
|
|
var msg = JsonSerializer.Deserialize<SubscriptionMessage>(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<GraphQLSchema> GetMergedSchemaAsync(CancellationToken cancellationToken);
|
|
void InvalidateCache();
|
|
}
|
|
|
|
public sealed class SchemaRegistry : ISchemaRegistry
|
|
{
|
|
private readonly GraphQLHandlerConfig _config;
|
|
private readonly ITransportClientFactory _transportFactory;
|
|
private readonly ILogger<SchemaRegistry> _logger;
|
|
private GraphQLSchema? _cachedSchema;
|
|
private DateTimeOffset _cacheExpiry;
|
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
|
|
|
public SchemaRegistry(
|
|
IOptions<GraphQLHandlerConfig> config,
|
|
ITransportClientFactory transportFactory,
|
|
ILogger<SchemaRegistry> logger)
|
|
{
|
|
_config = config.Value;
|
|
_transportFactory = transportFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<GraphQLSchema> 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<string>();
|
|
|
|
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<string> 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<string, string> { ["Content-Type"] = "application/json" },
|
|
Claims = new Dictionary<string, string>(),
|
|
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<byte>());
|
|
}
|
|
|
|
private GraphQLSchema MergeSchemas(List<string> 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.
|