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

15 KiB
Raw Blame History

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:

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:
[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:
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):
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:

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:

internal sealed class EndpointRegistration
{
    public EndpointDescriptor Descriptor { get; init; } = default!;
    public Type HandlerType { get; init; } = default!;
}

Define an interface for discovery:

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:

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:

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:

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:

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:

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:

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:

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:

      [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.