555 lines
16 KiB
Markdown
555 lines
16 KiB
Markdown
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<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:
|
||
|
||
* `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<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 `SendFromGatewayAsync` and get a response frame.
|
||
|
||
### 2.2 Register it in DI
|
||
|
||
In gateway `Program.cs` or a DI setup:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```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<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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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.
|
||
|
||
```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<RoutingDecisionMiddleware>();
|
||
```
|
||
|
||
### 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<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:
|
||
|
||
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.
|