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.
31 KiB
31 KiB
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
- Route GraphQL operations to appropriate backend services
- Support schema federation/stitching across microservices
- Handle batched queries with DataLoader patterns
- Support subscriptions via WebSocket upgrade
- 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
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
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
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
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
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
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
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
StellaOps.Router.Handlers.GraphQL/GraphQLHandler.csStellaOps.Router.Handlers.GraphQL/GraphQLHandlerConfig.csStellaOps.Router.Handlers.GraphQL/IGraphQLParser.csStellaOps.Router.Handlers.GraphQL/IQueryPlanner.csStellaOps.Router.Handlers.GraphQL/QueryPlanner.csStellaOps.Router.Handlers.GraphQL/IQueryExecutor.csStellaOps.Router.Handlers.GraphQL/QueryExecutor.csStellaOps.Router.Handlers.GraphQL/ISchemaRegistry.csStellaOps.Router.Handlers.GraphQL/SchemaRegistry.cs- Unit tests for query planning
- Integration tests for federated execution
- Subscription handling tests
Next Step
Proceed to Step 17: S3/Storage Handler Implementation to implement the storage route handler.