Files
git.stella-ops.org/docs/router/04-Step.md
2025-12-02 18:38:32 +02:00

521 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 its 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 isnt 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 dont 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 wont 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 steps 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, youll 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.