feature: MIMIC-IV Replay Scenario Generator

This commit is contained in:
voltsrage
2026-06-25 13:35:09 +08:00
parent 069881991a
commit a8964381a2
29 changed files with 916019 additions and 8 deletions
@@ -0,0 +1,261 @@
# Guide 21: Input Validation with FluentValidation
## What is Input Validation?
Every time your API receives data from the outside world — a request body, a query parameter, a header — you need to check that the data is valid before processing it. Does the observation code exist? Is the value within a plausible range? Is the required field present? Without validation, invalid data can corrupt your database, crash your application, or produce nonsensical clinical alerts.
**Input validation** is the practice of checking incoming data at the system boundary (where your application meets the outside world) and rejecting anything that doesn't meet the rules. It's your first line of defense.
## What is FluentValidation?
**FluentValidation** is a .NET library that lets you define validation rules using a readable, method-chain syntax (a "fluent" API). Instead of writing validation logic inside your controllers with lots of `if` statements, you create a dedicated validator class for each request type:
```csharp
// Without FluentValidation — verbose, mixed with controller logic
if (string.IsNullOrEmpty(req.ObservationCode))
return BadRequest("ObservationCode is required");
if (req.ObservationCode.Length > 50)
return BadRequest("ObservationCode must be 50 chars or fewer");
if (req.RecordedAt > DateTimeOffset.UtcNow.AddMinutes(5))
return BadRequest("RecordedAt cannot be in the future");
// With FluentValidation — clean, separate class
public class IngestObservationRequestValidator
: AbstractValidator<IngestObservationRequest>
{
public IngestObservationRequestValidator()
{
RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);
RuleFor(x => x.RecordedAt)
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
.WithMessage("RecordedAt cannot be more than 5 minutes in the future.");
}
}
```
The FluentValidation version is more readable, testable (you can unit test the validator), and separated from the controller logic.
---
## How FluentValidation Works
```
HTTP Request arrives
┌───────────────┐ model binding ┌──────────────────┐
│ ASP.NET Core │ ──────────────────► │ Request DTO │
│ deserializes │ (JSON → C# obj) │ (e.g. Ingest │
│ request body │ │ ObservationReq) │
└───────────────┘ └────────┬─────────┘
┌──────────────────┐
│ FluentValidation │
│ auto-validates │
│ (finds matching │
│ validator class) │
└────────┬─────────┘
┌───────────────┴──────────────┐
│ │
Valid? Invalid?
│ │
▼ ▼
┌────────────────┐ ┌─────────────────┐
│ Controller │ │ 400 Bad Request │
│ action runs │ │ with field-level │
│ │ │ error details │
└────────────────┘ └─────────────────┘
```
---
## Setup
### NuGet Package
FluentValidation is already referenced through the ASP.NET Core integration package. No explicit package reference is needed if you're using the meta-package.
### Registration in Program.cs
```csharp
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
```
**What do these two lines do?**
1. **`AddFluentValidationAutoValidation()`**: Hooks FluentValidation into ASP.NET Core's model binding pipeline. When a request arrives, ASP.NET automatically finds the matching validator and runs it _before_ your controller action executes. If validation fails, the action never runs — a 400 Bad Request response is returned immediately.
2. **`AddValidatorsFromAssemblyContaining<Program>()`**: Scans the assembly (the compiled project) for all classes that extend `AbstractValidator<T>` and registers them with dependency injection. This means you don't need to register each validator manually — just create a new validator class and it's automatically discovered.
### Custom Error Response
When validation fails, ASP.NET Core calls the `InvalidModelStateResponseFactory` to build the error response:
```csharp
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value!.Errors.Count > 0)
.Select(e => new { field = e.Key, errors = e.Value!.Errors.Select(x => x.ErrorMessage) })
.ToList();
return new BadRequestObjectResult(
ApiResponse<object>.Fail(400, "Validation failed.", "VALIDATION_ERROR"));
};
});
```
This ensures validation errors use the same `ApiResponse<T>` envelope as all other API responses.
---
## Writing Validators
### Basic Rules
```csharp
public class IngestObservationRequestValidator
: AbstractValidator<IngestObservationRequest>
{
public IngestObservationRequestValidator()
{
RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);
RuleFor(x => x.RecordedAt)
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
.WithMessage("RecordedAt cannot be more than 5 minutes in the future.");
}
}
```
**What does each rule do?**
| Rule | What It Checks | Error If |
|------|---------------|----------|
| `.NotEmpty()` | String is not null, not empty, not whitespace | `""`, `null`, `" "` |
| `.MaximumLength(50)` | String is at most 50 characters | `"A" * 51` |
| `.GreaterThan(0)` | Number is positive | `0`, `-1` |
| `.LessThanOrEqualTo(...)` | Value is at or before a limit | Timestamp in the future |
| `.WithMessage(...)` | Custom error message (default messages are generic) | — |
| `.When(...)` | Only apply this rule if a condition is true | — |
### Cross-Field Validation
Sometimes a rule depends on multiple fields. Alert thresholds must be in order: CriticalLow < WarningLow < WarningHigh < CriticalHigh:
```csharp
public class AlertThresholdRequestValidator
: AbstractValidator<AlertThresholdRequest>
{
public AlertThresholdRequestValidator()
{
RuleFor(x => x.ObservationCode).NotEmpty().MaximumLength(50);
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(200);
RuleFor(x => x.Unit).NotEmpty().MaximumLength(20);
// Cross-field: threshold ordering
RuleFor(x => x)
.Must(x => !x.CriticalLow.HasValue || !x.WarningLow.HasValue
|| x.CriticalLow < x.WarningLow)
.WithMessage("CriticalLow must be less than WarningLow.");
RuleFor(x => x)
.Must(x => !x.WarningLow.HasValue || !x.WarningHigh.HasValue
|| x.WarningLow < x.WarningHigh)
.WithMessage("WarningLow must be less than WarningHigh.");
RuleFor(x => x)
.Must(x => !x.WarningHigh.HasValue || !x.CriticalHigh.HasValue
|| x.WarningHigh < x.CriticalHigh)
.WithMessage("WarningHigh must be less than CriticalHigh.");
}
}
```
The `.Must()` rule takes a lambda that returns `true` (valid) or `false` (invalid). Using `RuleFor(x => x)` applies the rule to the entire object rather than a single property, which is necessary for cross-field checks.
The `!x.CriticalLow.HasValue ||` guard makes the comparison optional — if a bound is null, the ordering check is skipped.
### Conditional Rules
Sometimes a rule only applies if another field has a value:
```csharp
public class CreateMedicationAdministrationRequestValidator
: AbstractValidator<CreateMedicationAdministrationRequest>
{
public CreateMedicationAdministrationRequestValidator()
{
RuleFor(x => x.DrugName).NotEmpty().MaximumLength(200);
RuleFor(x => x.Dose).GreaterThan(0);
RuleFor(x => x.DoseUnit).NotEmpty().MaximumLength(20);
RuleFor(x => x.Route).NotEmpty().MaximumLength(50);
RuleFor(x => x.AdministeredBy).NotEmpty().MaximumLength(100);
RuleFor(x => x.AdministeredAt)
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
.When(x => x.AdministeredAt.HasValue)
.WithMessage("AdministeredAt cannot be in the future.");
}
}
```
`.When(x => x.AdministeredAt.HasValue)` means: "only check if `AdministeredAt` is in the future when it's actually provided." If `AdministeredAt` is null (meaning 'use current time'), the rule is skipped entirely.
---
## Validators in This Project
| Validator | Request Type | Key Rules |
|-----------|-------------|-----------|
| `IngestObservationRequestValidator` | `IngestObservationRequest` | Code required, unit required, timestamp not in future |
| `AlertThresholdRequestValidator` | `AlertThresholdRequest` | Code+name+unit required, threshold ordering (CritLow < WarnLow < WarnHigh < CritHigh) |
| `LoginRequestValidator` | `LoginRequest` | Username and password required |
| `CreateMedicationAdministrationRequestValidator` | `CreateMedicationAdministrationRequest` | Drug name, dose > 0, unit, route, administered-by required |
| `TransitionStatusRequestValidator` | `TransitionStatusRequest` | New status required |
| `TransitionOrderStatusRequestValidator` | `TransitionOrderStatusRequest` | New status required |
| `AcknowledgeAlertRequestValidator` | `AcknowledgeAlertRequest` | Acknowledged-by required |
| `CreateOrderRequestValidator` | `CreateOrderRequest` | Description, type required |
| `RecordOrderResultRequestValidator` | `RecordOrderResultRequest` | Result summary required |
| `UpdatePatientRequestValidator` | `UpdatePatientRequest` | Name length limits |
| `CreateSiteRequestValidator` | `CreateSiteRequest` | Site code and name required |
| `RegisterGatewayRequestValidator` | `RegisterGatewayRequest` | Gateway code, site ID, department required |
| `GatewayHeartbeatRequestValidator` | `GatewayHeartbeatRequest` | Buffer depth >= 0 |
| `SubmitAlertFeedbackRequestValidator` | `SubmitAlertFeedbackRequest` | Rating required, valid rating values |
| `FhirPatientUpsertRequestValidator` | `FhirPatientUpsertRequest` | First name, last name, gender required |
| `FhirEncounterUpsertRequestValidator` | `FhirEncounterUpsertRequest` | Patient ID, department required |
---
## Validation vs Domain Rules
FluentValidation handles **input validation** — basic format and constraint checks at the API boundary. More complex **business rules** are enforced in the service layer:
| Check | Where | Example |
|-------|-------|---------|
| "Is the field present?" | Validator | `RuleFor(x => x.DrugName).NotEmpty()` |
| "Is the value in the right range?" | Validator | `RuleFor(x => x.Dose).GreaterThan(0)` |
| "Is the timestamp format valid?" | Validator | `RuleFor(x => x.RecordedAt).LessThanOrEqualTo(...)` |
| "Does the encounter exist?" | Service layer | `_db.Encounters.FindAsync(encounterId)` |
| "Is the encounter active?" | Service layer | `if (encounter.Status != Active) throw ...` |
| "Is the observation code plausible?" | Service layer | `PlausibilityValidator.IsPlausible(code, value)` |
| "Would this create a duplicate?" | Database | Partial unique index on `idempotency_key` |
The rule of thumb: validators check **format** (shape, size, type). Services check **meaning** (does this entity exist? is this transition valid?). The database checks **integrity** (uniqueness, foreign keys, check constraints). Together, these three layers form a defense-in-depth strategy.
---
## Key Takeaways
- **Validate at the boundary** — check input as soon as it enters your system, before any database queries or business logic run
- **Separate validators from controllers** — each request type gets its own validator class, keeping controllers clean and validators testable
- **Auto-validation means zero boilerplate** — `AddFluentValidationAutoValidation()` + `AddValidatorsFromAssemblyContaining<>()` makes validation automatic. Create a validator class; it's discovered and applied without any additional wiring.
- **Cross-field rules use `.Must()`** — when a rule depends on multiple fields (like threshold ordering), apply the rule to the entire object
- **Conditional rules use `.When()`** — skip a rule when the field is null or when a condition isn't met
- **Validators are not the only defense** — they catch format issues. Service-layer logic catches business rule violations. Database constraints catch integrity violations. All three layers matter.