16 KiB
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.Commonexists and exposes:EndpointDescriptor,ConnectionState,Frame,FrameType,TransportType,RoutingDecision.- Interfaces:
IGlobalRoutingState,IRoutingPlugin,ITransportClient.
-
StellaOps.Microserviceminimal 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:
public interface IInMemoryRouterHub
{
// Called by microservice side to register a new connection
Task<string> RegisterMicroserviceAsync(
InstanceDescriptor instance,
IReadOnlyList<EndpointDescriptor> endpoints,
Func<Frame, Task> 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<Frame> SendFromGatewayAsync(string connectionId, Frame frame, CancellationToken ct);
}
Internally, the hub maintains per-connection data:
ConnectionIdInstanceDescriptor- Endpoints
- Delegate
onFrameFromGateway(microservice receiver)
For minimal routing you can start by:
- Only supporting
SendFromGatewayAsyncfor 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
RegisterMicroserviceAsyncon the hub when it sends HELLO:- Get
connectionId.
- Get
-
Provide a handler
onFrameFromGatewaythat:- Dispatches REQUEST frames via
IEndpointDispatcher. - Sends RESPONSE frames back via
SendFromMicroserviceAsync.
- Dispatches REQUEST frames via
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:
public sealed class InMemoryTransportClient : ITransportClient
{
private readonly IInMemoryRouterHub _hub;
public InMemoryTransportClient(IInMemoryRouterHub hub)
{
_hub = hub;
}
public Task<Frame> 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<Stream, Task> readResponseBody,
PayloadLimits limits,
CancellationToken ct)
=> throw new NotSupportedException("Streaming not implemented for InMemory in this step.");
}
For now:
- Ignore streaming.
- Ignore cancel.
- Just call
SendFromGatewayAsyncand get a response frame.
2.2 Register it in DI
In gateway Program.cs or a DI setup:
services.AddSingleton<IInMemoryRouterHub, InMemoryRouterHub>(); // your hub implementation
services.AddSingleton<ITransportClient, InMemoryTransportClient>();
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:
public sealed class InMemoryGlobalRoutingState : IGlobalRoutingState
{
private readonly object _lock = new();
private readonly Dictionary<(string, string), EndpointDescriptor> _endpoints = new();
private readonly List<ConnectionState> _connections = new();
public EndpointDescriptor? ResolveEndpoint(string method, string path)
{
lock (_lock)
{
_endpoints.TryGetValue((method, path), out var endpoint);
return endpoint;
}
}
public IReadOnlyList<ConnectionState> 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: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:
- Resolved to a logical endpoint.
- Routed to one connection.
- Dispatched via InMemory transport.
4.1 EndpointResolutionMiddleware
This maps (Method, Path) to an EndpointDescriptor.
Create a middleware:
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:
app.UseMiddleware<EndpointResolutionMiddleware>();
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:
public sealed class NaiveRoutingPlugin : IRoutingPlugin
{
private readonly IGlobalRoutingState _state;
public NaiveRoutingPlugin(IGlobalRoutingState state) => _state = state;
public Task<RoutingDecision?> 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<RoutingDecision?>(null);
var decision = new RoutingDecision
{
Endpoint = endpoint,
Connection = chosen,
TransportType = chosen.TransportType,
EffectiveTimeout = endpoint.DefaultTimeout
};
return Task.FromResult<RoutingDecision?>(decision);
}
}
Register it:
services.AddSingleton<IGlobalRoutingState, InMemoryGlobalRoutingState>();
services.AddSingleton<IRoutingPlugin, NaiveRoutingPlugin>();
4.3 RoutingDecisionMiddleware
This middleware grabs the endpoint descriptor and asks the routing plugin for a connection.
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:
app.UseMiddleware<RoutingDecisionMiddleware>();
4.4 TransportDispatchMiddleware
This middleware:
- Builds a REQUEST frame from HTTP.
- Uses
ITransportClientto send it to the chosen connection. - Writes the RESPONSE frame back to HTTP.
Minimal version (buffered, no streaming):
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:
app.UseMiddleware<TransportDispatchMiddleware>();
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:
-
Start an in-memory microservice host:
-
It uses
AddStellaMicroservice. -
It attaches to the same
IInMemoryRouterHubinstance as the gateway (created inside the test). -
It has a single endpoint:
[StellaEndpoint("GET", "/ping")]- Handler returns “pong”.
-
-
Start the gateway host:
- Inject the same
IInMemoryRouterHub. - Use middlewares:
EndpointResolutionMiddleware,RoutingDecisionMiddleware,TransportDispatchMiddleware.
- Inject the same
-
Invoke HTTP
GET /pingagainst the gateway (usingWebApplicationFactoryorTestServer).
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
IGlobalRoutingStateknows about endpoints and connections. -
The HTTP pipeline:
- Resolves an endpoint based on
(Method, Path). - Asks
IRoutingPluginfor a connection. - Uses
ITransportClient(InMemory) to send REQUEST and get RESPONSE. - Returns the mapped HTTP response to the client.
- Resolves an endpoint based on
-
You have at least one automated test showing:
GET /pingthrough gateway → InMemory → microservice → back to HTTP.
After this, you’re ready to:
- Swap
NaiveRoutingPluginwith the health/region-sensitive plugin you defined. - Implement heartbeat and latency.
- Later replace InMemory with TCP/UDP/Rabbit without changing the HTTP pipeline.