521 lines
15 KiB
Markdown
521 lines
15 KiB
Markdown
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.
|