Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
25 KiB
25 KiB
Step 21: Request/Response Context
Phase 5: Microservice SDK Estimated Complexity: Medium Dependencies: Step 20 (Endpoint Discovery)
Overview
The Request/Response Context provides a clean abstraction for endpoint handlers to access request data, claims, and build responses. It hides transport details while providing easy access to parsed path parameters, query strings, headers, and the request body.
Goals
- Provide clean request context abstraction
- Support path parameter extraction
- Provide typed body deserialization
- Support streaming responses
- Enable easy response building
Request Context
namespace StellaOps.Microservice;
/// <summary>
/// Context for handling a request in a microservice endpoint.
/// </summary>
public sealed class StellaRequestContext
{
private readonly RequestPayload _payload;
private readonly Dictionary<string, string> _pathParameters;
private readonly Lazy<IQueryCollection> _query;
private readonly Lazy<IHeaderDictionary> _headers;
internal StellaRequestContext(
RequestPayload payload,
Dictionary<string, string> pathParameters)
{
_payload = payload;
_pathParameters = pathParameters;
_query = new Lazy<IQueryCollection>(() => ParseQuery(payload.Path));
_headers = new Lazy<IHeaderDictionary>(() => new HeaderDictionary(
payload.Headers.ToDictionary(
h => h.Key,
h => new StringValues(h.Value))));
}
/// <summary>HTTP method.</summary>
public string Method => _payload.Method;
/// <summary>Request path (without query string).</summary>
public string Path => _payload.Path.Split('?')[0];
/// <summary>Full path including query string.</summary>
public string FullPath => _payload.Path;
/// <summary>Host header value.</summary>
public string? Host => _payload.Host;
/// <summary>Client IP address.</summary>
public string? ClientIp => _payload.ClientIp;
/// <summary>Trace/correlation ID.</summary>
public string? TraceId => _payload.TraceId;
/// <summary>Request headers.</summary>
public IHeaderDictionary Headers => _headers.Value;
/// <summary>Query string parameters.</summary>
public IQueryCollection Query => _query.Value;
/// <summary>Authenticated claims from JWT + hydration.</summary>
public IReadOnlyDictionary<string, string> Claims => _payload.Claims;
/// <summary>Path parameters extracted from route pattern.</summary>
public IReadOnlyDictionary<string, string> PathParameters => _pathParameters;
/// <summary>Content-Type header value.</summary>
public string? ContentType => Headers.ContentType;
/// <summary>Content-Length header value.</summary>
public long? ContentLength => _payload.ContentLength > 0 ? _payload.ContentLength : null;
/// <summary>Whether the request has a body.</summary>
public bool HasBody => _payload.Body != null && _payload.Body.Length > 0;
/// <summary>Raw request body bytes.</summary>
public byte[]? RawBody => _payload.Body;
/// <summary>
/// Gets a path parameter by name.
/// </summary>
public string? GetPathParameter(string name)
{
return _pathParameters.TryGetValue(name, out var value) ? value : null;
}
/// <summary>
/// Gets a required path parameter, throws if missing.
/// </summary>
public string RequirePathParameter(string name)
{
return _pathParameters.TryGetValue(name, out var value)
? value
: throw new ArgumentException($"Missing path parameter: {name}");
}
/// <summary>
/// Gets a query parameter by name.
/// </summary>
public string? GetQueryParameter(string name)
{
return Query.TryGetValue(name, out var values) ? values.FirstOrDefault() : null;
}
/// <summary>
/// Gets all values for a query parameter.
/// </summary>
public string[] GetQueryParameterValues(string name)
{
return Query.TryGetValue(name, out var values) ? values.ToArray() : Array.Empty<string>();
}
/// <summary>
/// Gets a header value by name.
/// </summary>
public string? GetHeader(string name)
{
return Headers.TryGetValue(name, out var values) ? values.FirstOrDefault() : null;
}
/// <summary>
/// Gets a claim value by name.
/// </summary>
public string? GetClaim(string name)
{
return Claims.TryGetValue(name, out var value) ? value : null;
}
/// <summary>
/// Gets a required claim, throws if missing.
/// </summary>
public string RequireClaim(string name)
{
return Claims.TryGetValue(name, out var value)
? value
: throw new UnauthorizedAccessException($"Missing required claim: {name}");
}
/// <summary>
/// Reads the body as a string.
/// </summary>
public string? ReadBodyAsString(Encoding? encoding = null)
{
if (_payload.Body == null || _payload.Body.Length == 0)
return null;
return (encoding ?? Encoding.UTF8).GetString(_payload.Body);
}
/// <summary>
/// Deserializes the body as JSON.
/// </summary>
public T? ReadBodyAsJson<T>(JsonSerializerOptions? options = null)
{
if (_payload.Body == null || _payload.Body.Length == 0)
return default;
return JsonSerializer.Deserialize<T>(_payload.Body, options ?? JsonDefaults.Options);
}
/// <summary>
/// Deserializes the body as JSON, throwing if null or invalid.
/// </summary>
public T RequireBodyAsJson<T>(JsonSerializerOptions? options = null) where T : class
{
var result = ReadBodyAsJson<T>(options);
return result ?? throw new ArgumentException("Request body is required");
}
/// <summary>
/// Gets a body stream for reading.
/// </summary>
public Stream GetBodyStream()
{
return new MemoryStream(_payload.Body ?? Array.Empty<byte>(), writable: false);
}
private static IQueryCollection ParseQuery(string path)
{
var queryIndex = path.IndexOf('?');
if (queryIndex < 0)
return QueryCollection.Empty;
var queryString = path[(queryIndex + 1)..];
return QueryHelpers.ParseQuery(queryString);
}
}
internal static class JsonDefaults
{
public static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
Response Builder
namespace StellaOps.Microservice;
/// <summary>
/// Builder for constructing endpoint responses.
/// </summary>
public sealed class StellaResponseBuilder
{
private int _statusCode = 200;
private readonly Dictionary<string, string> _headers = new(StringComparer.OrdinalIgnoreCase);
private byte[]? _body;
private string _contentType = "application/json";
/// <summary>
/// Creates a new response builder.
/// </summary>
public static StellaResponseBuilder Create() => new();
/// <summary>
/// Sets the status code.
/// </summary>
public StellaResponseBuilder WithStatus(int statusCode)
{
_statusCode = statusCode;
return this;
}
/// <summary>
/// Sets a response header.
/// </summary>
public StellaResponseBuilder WithHeader(string name, string value)
{
_headers[name] = value;
return this;
}
/// <summary>
/// Sets multiple response headers.
/// </summary>
public StellaResponseBuilder WithHeaders(IEnumerable<KeyValuePair<string, string>> headers)
{
foreach (var (key, value) in headers)
{
_headers[key] = value;
}
return this;
}
/// <summary>
/// Sets the Content-Type header.
/// </summary>
public StellaResponseBuilder WithContentType(string contentType)
{
_contentType = contentType;
return this;
}
/// <summary>
/// Sets a JSON body.
/// </summary>
public StellaResponseBuilder WithJson<T>(T value, JsonSerializerOptions? options = null)
{
_contentType = "application/json";
_body = JsonSerializer.SerializeToUtf8Bytes(value, options ?? JsonDefaults.Options);
return this;
}
/// <summary>
/// Sets a string body.
/// </summary>
public StellaResponseBuilder WithText(string text, Encoding? encoding = null)
{
if (!_headers.ContainsKey("Content-Type") && _contentType == "application/json")
{
_contentType = "text/plain";
}
_body = (encoding ?? Encoding.UTF8).GetBytes(text);
return this;
}
/// <summary>
/// Sets raw bytes as body.
/// </summary>
public StellaResponseBuilder WithBytes(byte[] data, string? contentType = null)
{
if (contentType != null)
{
_contentType = contentType;
}
_body = data;
return this;
}
/// <summary>
/// Sets a stream as body.
/// </summary>
public StellaResponseBuilder WithStream(Stream stream, string? contentType = null)
{
if (contentType != null)
{
_contentType = contentType;
}
using var ms = new MemoryStream();
stream.CopyTo(ms);
_body = ms.ToArray();
return this;
}
/// <summary>
/// Builds the response payload.
/// </summary>
public ResponsePayload Build()
{
_headers["Content-Type"] = _contentType;
return new ResponsePayload
{
StatusCode = _statusCode,
Headers = new Dictionary<string, string>(_headers),
Body = _body,
IsFinalChunk = true
};
}
// Static factory methods for common responses
/// <summary>Creates a 200 OK response with JSON body.</summary>
public static ResponsePayload Ok<T>(T value) =>
Create().WithStatus(200).WithJson(value).Build();
/// <summary>Creates a 200 OK response with no body.</summary>
public static ResponsePayload Ok() =>
Create().WithStatus(200).Build();
/// <summary>Creates a 201 Created response with JSON body.</summary>
public static ResponsePayload Created<T>(T value, string? location = null)
{
var builder = Create().WithStatus(201).WithJson(value);
if (location != null)
{
builder.WithHeader("Location", location);
}
return builder.Build();
}
/// <summary>Creates a 204 No Content response.</summary>
public static ResponsePayload NoContent() =>
Create().WithStatus(204).Build();
/// <summary>Creates a 400 Bad Request response.</summary>
public static ResponsePayload BadRequest(string message) =>
Create().WithStatus(400).WithJson(new { error = message }).Build();
/// <summary>Creates a 400 Bad Request response with validation errors.</summary>
public static ResponsePayload BadRequest(Dictionary<string, string[]> errors) =>
Create().WithStatus(400).WithJson(new { errors }).Build();
/// <summary>Creates a 401 Unauthorized response.</summary>
public static ResponsePayload Unauthorized(string? message = null) =>
Create().WithStatus(401).WithJson(new { error = message ?? "Unauthorized" }).Build();
/// <summary>Creates a 403 Forbidden response.</summary>
public static ResponsePayload Forbidden(string? message = null) =>
Create().WithStatus(403).WithJson(new { error = message ?? "Forbidden" }).Build();
/// <summary>Creates a 404 Not Found response.</summary>
public static ResponsePayload NotFound(string? message = null) =>
Create().WithStatus(404).WithJson(new { error = message ?? "Not found" }).Build();
/// <summary>Creates a 409 Conflict response.</summary>
public static ResponsePayload Conflict(string message) =>
Create().WithStatus(409).WithJson(new { error = message }).Build();
/// <summary>Creates a 500 Internal Server Error response.</summary>
public static ResponsePayload InternalError(string? message = null) =>
Create().WithStatus(500).WithJson(new { error = message ?? "Internal server error" }).Build();
/// <summary>Creates a 503 Service Unavailable response.</summary>
public static ResponsePayload ServiceUnavailable(string? message = null) =>
Create().WithStatus(503).WithJson(new { error = message ?? "Service unavailable" }).Build();
/// <summary>Creates a redirect response.</summary>
public static ResponsePayload Redirect(string location, bool permanent = false) =>
Create()
.WithStatus(permanent ? 301 : 302)
.WithHeader("Location", location)
.Build();
}
Endpoint Handler Interface
namespace StellaOps.Microservice;
/// <summary>
/// Interface for endpoint handler classes.
/// </summary>
public interface IEndpointHandler
{
}
/// <summary>
/// Base class for endpoint handlers with helper methods.
/// </summary>
public abstract class EndpointHandler : IEndpointHandler
{
/// <summary>Current request context (set by dispatcher).</summary>
public StellaRequestContext Context { get; internal set; } = null!;
/// <summary>Creates a 200 OK response with JSON body.</summary>
protected ResponsePayload Ok<T>(T value) => StellaResponseBuilder.Ok(value);
/// <summary>Creates a 200 OK response with no body.</summary>
protected ResponsePayload Ok() => StellaResponseBuilder.Ok();
/// <summary>Creates a 201 Created response.</summary>
protected ResponsePayload Created<T>(T value, string? location = null) =>
StellaResponseBuilder.Created(value, location);
/// <summary>Creates a 204 No Content response.</summary>
protected ResponsePayload NoContent() => StellaResponseBuilder.NoContent();
/// <summary>Creates a 400 Bad Request response.</summary>
protected ResponsePayload BadRequest(string message) =>
StellaResponseBuilder.BadRequest(message);
/// <summary>Creates a 401 Unauthorized response.</summary>
protected ResponsePayload Unauthorized(string? message = null) =>
StellaResponseBuilder.Unauthorized(message);
/// <summary>Creates a 403 Forbidden response.</summary>
protected ResponsePayload Forbidden(string? message = null) =>
StellaResponseBuilder.Forbidden(message);
/// <summary>Creates a 404 Not Found response.</summary>
protected ResponsePayload NotFound(string? message = null) =>
StellaResponseBuilder.NotFound(message);
/// <summary>Creates a response with custom status and body.</summary>
protected StellaResponseBuilder Response() => StellaResponseBuilder.Create();
}
Request Dispatcher
namespace StellaOps.Microservice;
public interface IRequestDispatcher
{
Task<ResponsePayload> DispatchAsync(RequestPayload request, CancellationToken cancellationToken);
}
public sealed class RequestDispatcher : IRequestDispatcher
{
private readonly IEndpointRegistry _registry;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<RequestDispatcher> _logger;
public RequestDispatcher(
IEndpointRegistry registry,
IServiceProvider serviceProvider,
ILogger<RequestDispatcher> logger)
{
_registry = registry;
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task<ResponsePayload> DispatchAsync(
RequestPayload request,
CancellationToken cancellationToken)
{
var path = request.Path.Split('?')[0];
var endpoint = _registry.FindEndpoint(request.Method, path);
if (endpoint == null)
{
_logger.LogDebug("No endpoint found for {Method} {Path}", request.Method, path);
return StellaResponseBuilder.NotFound($"No endpoint: {request.Method} {path}");
}
// Extract path parameters
var pathParams = ExtractPathParameters(path, endpoint.Descriptor.Path);
// Create request context
var context = new StellaRequestContext(request, pathParams);
// Create handler instance
using var scope = _serviceProvider.CreateScope();
var handler = scope.ServiceProvider.GetService(endpoint.HandlerType);
if (handler == null)
{
// Try to create without DI
handler = Activator.CreateInstance(endpoint.HandlerType);
}
if (handler == null)
{
_logger.LogError("Cannot create handler {Type}", endpoint.HandlerType);
return StellaResponseBuilder.InternalError("Handler instantiation failed");
}
// Set context on base handler
if (handler is EndpointHandler baseHandler)
{
baseHandler.Context = context;
}
try
{
// Invoke handler method
var result = endpoint.HandlerMethod.Invoke(handler, BuildMethodParameters(
endpoint.HandlerMethod, context, cancellationToken));
// Handle async methods
if (result is Task<ResponsePayload> taskResponse)
{
return await taskResponse;
}
else if (result is Task task)
{
await task;
// Method returned Task without result - assume OK
return StellaResponseBuilder.Ok();
}
else if (result is ResponsePayload response)
{
return response;
}
else if (result != null)
{
// Serialize result as JSON
return StellaResponseBuilder.Ok(result);
}
else
{
return StellaResponseBuilder.NoContent();
}
}
catch (TargetInvocationException ex) when (ex.InnerException != null)
{
throw ex.InnerException;
}
}
private Dictionary<string, string> ExtractPathParameters(string actualPath, string pattern)
{
var result = new Dictionary<string, string>();
var patternSegments = pattern.Split('/', StringSplitOptions.RemoveEmptyEntries);
var pathSegments = actualPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < patternSegments.Length && i < pathSegments.Length; i++)
{
var patternSeg = patternSegments[i];
if (patternSeg.StartsWith('{') && patternSeg.EndsWith('}'))
{
var paramName = patternSeg[1..^1];
result[paramName] = pathSegments[i];
}
}
return result;
}
private object?[] BuildMethodParameters(
MethodInfo method,
StellaRequestContext context,
CancellationToken cancellationToken)
{
var parameters = method.GetParameters();
var args = new object?[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
var param = parameters[i];
var paramType = param.ParameterType;
if (paramType == typeof(StellaRequestContext))
{
args[i] = context;
}
else if (paramType == typeof(CancellationToken))
{
args[i] = cancellationToken;
}
else if (param.GetCustomAttribute<FromPathAttribute>() != null)
{
var value = context.GetPathParameter(param.Name ?? "");
args[i] = ConvertParameter(value, paramType);
}
else if (param.GetCustomAttribute<FromQueryAttribute>() != null)
{
var value = context.GetQueryParameter(param.Name ?? "");
args[i] = ConvertParameter(value, paramType);
}
else if (param.GetCustomAttribute<FromHeaderAttribute>() != null)
{
var headerName = param.GetCustomAttribute<FromHeaderAttribute>()?.Name ?? param.Name;
var value = context.GetHeader(headerName ?? "");
args[i] = ConvertParameter(value, paramType);
}
else if (param.GetCustomAttribute<FromClaimAttribute>() != null)
{
var claimName = param.GetCustomAttribute<FromClaimAttribute>()?.Name ?? param.Name;
var value = context.GetClaim(claimName ?? "");
args[i] = ConvertParameter(value, paramType);
}
else if (param.GetCustomAttribute<FromBodyAttribute>() != null || IsComplexType(paramType))
{
// Deserialize body
args[i] = context.ReadBodyAsJson(paramType);
}
else
{
args[i] = param.HasDefaultValue ? param.DefaultValue : null;
}
}
return args;
}
private static object? ConvertParameter(string? value, Type targetType)
{
if (value == null)
return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
if (targetType == typeof(string))
return value;
if (targetType == typeof(int) || targetType == typeof(int?))
return int.TryParse(value, out var i) ? i : null;
if (targetType == typeof(long) || targetType == typeof(long?))
return long.TryParse(value, out var l) ? l : null;
if (targetType == typeof(Guid) || targetType == typeof(Guid?))
return Guid.TryParse(value, out var g) ? g : null;
if (targetType == typeof(bool) || targetType == typeof(bool?))
return bool.TryParse(value, out var b) ? b : null;
return Convert.ChangeType(value, targetType);
}
private static bool IsComplexType(Type type)
{
return !type.IsPrimitive &&
type != typeof(string) &&
type != typeof(decimal) &&
type != typeof(Guid) &&
type != typeof(DateTime) &&
type != typeof(DateTimeOffset) &&
!type.IsEnum;
}
private object? ReadBodyAsJson(StellaRequestContext context, Type targetType)
{
if (!context.HasBody)
return null;
var json = context.RawBody;
return JsonSerializer.Deserialize(json, targetType, JsonDefaults.Options);
}
}
Parameter Binding Attributes
namespace StellaOps.Microservice;
[AttributeUsage(AttributeTargets.Parameter)]
public sealed class FromPathAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Parameter)]
public sealed class FromQueryAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Parameter)]
public sealed class FromHeaderAttribute : Attribute
{
public string? Name { get; set; }
}
[AttributeUsage(AttributeTargets.Parameter)]
public sealed class FromClaimAttribute : Attribute
{
public string? Name { get; set; }
}
[AttributeUsage(AttributeTargets.Parameter)]
public sealed class FromBodyAttribute : Attribute { }
Usage Example
[StellaEndpoint(BasePath = "/billing")]
public class InvoiceHandler : EndpointHandler
{
private readonly InvoiceService _service;
public InvoiceHandler(InvoiceService service)
{
_service = service;
}
[StellaGet("invoices/{id}")]
public async Task<ResponsePayload> GetInvoice(
[FromPath] Guid id,
CancellationToken cancellationToken)
{
var invoice = await _service.GetByIdAsync(id, cancellationToken);
if (invoice == null)
return NotFound($"Invoice {id} not found");
return Ok(invoice);
}
[StellaPost("invoices")]
[StellaAuth(RequiredClaims = new[] { "billing:write" })]
public async Task<ResponsePayload> CreateInvoice(
[FromBody] CreateInvoiceRequest request,
[FromClaim(Name = "sub")] string userId,
CancellationToken cancellationToken)
{
var invoice = await _service.CreateAsync(request, userId, cancellationToken);
return Created(invoice, $"/billing/invoices/{invoice.Id}");
}
[StellaGet("invoices")]
public async Task<ResponsePayload> ListInvoices(
StellaRequestContext context,
CancellationToken cancellationToken)
{
var page = int.Parse(context.GetQueryParameter("page") ?? "1");
var pageSize = int.Parse(context.GetQueryParameter("pageSize") ?? "20");
var invoices = await _service.ListAsync(page, pageSize, cancellationToken);
return Ok(invoices);
}
}
Deliverables
StellaOps.Microservice/StellaRequestContext.csStellaOps.Microservice/StellaResponseBuilder.csStellaOps.Microservice/IEndpointHandler.csStellaOps.Microservice/EndpointHandler.csStellaOps.Microservice/IRequestDispatcher.csStellaOps.Microservice/RequestDispatcher.csStellaOps.Microservice/ParameterBindingAttributes.cs- Parameter binding tests
- Response builder tests
- Dispatcher routing tests
Next Step
Proceed to Step 22: Logging & Tracing to implement structured logging and distributed tracing.