Skip to content

Commit 795f59b

Browse files
authored
Merge pull request #4 from Zio-Net/release/v2.0.0
Release/v2.0.0
2 parents fa2a1e1 + be1ae94 commit 795f59b

11 files changed

Lines changed: 373 additions & 162 deletions

File tree

README.md

Lines changed: 165 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,25 @@
22

33
Typed request context middleware for ASP.NET Core.
44

5-
Define *per-endpoint* (or per-route-group) strongly-typed request context objects, extract them from claims/headers, and access them anywhere in the request pipeline via an `AsyncLocal` accessor.
5+
Define *per-endpoint* (or per-route-group) strongly-typed "request context" objects, extract them from claims/headers, validate them, and access them anywhere in the request pipeline via an `AsyncLocal` accessor — including non-HTTP flows like queues and background jobs.
66

77
This repo produces two NuGet packages:
88

9-
- **`TypedRequestContext`** — core middleware, attributes, accessor, correlation ID
10-
- **`TypedRequestContext.Propagation`** — optional propagation (serialize/deserialize + header provider)
9+
- **`TypedRequestContext`** — core middleware, attributes, accessor, validation, correlation ID
10+
- **`TypedRequestContext.Propagation`** — optional propagation (serialize/deserialize, header provider, non-HTTP context restoration)
1111

1212
---
1313

1414
## Why
1515

1616
In many services you end up needing the same business identifiers everywhere (tenant, user, role, operation id, etc.). Passing them manually through every method is noisy, and extracting them ad-hoc in each endpoint is easy to get wrong.
1717

18-
`TypedRequestContext` gives you:
18+
`TypedRequestContext` solves this by letting you:
1919

20-
- A **typed model** for the “ambient” request context (per endpoint/group)
21-
- **One extraction pipeline** (claims/headers) with clear “required vs optional” semantics
22-
- **Multiple context shapes in the same service** — register many context types and choose per endpoint / route group
23-
- **Ambient access** for deep services (inject the context type directly *or* use `IRequestContextAccessor`)
24-
- Optional **propagation across services and async boundaries** (HTTP headers, queue/event metadata)
25-
- **Correlation ID propagation** via `x-correlation-id` when the propagation package is enabled
20+
- **Define once, use everywhere** — declare a typed model for your request context and access it from any layer without manual plumbing
21+
- **Fail fast on bad input** — required values are enforced at the edge; validation errors return structured responses before your handler runs
22+
- **Support multiple context shapes** — different endpoints can require different context types in the same service
23+
- **Propagate context across boundaries** — carry tenant, user, and correlation data to downstream HTTP calls, queues, and background jobs without ad-hoc serialization
2624

2725
---
2826

@@ -147,7 +145,7 @@ static IResult GetOrders([FromServices] CustomerRequestContext ctx)
147145

148146
In deeper services you have two options:
149147

150-
1) Inject the typed context directly (its registered as scoped when you call `AddTypedRequestContext<T>()`):
148+
1) Inject the typed context directly (it's registered as scoped when you call `AddTypedRequestContext<T>()`):
151149

152150
```csharp
153151
public sealed class BillingService(CustomerRequestContext ctx)
@@ -156,7 +154,7 @@ public sealed class BillingService(CustomerRequestContext ctx)
156154
}
157155
```
158156

159-
2) Use `IRequestContextAccessor` when you need a looser coupling (e.g., interface-based access, optional access, libraries that shouldnt depend on a specific context type):
157+
2) Use `IRequestContextAccessor` when you need a looser coupling (e.g., interface-based access, optional access, libraries that shouldn't depend on a specific context type):
160158

161159
```csharp
162160
using TypedRequestContext;
@@ -179,6 +177,7 @@ public sealed class AuditService(IRequestContextAccessor accessor)
179177
- You attach the desired context type to endpoints/groups via `WithRequestContext<TContext>()`.
180178
- For requests hitting such endpoints, the middleware:
181179
- Creates the context via an extractor (`IRequestContextExtractor<TContext>`)
180+
- Validates it (if validation is enabled)
182181
- Stores it into `IRequestContextAccessor.Current` for the request lifetime
183182

184183
The default extractor uses cached reflection and the attributes on your context properties.
@@ -188,7 +187,7 @@ The default extractor uses cached reflection and the attributes on your context
188187
If a `[RequiredContextValue]` property is missing:
189188

190189
- For **claims**: the middleware returns **401**
191-
- For **headers**: the middleware returns **403**
190+
- For **headers**: the middleware returns **400**
192191

193192
The response body is JSON:
194193

@@ -198,6 +197,98 @@ The response body is JSON:
198197

199198
---
200199

200+
## Validation
201+
202+
Enable validation per context type during registration:
203+
204+
```csharp
205+
builder.Services.AddTypedRequestContext<CustomerRequestContext>(b =>
206+
b.EnableValidation());
207+
```
208+
209+
By default, `EnableValidation()` uses **DataAnnotations** (`System.ComponentModel.DataAnnotations`). You can use any standard validation attributes:
210+
211+
```csharp
212+
using System.ComponentModel.DataAnnotations;
213+
using TypedRequestContext;
214+
215+
public sealed class OrderContext : ITypedRequestContext
216+
{
217+
[FromHeader("x-order-code"), MaxLength(4)]
218+
public string? Code { get; init; }
219+
220+
[FromHeader("x-quantity"), Range(1, 1000)]
221+
public int Quantity { get; init; }
222+
223+
[FromClaim("email"), RegularExpression(@"^[^@]+@[^@]+\.[^@]+$")]
224+
public string? Email { get; init; }
225+
}
226+
```
227+
228+
The context type can also implement `IValidatableObject` for cross-property validation:
229+
230+
```csharp
231+
public sealed class OrderContext : ITypedRequestContext, IValidatableObject
232+
{
233+
[FromHeader("x-min"), RequiredContextValue]
234+
public int Min { get; init; }
235+
236+
[FromHeader("x-max"), RequiredContextValue]
237+
public int Max { get; init; }
238+
239+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
240+
{
241+
if (Min > Max)
242+
yield return new ValidationResult("Min must be <= Max", [nameof(Min), nameof(Max)]);
243+
}
244+
}
245+
```
246+
247+
### Validation error response
248+
249+
When validation fails, the middleware returns **400** with a structured body grouping errors by property:
250+
251+
```json
252+
{
253+
"message": "Request context validation failed.",
254+
"errors": {
255+
"Code": ["The field Code must be a string with a maximum length of 4."],
256+
"Quantity": ["The field Quantity must be between 1 and 1000."],
257+
"$": ["Object-level validation error message"]
258+
}
259+
}
260+
```
261+
262+
### Custom validators
263+
264+
For validation logic beyond DataAnnotations, implement `IRequestContextValidator<T>`:
265+
266+
```csharp
267+
using TypedRequestContext;
268+
269+
public sealed class OrderContextValidator : IRequestContextValidator<OrderContext>
270+
{
271+
public IReadOnlyList<RequestContextValidationError> Validate(OrderContext context)
272+
{
273+
var errors = new List<RequestContextValidationError>();
274+
275+
if (context.Code is not null && !IsValidCode(context.Code))
276+
errors.Add(new RequestContextValidationError(nameof(context.Code), "Invalid order code format."));
277+
278+
return errors;
279+
}
280+
}
281+
```
282+
283+
Register it:
284+
285+
```csharp
286+
builder.Services.AddTypedRequestContext<OrderContext>(b =>
287+
b.UseValidation<OrderContextValidator>());
288+
```
289+
290+
---
291+
201292
## Correlation ID (optional)
202293

203294
Register once:
@@ -296,52 +387,63 @@ public sealed class DownstreamClient(
296387

297388
Because `GetCurrentHeaders()` includes `x-correlation-id` (when correlation is enabled), a downstream ASP.NET Core service using `AddCorrelationId()` will automatically pick it up on inbound requests.
298389

299-
### Non-HTTP: deserialize from metadata
390+
### Non-HTTP flows: queues, events, background jobs
300391

301-
For queues/events/background jobs, you can serialize and carry the same headers dictionary as message metadata.
392+
For non-HTTP consumers, `IRequestContextPropagator<T>` handles deserialization, validation, and context lifecycle in a single call.
302393

303-
Producer side (create metadata):
394+
**Producer side** — serialize current context into message metadata:
304395

305396
```csharp
306397
using TypedRequestContext.Propagation;
307398

308-
public sealed class Producer(IPropagationHeadersProvider headers)
399+
public sealed class OrderProducer(IPropagationHeadersProvider headers)
309400
{
310-
public IReadOnlyDictionary<string, string> CreateMetadata()
311-
=> headers.GetCurrentHeaders();
401+
public void Publish(Order order)
402+
{
403+
var metadata = headers.GetCurrentHeaders();
404+
// Attach metadata as message headers / properties
405+
messageBus.Publish(order, metadata);
406+
}
312407
}
313408
```
314409

315-
Consumer side (restore context):
410+
**Consumer side**restore context from metadata:
316411

317412
```csharp
318413
using TypedRequestContext;
319414
using TypedRequestContext.Propagation;
320415

321-
public sealed class Handler(
322-
IRequestContextDeserializer<CustomerRequestContext> deserializer,
416+
public sealed class OrderHandler(
417+
IRequestContextPropagator<CustomerRequestContext> propagator,
323418
IRequestContextAccessor accessor)
324419
{
325-
public async Task HandleAsync(IReadOnlyDictionary<string, string> metadata, CancellationToken ct)
420+
public async Task HandleAsync(
421+
IReadOnlyDictionary<string, string> metadata,
422+
CancellationToken ct)
326423
{
327-
var ctx = deserializer.Deserialize(metadata);
328-
329-
accessor.Current = ctx;
330-
try
331-
{
332-
// Your handler/service code can now inject CustomerRequestContext
333-
// or use IRequestContextAccessor.GetRequired<CustomerRequestContext>().
334-
await Task.CompletedTask;
335-
}
336-
finally
337-
{
338-
accessor.Current = null;
339-
}
424+
using var _ = propagator.Propagate(metadata);
425+
426+
// Context is now set — use it via DI or accessor
427+
var ctx = accessor.GetRequired<CustomerRequestContext>();
428+
await ProcessOrderAsync(ctx.TenantId, ctx.UserId, ct);
429+
430+
// Context is automatically cleared when the scope is disposed
340431
}
341432
}
342433
```
343434

344-
The same metadata can include `x-correlation-id` (from `GetCurrentHeaders()`). How you apply correlation in a non-HTTP consumer depends on your hosting model and logging setup.
435+
`Propagate()` will:
436+
1. Deserialize the metadata into the typed context using `IRequestContextDeserializer<T>`
437+
2. Validate the context (if validation is enabled for this type)
438+
3. Set it on `IRequestContextAccessor`
439+
4. Return a disposable scope that clears the context on disposal
440+
441+
If validation is enabled and the validator has scoped dependencies, pass a scoped `IServiceProvider`:
442+
443+
```csharp
444+
using var scope = serviceProvider.CreateScope();
445+
using var _ = propagator.Propagate(metadata, scope.ServiceProvider);
446+
```
345447

346448
---
347449

@@ -383,19 +485,43 @@ builder.Services.AddTypedRequestContext<MyContext>(b =>
383485
.UseDeserializer<MyDeserializer>());
384486
```
385487

488+
Serializer interface:
489+
490+
```csharp
491+
public interface IRequestContextSerializer<in T> where T : class, ITypedRequestContext
492+
{
493+
IReadOnlyDictionary<string, string> Serialize(T context);
494+
}
495+
```
496+
497+
Deserializer interface:
498+
499+
```csharp
500+
public interface IRequestContextDeserializer<out T> where T : class, ITypedRequestContext
501+
{
502+
T Deserialize(IReadOnlyDictionary<string, string> metadata);
503+
}
504+
```
505+
386506
---
387507

388508
## Troubleshooting
389509

390-
- **My handler cant resolve `CustomerRequestContext` from DI**
510+
- **My handler can't resolve `CustomerRequestContext` from DI**
391511
- Ensure `AddTypedRequestContext<CustomerRequestContext>()` was called.
392512
- Ensure the endpoint/group has `.WithRequestContext<CustomerRequestContext>()`.
393513

394514
- **Claims are missing**
395515
- Ensure `app.UseTypedRequestContext()` runs **after** auth middleware.
396516

397517
- **I get `No extractor registered for context type ...`**
398-
- You attached `.WithRequestContext<T>()` but didn’t register `AddTypedRequestContext<T>()`.
518+
- You attached `.WithRequestContext<T>()` but didn't register `AddTypedRequestContext<T>()`.
519+
520+
- **Validation errors return 400 but I expected a different status**
521+
- Validation failures always return 400. For missing required values (pre-validation), claims return 401 and headers return 400.
522+
523+
- **Non-HTTP consumer throws `RequestContextDeserializationException`**
524+
- A required metadata key is missing or has an invalid format. Check that the producer serialized all `[PropagationKey]` properties.
399525

400526

401527
## Contributing

0 commit comments

Comments
 (0)