Add integration tests for migration categories and execution
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
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.
This commit is contained in:
994
docs/router/16-Step.md
Normal file
994
docs/router/16-Step.md
Normal file
@@ -0,0 +1,994 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user