Files
git.stella-ops.org/docs/router/16-Step.md
master 75f6942769
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
Add integration tests for migration categories and execution
- 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.
2025-12-04 19:10:54 +02:00

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.