For this step, the goal is: the gateway can accept an HTTP request, route it to **one** microservice over the **InMemory** transport, get a response, and return it to the client. No health/heartbeat yet. No streaming yet. Just: HTTP → InMemory → microservice → InMemory → HTTP. I’ll assume you’re still in the InMemory world and not touching TCP/UDP/RabbitMQ at this stage. --- ## 0. Preconditions Before you start: * `StellaOps.Router.Common` exists and exposes: * `EndpointDescriptor`, `ConnectionState`, `Frame`, `FrameType`, `TransportType`, `RoutingDecision`. * Interfaces: `IGlobalRoutingState`, `IRoutingPlugin`, `ITransportClient`. * `StellaOps.Microservice` minimal handshake & dispatch is in place (from your “step 4”): * Microservice can: * Discover endpoints. * Connect to an InMemory router client. * Send HELLO. * Receive REQUEST and send RESPONSE. * Gateway project exists (`StellaOps.Gateway.WebService`) and runs as a basic ASP.NET Core app. If anything in that list is not true, fix it first or adjust the plan accordingly. --- ## 1. Implement an InMemory transport “hub” You need a simple in-process component that: * Keeps track of “connections” from microservices. * Delivers frames from the gateway to the correct microservice and back. You can host this either: * In a dedicated **test/support** assembly, or * In the gateway project but marked as “dev-only” transport. For this step, keep it simple and in-memory. ### 1.1 Define an InMemory router hub Conceptually: ```csharp public interface IInMemoryRouterHub { // Called by microservice side to register a new connection Task RegisterMicroserviceAsync( InstanceDescriptor instance, IReadOnlyList endpoints, Func onFrameFromGateway, CancellationToken ct); // Called by microservice when it wants to send a frame to the gateway Task SendFromMicroserviceAsync(string connectionId, Frame frame, CancellationToken ct); // Called by gateway transport client when sending a frame to a microservice Task SendFromGatewayAsync(string connectionId, Frame frame, CancellationToken ct); } ``` Internally, the hub maintains per-connection data: * `ConnectionId` * `InstanceDescriptor` * Endpoints * Delegate `onFrameFromGateway` (microservice receiver) For minimal routing you can start by: * Only supporting `SendFromGatewayAsync` for REQUEST and returning RESPONSE. * For now, heartbeat frames can be ignored or stubbed. ### 1.2 Connect the microservice side Your `InMemoryMicroserviceConnection` (from step 4) should: * Call `RegisterMicroserviceAsync` on the hub when it sends HELLO: * Get `connectionId`. * Provide a handler `onFrameFromGateway` that: * Dispatches REQUEST frames via `IEndpointDispatcher`. * Sends RESPONSE frames back via `SendFromMicroserviceAsync`. This is mostly microservice work; you should already have most of it outlined. --- ## 2. Implement an InMemory `ITransportClient` in the gateway Now focus on the gateway side. **Project:** `StellaOps.Gateway.WebService` (or a small internal infra class in the same project) ### 2.1 `InMemoryTransportClient` Implement `ITransportClient` using the `IInMemoryRouterHub`: ```csharp public sealed class InMemoryTransportClient : ITransportClient { private readonly IInMemoryRouterHub _hub; public InMemoryTransportClient(IInMemoryRouterHub hub) { _hub = hub; } public Task SendRequestAsync( ConnectionState connection, Frame requestFrame, TimeSpan timeout, CancellationToken ct) { // connection.ConnectionId must be set when HELLO is processed return _hub.SendFromGatewayAsync(connection.ConnectionId, requestFrame, ct); } public Task SendCancelAsync(ConnectionState connection, Guid correlationId, string? reason = null) => Task.CompletedTask; // no-op at this stage public Task SendStreamingAsync( ConnectionState connection, Frame requestHeader, Stream requestBody, Func readResponseBody, PayloadLimits limits, CancellationToken ct) => throw new NotSupportedException("Streaming not implemented for InMemory in this step."); } ``` For now: * Ignore streaming. * Ignore cancel. * Just call `SendFromGatewayAsync` and get a response frame. ### 2.2 Register it in DI In gateway `Program.cs` or a DI setup: ```csharp services.AddSingleton(); // your hub implementation services.AddSingleton(); ``` You’ll later swap this with real transport clients (TCP, UDP, Rabbit), but for now everything uses InMemory. --- ## 3. Implement minimal `IGlobalRoutingState` You now need the gateway’s internal view of: * Which endpoints exist. * Which connections serve them. **Project:** `StellaOps.Gateway.WebService` or a small internal infra namespace. ### 3.1 In-memory implementation Implement an `InMemoryGlobalRoutingState` something like: ```csharp public sealed class InMemoryGlobalRoutingState : IGlobalRoutingState { private readonly object _lock = new(); private readonly Dictionary<(string, string), EndpointDescriptor> _endpoints = new(); private readonly List _connections = new(); public EndpointDescriptor? ResolveEndpoint(string method, string path) { lock (_lock) { _endpoints.TryGetValue((method, path), out var endpoint); return endpoint; } } public IReadOnlyList GetConnectionsFor( string serviceName, string version, string method, string path) { lock (_lock) { return _connections .Where(c => c.Instance.ServiceName == serviceName && c.Instance.Version == version && c.Endpoints.ContainsKey((method, path))) .ToList(); } } // Called when HELLO arrives from microservice public void RegisterConnection(ConnectionState connection) { lock (_lock) { _connections.Add(connection); foreach (var kvp in connection.Endpoints) { var key = kvp.Key; // (Method, Path) var descriptor = kvp.Value; // global endpoint map: any connection's descriptor is ok as "canonical" _endpoints[(key.Method, key.Path)] = descriptor; } } } } ``` You will refine this later; for minimal routing it's enough. ### 3.2 Hook HELLO to `IGlobalRoutingState` In your InMemory router hub, when a microservice registers (HELLO): * Create a `ConnectionState`: ```csharp var conn = new ConnectionState { ConnectionId = generatedConnectionId, Instance = instanceDescriptor, Status = InstanceHealthStatus.Healthy, LastHeartbeatUtc = DateTime.UtcNow, AveragePingMs = 0, TransportType = TransportType.Udp, // or TransportType.Tcp logically for InMemory Endpoints = endpointDescriptors.ToDictionary( e => (e.Method, e.Path), e => e) }; ``` * Call `InMemoryGlobalRoutingState.RegisterConnection(conn)`. This gives the gateway a routing view as soon as HELLO is processed. --- ## 4. Implement HTTP pipeline middlewares for routing Now, wire the gateway HTTP pipeline so that an incoming HTTP request is: 1. Resolved to a logical endpoint. 2. Routed to one connection. 3. Dispatched via InMemory transport. ### 4.1 EndpointResolutionMiddleware This maps `(Method, Path)` to an `EndpointDescriptor`. Create a middleware: ```csharp public sealed class EndpointResolutionMiddleware { private readonly RequestDelegate _next; public EndpointResolutionMiddleware(RequestDelegate next) => _next = next; public async Task Invoke(HttpContext context, IGlobalRoutingState routingState) { var method = context.Request.Method; var path = context.Request.Path.ToString(); var endpoint = routingState.ResolveEndpoint(method, path); if (endpoint is null) { context.Response.StatusCode = StatusCodes.Status404NotFound; await context.Response.WriteAsync("Endpoint not found"); return; } context.Items["Stella.EndpointDescriptor"] = endpoint; await _next(context); } } ``` Register it in the pipeline: ```csharp app.UseMiddleware(); ``` Before or after auth depending on your final pipeline; for minimal routing, order is not critical. ### 4.2 Minimal routing plugin (pick first connection) Implement a very naive `IRoutingPlugin` just to get things moving: ```csharp public sealed class NaiveRoutingPlugin : IRoutingPlugin { private readonly IGlobalRoutingState _state; public NaiveRoutingPlugin(IGlobalRoutingState state) => _state = state; public Task ChooseInstanceAsync( RoutingContext context, CancellationToken cancellationToken) { var endpoint = context.Endpoint; var connections = _state.GetConnectionsFor( endpoint.ServiceName, endpoint.Version, endpoint.Method, endpoint.Path); var chosen = connections.FirstOrDefault(); if (chosen is null) return Task.FromResult(null); var decision = new RoutingDecision { Endpoint = endpoint, Connection = chosen, TransportType = chosen.TransportType, EffectiveTimeout = endpoint.DefaultTimeout }; return Task.FromResult(decision); } } ``` Register it: ```csharp services.AddSingleton(); services.AddSingleton(); ``` ### 4.3 RoutingDecisionMiddleware This middleware grabs the endpoint descriptor and asks the routing plugin for a connection. ```csharp public sealed class RoutingDecisionMiddleware { private readonly RequestDelegate _next; public RoutingDecisionMiddleware(RequestDelegate next) => _next = next; public async Task Invoke(HttpContext context, IRoutingPlugin routingPlugin) { var endpoint = (EndpointDescriptor?)context.Items["Stella.EndpointDescriptor"]; if (endpoint is null) { context.Response.StatusCode = 500; await context.Response.WriteAsync("Endpoint metadata missing"); return; } var routingContext = new RoutingContext { Endpoint = endpoint, GatewayRegion = "not_used_yet", // you’ll fill this from GatewayNodeConfig later HttpContext = context }; var decision = await routingPlugin.ChooseInstanceAsync(routingContext, context.RequestAborted); if (decision is null) { context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; await context.Response.WriteAsync("No instances available"); return; } context.Items["Stella.RoutingDecision"] = decision; await _next(context); } } ``` Register it after `EndpointResolutionMiddleware`: ```csharp app.UseMiddleware(); ``` ### 4.4 TransportDispatchMiddleware This middleware: * Builds a REQUEST frame from HTTP. * Uses `ITransportClient` to send it to the chosen connection. * Writes the RESPONSE frame back to HTTP. Minimal version (buffered, no streaming): ```csharp public sealed class TransportDispatchMiddleware { private readonly RequestDelegate _next; public TransportDispatchMiddleware(RequestDelegate next) => _next = next; public async Task Invoke( HttpContext context, ITransportClient transportClient) { var decision = (RoutingDecision?)context.Items["Stella.RoutingDecision"]; if (decision is null) { context.Response.StatusCode = 500; await context.Response.WriteAsync("Routing decision missing"); return; } // Read request body into memory (safe for minimal tests) byte[] bodyBytes; using (var ms = new MemoryStream()) { await context.Request.Body.CopyToAsync(ms); bodyBytes = ms.ToArray(); } var requestPayload = new MinimalRequestPayload { Method = context.Request.Method, Path = context.Request.Path.ToString(), Body = bodyBytes // headers can be ignored or added later }; var requestFrame = new Frame { Type = FrameType.Request, CorrelationId = Guid.NewGuid(), Payload = SerializeRequestPayload(requestPayload) }; var timeout = decision.EffectiveTimeout; using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); cts.CancelAfter(timeout); Frame responseFrame; try { responseFrame = await transportClient.SendRequestAsync( decision.Connection, requestFrame, timeout, cts.Token); } catch (OperationCanceledException) { context.Response.StatusCode = StatusCodes.Status504GatewayTimeout; await context.Response.WriteAsync("Upstream timeout"); return; } var responsePayload = DeserializeResponsePayload(responseFrame.Payload); context.Response.StatusCode = responsePayload.StatusCode; foreach (var (k, v) in responsePayload.Headers) { context.Response.Headers[k] = v; } if (responsePayload.Body is { Length: > 0 }) { await context.Response.Body.WriteAsync(responsePayload.Body); } } } ``` You’ll need minimal DTOs and serializers (`MinimalRequestPayload`, `MinimalResponsePayload`) just to move bytes. You can use JSON for now; protocol details will be formalized later. Register it after `RoutingDecisionMiddleware`: ```csharp app.UseMiddleware(); ``` At this point, you no longer need ASP.NET controllers for microservice endpoints; you can have a catch-all pipeline. --- ## 5. Minimal end-to-end test **Owner:** test agent, probably in `StellaOps.Gateway.WebService.Tests` (plus a simple host for microservice in tests) Scenario: 1. Start an in-memory microservice host: * It uses `AddStellaMicroservice`. * It attaches to the same `IInMemoryRouterHub` instance as the gateway (created inside the test). * It has a single endpoint: * `[StellaEndpoint("GET", "/ping")]` * Handler returns “pong”. 2. Start the gateway host: * Inject the same `IInMemoryRouterHub`. * Use middlewares: `EndpointResolutionMiddleware`, `RoutingDecisionMiddleware`, `TransportDispatchMiddleware`. 3. Invoke HTTP `GET /ping` against the gateway (using `WebApplicationFactory` or `TestServer`). Assert: * HTTP status 200. * Body “pong”. * The router hub saw: * At least one HELLO frame. * One REQUEST frame. * One RESPONSE frame. This proves: * HELLO → gateway routing state population. * Endpoint resolution → connection selection. * InMemory transport client used. * Minimal dispatch works. --- ## 6. Done criteria for “Gateway: minimal routing using InMemory plugin” You’re done with this step when: * A microservice can register with the gateway via InMemory. * The gateway’s `IGlobalRoutingState` knows about endpoints and connections. * The HTTP pipeline: * Resolves an endpoint based on `(Method, Path)`. * Asks `IRoutingPlugin` for a connection. * Uses `ITransportClient` (InMemory) to send REQUEST and get RESPONSE. * Returns the mapped HTTP response to the client. * You have at least one automated test showing: * `GET /ping` through gateway → InMemory → microservice → back to HTTP. After this, you’re ready to: * Swap `NaiveRoutingPlugin` with the health/region-sensitive plugin you defined. * Implement heartbeat and latency. * Later replace InMemory with TCP/UDP/Rabbit without changing the HTTP pipeline.