router planning
This commit is contained in:
520
docs/router/04-Step.md
Normal file
520
docs/router/04-Step.md
Normal file
@@ -0,0 +1,520 @@
|
||||
For this step, the goal is: a microservice that can:
|
||||
|
||||
* Start up with `AddStellaMicroservice(...)`
|
||||
* Discover its endpoints from attributes
|
||||
* Connect to the router (via InMemory transport)
|
||||
* Send a HELLO with identity + endpoints
|
||||
* Receive a REQUEST and return a RESPONSE
|
||||
|
||||
No streaming, no cancellation, no heartbeat yet. Pure minimal handshake & dispatch.
|
||||
|
||||
---
|
||||
|
||||
## 0. Preconditions
|
||||
|
||||
Before your agents start this step, you should have:
|
||||
|
||||
* `StellaOps.Router.Common` contracts in place (enums, `EndpointDescriptor`, `ConnectionState`, `Frame`, etc.).
|
||||
* The solution skeleton and project references configured.
|
||||
* A **stub** InMemory transport “router harness” (at least a place to park the future InMemory transport). Even if it’s not fully implemented, assume it will expose:
|
||||
|
||||
* A way for a microservice to “connect” and register itself.
|
||||
* A way to deliver frames from router to microservice and back.
|
||||
|
||||
If InMemory isn’t built yet, the microservice code should be written *against abstractions* so you can plug it in later.
|
||||
|
||||
---
|
||||
|
||||
## 1. Define microservice public surface (SDK contract)
|
||||
|
||||
**Project:** `__Libraries/StellaOps.Microservice`
|
||||
**Owner:** microservice SDK agent
|
||||
|
||||
Purpose: give product teams a stable way to define services and endpoints without caring about transports.
|
||||
|
||||
### 1.1 Options
|
||||
|
||||
Make sure `StellaMicroserviceOptions` matches the spec:
|
||||
|
||||
```csharp
|
||||
public sealed class StellaMicroserviceOptions
|
||||
{
|
||||
public string ServiceName { get; set; } = string.Empty;
|
||||
public string Version { get; set; } = string.Empty;
|
||||
public string Region { get; set; } = string.Empty;
|
||||
public string InstanceId { get; set; } = string.Empty;
|
||||
|
||||
public IList<RouterEndpointConfig> Routers { get; set; } = new List<RouterEndpointConfig>();
|
||||
|
||||
public string? ConfigFilePath { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RouterEndpointConfig
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public int Port { get; set; }
|
||||
public TransportType TransportType { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
`Routers` is mandatory: without at least one router configured, the SDK should refuse to start later (that policy can be enforced in the handshake stage).
|
||||
|
||||
### 1.2 Public endpoint abstractions
|
||||
|
||||
Define:
|
||||
|
||||
* Attribute for endpoint identity:
|
||||
|
||||
```csharp
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
||||
public sealed class StellaEndpointAttribute : Attribute
|
||||
{
|
||||
public string Method { get; }
|
||||
public string Path { get; }
|
||||
|
||||
public StellaEndpointAttribute(string method, string path)
|
||||
{
|
||||
Method = method;
|
||||
Path = path;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* Raw handler:
|
||||
|
||||
```csharp
|
||||
public sealed class RawRequestContext
|
||||
{
|
||||
public string Method { get; init; } = string.Empty;
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public IReadOnlyDictionary<string,string> Headers { get; init; } =
|
||||
new Dictionary<string,string>();
|
||||
public Stream Body { get; init; } = Stream.Null;
|
||||
public CancellationToken CancellationToken { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RawResponse
|
||||
{
|
||||
public int StatusCode { get; set; } = 200;
|
||||
public IDictionary<string,string> Headers { get; } =
|
||||
new Dictionary<string,string>();
|
||||
public Func<Stream,Task>? WriteBodyAsync { get; set; } // may be null
|
||||
}
|
||||
|
||||
public interface IRawStellaEndpoint
|
||||
{
|
||||
Task<RawResponse> HandleAsync(RawRequestContext ctx);
|
||||
}
|
||||
```
|
||||
|
||||
* Typed convenience interfaces (used later, but define now):
|
||||
|
||||
```csharp
|
||||
public interface IStellaEndpoint<TRequest,TResponse>
|
||||
{
|
||||
Task<TResponse> HandleAsync(TRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IStellaEndpoint<TResponse>
|
||||
{
|
||||
Task<TResponse> HandleAsync(CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
At this step, you don’t need to implement adapters yet, but the signatures must be fixed.
|
||||
|
||||
### 1.3 Registration extension
|
||||
|
||||
Extend `AddStellaMicroservice` to wire options + a few internal services:
|
||||
|
||||
```csharp
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddStellaMicroservice(
|
||||
this IServiceCollection services,
|
||||
Action<StellaMicroserviceOptions> configure)
|
||||
{
|
||||
services.Configure(configure);
|
||||
|
||||
services.AddSingleton<IEndpointCatalog, EndpointCatalog>(); // to be implemented
|
||||
services.AddSingleton<IEndpointDispatcher, EndpointDispatcher>(); // to be implemented
|
||||
|
||||
services.AddHostedService<MicroserviceBootstrapHostedService>(); // handshake loop
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This still compiles with empty implementations; you fill them in next steps.
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoint discovery (reflection only for now)
|
||||
|
||||
**Project:** `StellaOps.Microservice`
|
||||
**Owner:** SDK agent
|
||||
|
||||
Goal: given the entry assembly, build:
|
||||
|
||||
* A list of `EndpointDescriptor` objects (from Common).
|
||||
* A mapping `(Method, Path) -> handler type` used for dispatch.
|
||||
|
||||
### 2.1 Internal types
|
||||
|
||||
Define an internal representation:
|
||||
|
||||
```csharp
|
||||
internal sealed class EndpointRegistration
|
||||
{
|
||||
public EndpointDescriptor Descriptor { get; init; } = default!;
|
||||
public Type HandlerType { get; init; } = default!;
|
||||
}
|
||||
```
|
||||
|
||||
Define an interface for discovery:
|
||||
|
||||
```csharp
|
||||
internal interface IEndpointDiscovery
|
||||
{
|
||||
IReadOnlyList<EndpointRegistration> DiscoverEndpoints(StellaMicroserviceOptions options);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Implement reflection-based discovery
|
||||
|
||||
Create `ReflectionEndpointDiscovery`:
|
||||
|
||||
* Scan the entry assembly (and optionally referenced assemblies) for classes that:
|
||||
|
||||
* Have `StellaEndpointAttribute`.
|
||||
* Implement either:
|
||||
|
||||
* `IRawStellaEndpoint`, or
|
||||
* `IStellaEndpoint<,>`, or
|
||||
* `IStellaEndpoint<>`.
|
||||
|
||||
* For each `[StellaEndpoint]` usage:
|
||||
|
||||
* Create `EndpointDescriptor` with:
|
||||
|
||||
* `ServiceName` = `options.ServiceName`.
|
||||
* `Version` = `options.Version`.
|
||||
* `Method`, `Path` from attribute.
|
||||
* `DefaultTimeout` = some sensible default (e.g. `TimeSpan.FromSeconds(30)`; refine later).
|
||||
* `SupportsStreaming` = `false` (for now).
|
||||
* `RequiringClaims` = empty array (for now).
|
||||
|
||||
* Create `EndpointRegistration` with `Descriptor` + `HandlerType`.
|
||||
|
||||
* Return the list.
|
||||
|
||||
Wire it into DI:
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<IEndpointDiscovery, ReflectionEndpointDiscovery>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Endpoint catalog & dispatcher (microservice internal)
|
||||
|
||||
**Project:** `StellaOps.Microservice`
|
||||
**Owner:** SDK agent
|
||||
|
||||
Goal: presence of:
|
||||
|
||||
* A catalog holding endpoints and descriptors.
|
||||
* A dispatcher that takes frames and calls handlers.
|
||||
|
||||
### 3.1 Endpoint catalog
|
||||
|
||||
Define:
|
||||
|
||||
```csharp
|
||||
internal interface IEndpointCatalog
|
||||
{
|
||||
IReadOnlyList<EndpointDescriptor> Descriptors { get; }
|
||||
bool TryGetHandler(string method, string path, out EndpointRegistration endpoint);
|
||||
}
|
||||
|
||||
internal sealed class EndpointCatalog : IEndpointCatalog
|
||||
{
|
||||
private readonly Dictionary<(string Method, string Path), EndpointRegistration> _map;
|
||||
public IReadOnlyList<EndpointDescriptor> Descriptors { get; }
|
||||
|
||||
public EndpointCatalog(IEndpointDiscovery discovery,
|
||||
IOptions<StellaMicroserviceOptions> optionsAccessor)
|
||||
{
|
||||
var options = optionsAccessor.Value;
|
||||
var registrations = discovery.DiscoverEndpoints(options);
|
||||
|
||||
_map = registrations.ToDictionary(
|
||||
r => (r.Descriptor.Method, r.Descriptor.Path),
|
||||
r => r,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Descriptors = registrations.Select(r => r.Descriptor).ToArray();
|
||||
}
|
||||
|
||||
public bool TryGetHandler(string method, string path, out EndpointRegistration endpoint) =>
|
||||
_map.TryGetValue((method, path), out endpoint!);
|
||||
}
|
||||
```
|
||||
|
||||
You can refine path normalization later; for now, keep it simple.
|
||||
|
||||
### 3.2 Endpoint dispatcher
|
||||
|
||||
Define:
|
||||
|
||||
```csharp
|
||||
internal interface IEndpointDispatcher
|
||||
{
|
||||
Task<Frame> HandleRequestAsync(Frame requestFrame, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
Implement `EndpointDispatcher` with minimal behavior:
|
||||
|
||||
1. Decode `requestFrame.Payload` into a small DTO carrying:
|
||||
|
||||
* Method
|
||||
* Path
|
||||
* Headers (if you already have a format; if not, assume no headers in v0)
|
||||
* Body bytes
|
||||
|
||||
For this step, you can stub decoding as:
|
||||
|
||||
* Payload = raw body bytes.
|
||||
* Method/Path are carried separately in frame header or in a simple DTO; decide a minimal interim format and write it down.
|
||||
|
||||
2. Use `IEndpointCatalog.TryGetHandler(method, path, ...)`:
|
||||
|
||||
* If not found:
|
||||
|
||||
* Build a `RawResponse` with status 404 and empty body.
|
||||
|
||||
3. If handler implements `IRawStellaEndpoint`:
|
||||
|
||||
* Instantiate via DI (`IServiceProvider.GetRequiredService(handlerType)`).
|
||||
* Build `RawRequestContext` with:
|
||||
|
||||
* Method, Path, Headers, Body (`new MemoryStream(bodyBytes)` for now).
|
||||
* `CancellationToken` = `ct`.
|
||||
* Call `HandleAsync`.
|
||||
* Convert `RawResponse` into a response frame payload.
|
||||
|
||||
4. If handler implements `IStellaEndpoint<,>` (typed):
|
||||
|
||||
* For now, **you can skip typed handling** or wire a very simple JSON-based adapter if you want to unlock it early. The focus in this step is the raw path; typed adapters can come in the next iteration.
|
||||
|
||||
Return a `Frame` with:
|
||||
|
||||
* `Type = FrameType.Response`
|
||||
* `CorrelationId` = `requestFrame.CorrelationId`
|
||||
* `Payload` = encoded response (status + body bytes).
|
||||
|
||||
No streaming, no cancellation logic beyond passing `ct` through — router won’t cancel yet.
|
||||
|
||||
---
|
||||
|
||||
## 4. Minimal handshake hosted service (using InMemory)
|
||||
|
||||
**Project:** `StellaOps.Microservice`
|
||||
**Owner:** SDK agent
|
||||
|
||||
This is where the microservice actually “talks” to the router.
|
||||
|
||||
### 4.1 Define a microservice connection abstraction
|
||||
|
||||
Your SDK should not depend directly on InMemory; define an internal abstraction:
|
||||
|
||||
```csharp
|
||||
internal interface IMicroserviceConnection
|
||||
{
|
||||
Task StartAsync(CancellationToken ct);
|
||||
Task StopAsync(CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
The implementation for this step will target the InMemory transport; later you can add TCP/TLS/RabbitMQ versions.
|
||||
|
||||
### 4.2 Implement InMemory microservice connection
|
||||
|
||||
Assuming you have or will have an `IInMemoryRouter` (or similar) dev harness, implement:
|
||||
|
||||
```csharp
|
||||
internal sealed class InMemoryMicroserviceConnection : IMicroserviceConnection
|
||||
{
|
||||
private readonly IEndpointCatalog _catalog;
|
||||
private readonly IEndpointDispatcher _dispatcher;
|
||||
private readonly IOptions<StellaMicroserviceOptions> _options;
|
||||
private readonly IInMemoryRouterClient _routerClient; // dev-only abstraction
|
||||
|
||||
public InMemoryMicroserviceConnection(
|
||||
IEndpointCatalog catalog,
|
||||
IEndpointDispatcher dispatcher,
|
||||
IOptions<StellaMicroserviceOptions> options,
|
||||
IInMemoryRouterClient routerClient)
|
||||
{
|
||||
_catalog = catalog;
|
||||
_dispatcher = dispatcher;
|
||||
_options = options;
|
||||
_routerClient = routerClient;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
|
||||
// Build HELLO payload from options + catalog.Descriptors
|
||||
var helloPayload = BuildHelloPayload(opts, _catalog.Descriptors);
|
||||
|
||||
await _routerClient.ConnectAsync(opts, ct);
|
||||
await _routerClient.SendHelloAsync(helloPayload, ct);
|
||||
|
||||
// Start background receive loop
|
||||
_ = Task.Run(() => ReceiveLoopAsync(ct), ct);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
// For now: ask routerClient to disconnect; finer handling later
|
||||
return _routerClient.DisconnectAsync(ct);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
await foreach (var frame in _routerClient.GetIncomingFramesAsync(ct))
|
||||
{
|
||||
if (frame.Type == FrameType.Request)
|
||||
{
|
||||
var response = await _dispatcher.HandleRequestAsync(frame, ct);
|
||||
await _routerClient.SendFrameAsync(response, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ignore other frame types in this minimal step
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`IInMemoryRouterClient` is whatever dev harness you build for the in-memory transport; the exact shape is not important for this step’s planning, only that it provides:
|
||||
|
||||
* `ConnectAsync`
|
||||
* `SendHelloAsync`
|
||||
* `GetIncomingFramesAsync` (async stream of frames)
|
||||
* `SendFrameAsync` for responses
|
||||
* `DisconnectAsync`
|
||||
|
||||
### 4.3 Hosted service to bootstrap the connection
|
||||
|
||||
Implement `MicroserviceBootstrapHostedService`:
|
||||
|
||||
```csharp
|
||||
internal sealed class MicroserviceBootstrapHostedService : IHostedService
|
||||
{
|
||||
private readonly IMicroserviceConnection _connection;
|
||||
|
||||
public MicroserviceBootstrapHostedService(IMicroserviceConnection connection)
|
||||
{
|
||||
_connection = connection;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) =>
|
||||
_connection.StartAsync(cancellationToken);
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) =>
|
||||
_connection.StopAsync(cancellationToken);
|
||||
}
|
||||
```
|
||||
|
||||
Wire `IMicroserviceConnection` to `InMemoryMicroserviceConnection` in DI for now:
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<IMicroserviceConnection, InMemoryMicroserviceConnection>();
|
||||
```
|
||||
|
||||
In a later phase, you’ll swap this to transport-specific connectors.
|
||||
|
||||
---
|
||||
|
||||
## 5. End-to-end smoke test (InMemory only)
|
||||
|
||||
**Project:** `StellaOps.Microservice.Tests` + a minimal InMemory router test harness
|
||||
**Owner:** test agent
|
||||
|
||||
Goal: prove that minimal handshake & dispatch works in memory.
|
||||
|
||||
1. Build a trivial test microservice:
|
||||
|
||||
* Define a handler:
|
||||
|
||||
```csharp
|
||||
[StellaEndpoint("GET", "/ping")]
|
||||
public sealed class PingEndpoint : IRawStellaEndpoint
|
||||
{
|
||||
public Task<RawResponse> HandleAsync(RawRequestContext ctx)
|
||||
{
|
||||
var resp = new RawResponse { StatusCode = 200 };
|
||||
resp.Headers["Content-Type"] = "text/plain";
|
||||
resp.WriteBodyAsync = stream => stream.WriteAsync(
|
||||
Encoding.UTF8.GetBytes("pong"));
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Test harness:
|
||||
|
||||
* Spin up:
|
||||
|
||||
* An instance of the microservice host (generic HostBuilder).
|
||||
* An in-memory “router” that:
|
||||
|
||||
* Accepts HELLO from the microservice.
|
||||
* Sends a single REQUEST frame for `GET /ping`.
|
||||
* Receives the RESPONSE frame.
|
||||
|
||||
3. Assert:
|
||||
|
||||
* The HELLO includes the `/ping` endpoint.
|
||||
* The REQUEST is dispatched to `PingEndpoint`.
|
||||
* The RESPONSE has status 200 and body “pong”.
|
||||
|
||||
This verifies that:
|
||||
|
||||
* `AddStellaMicroservice` wires discovery, catalog, dispatcher, bootstrap.
|
||||
* The microservice sends HELLO on connect.
|
||||
* The microservice can handle at least one request via InMemory.
|
||||
|
||||
---
|
||||
|
||||
## 6. Done criteria for “minimal handshake & dispatch”
|
||||
|
||||
You can consider this step complete when:
|
||||
|
||||
* `StellaOps.Microservice` exposes:
|
||||
|
||||
* Options.
|
||||
* Attribute & handler interfaces (raw + typed).
|
||||
* `AddStellaMicroservice` registering discovery, catalog, dispatcher, and hosted service.
|
||||
* The microservice can:
|
||||
|
||||
* Discover endpoints via reflection.
|
||||
* Build a `HELLO` payload and send it over InMemory on startup.
|
||||
* Receive a `REQUEST` frame over InMemory.
|
||||
* Dispatch that request to the correct handler.
|
||||
* Return a `RESPONSE` frame.
|
||||
|
||||
Not yet required in this step:
|
||||
|
||||
* Streaming bodies.
|
||||
* Heartbeats or health evaluation.
|
||||
* Cancellation via CANCEL frames.
|
||||
* Authority overrides for requiringClaims.
|
||||
|
||||
Those come in subsequent phases; right now you just want a working minimal vertical slice: InMemory microservice that says “HELLO” and responds to one simple request.
|
||||
Reference in New Issue
Block a user