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,268 @@
# Guide 16: PHI Encryption with Data Protection API
## What is PHI and Why Encrypt It?
**PHI** stands for **Protected Health Information** — any data that can identify a patient and relates to their health. This includes names, dates of birth, medical record numbers, allergies, and emergency contact details. Healthcare regulations (HIPAA in the US, GDPR in the EU) require that PHI be **encrypted at rest** — meaning even if someone steals the database files or a backup, they can't read the patient data without the encryption key.
**Encryption at rest** protects against scenarios like:
- A database backup is accidentally uploaded to a public S3 bucket
- A disgruntled employee copies the database files
- The database server's hard drive is stolen or improperly disposed of
Without encryption, the database stores plaintext: `first_name = "Sarah"`. With encryption: `first_name = "CfDJ8Nrq7...long encrypted string..."`. The application decrypts transparently when reading, and encrypts transparently when writing.
## What is the Data Protection API?
**.NET's Data Protection API (DPAPI)** is a built-in framework for encrypting and decrypting data. It manages encryption keys, handles key rotation, and provides a simple `Protect()`/`Unprotect()` interface. You don't need to pick cipher algorithms or manage IVs manually — DPAPI handles the cryptographic details.
Key concepts:
- **`IDataProtectionProvider`**: The factory that creates protectors. Registered with DI at startup.
- **`IDataProtector`**: An instance tied to a specific **purpose string**. A protector created with purpose `"VigilCare.PatientPhi.v1"` can only decrypt data that was encrypted with the same purpose. This prevents accidentally decrypting data meant for a different part of the application.
- **Key ring**: DPAPI stores encryption keys on disk (configurable path). Keys are automatically rotated and expired on a schedule. Old keys are kept so previously encrypted data can still be decrypted.
---
## How PHI Encryption Works in This Project
```
Application reads patient.FirstName
EF Core Value Converter
│ calls crypto.Decrypt(ciphertext)
PhiEncryptionService.Decrypt()
│ calls _protector.Unprotect(ciphertext)
Returns "Sarah" to the application
```
```
Application writes patient.FirstName = "Sarah"
EF Core Value Converter
│ calls crypto.Encrypt("Sarah")
PhiEncryptionService.Encrypt()
│ calls _protector.Protect("Sarah")
Stores "CfDJ8Nrq7..." in PostgreSQL
```
The application code never sees ciphertext — it works with plaintext strings as usual. The encryption and decryption happen inside EF Core value converters (see Guide 4), invisible to the rest of the codebase.
---
## Configuration
### PhiEncryptionOptions
```csharp
public class PhiEncryptionOptions
{
public const string Section = "PhiEncryption";
public string ProtectorPurpose { get; set; } = "VigilCare.PatientPhi.v1";
public string SearchTokenKey { get; set; } = null!;
public bool LogListAccess { get; set; } = true;
}
```
| Setting | Purpose |
|---------|---------|
| `ProtectorPurpose` | The purpose string for the data protector. Changing this creates a new encryption scope — old data can't be decrypted with a new purpose without migration. |
| `SearchTokenKey` | HMAC key for generating searchable name tokens (explained below). Separate from the encryption key. |
| `LogListAccess` | Whether to log PHI access for list/search operations (compliance auditing). |
### appsettings.json
```json
{
"PhiEncryption": {
"ProtectorPurpose": "VigilCare.PatientPhi.v1",
"SearchTokenKey": "DEV-ONLY-HMAC-KEY-REPLACE-IN-PRODUCTION-32bytes!!",
"LogListAccess": true
},
"DataProtection": {
"KeyPath": "./data-protection-keys"
}
}
```
### Registration in Program.cs
```csharp
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(
builder.Configuration["DataProtection:KeyPath"]
?? "./data-protection-keys"))
.SetApplicationName("VigilCareClinical");
```
`PersistKeysToFileSystem` stores the encryption keys in a local directory. In production, you'd use `PersistKeysToAzureBlobStorage()` or `PersistKeysToStackExchangeRedis()` so keys survive container restarts. `SetApplicationName` ensures all instances of the application share the same key ring.
---
## The PhiEncryptionService
```csharp
public class PhiEncryptionService : IPhiEncryptionService
{
private readonly IDataProtector _protector;
private readonly byte[] _searchKey;
public PhiEncryptionService(
IDataProtectionProvider provider,
IOptions<PhiEncryptionOptions> options)
{
var opts = options.Value;
_protector = provider.CreateProtector(opts.ProtectorPurpose);
_searchKey = /* derived from SearchTokenKey */;
}
public string Encrypt(string plaintext)
{
if (string.IsNullOrEmpty(plaintext)) return plaintext;
return _protector.Protect(plaintext);
}
public string Decrypt(string ciphertext)
{
if (string.IsNullOrEmpty(ciphertext)) return ciphertext;
if (!IsEncrypted(ciphertext)) return ciphertext; // plaintext rows still readable
return _protector.Unprotect(ciphertext);
}
public bool IsEncrypted(string value) =>
value.StartsWith("CfDJ8", StringComparison.Ordinal) || value.Length > 50;
}
```
**The `IsEncrypted` check**: During the migration from plaintext to encrypted data, some rows may still contain plaintext. The `Decrypt` method checks if the value looks encrypted (DPAPI-encrypted values start with `"CfDJ8"` and are much longer than typical names). If not, it returns the value as-is. This lets the application work correctly with a partially-migrated database.
### How EF Core Uses the Service
The `PatientPhiConverterConfigurator` (from Guide 4) wires the service into EF Core value converters:
```csharp
entity.Property(p => p.FirstName)
.HasConversion(
v => crypto.Encrypt(v), // called on every INSERT/UPDATE
v => crypto.Decrypt(v)); // called on every SELECT
entity.Property(p => p.DateOfBirth)
.HasConversion(
v => crypto.Encrypt(v.ToString("yyyy-MM-dd")),
v => DateOnly.Parse(crypto.Decrypt(v)));
```
Encrypted columns: `first_name`, `last_name`, `date_of_birth`, `allergies`, `emergency_contact_name`, `emergency_contact_phone`.
---
## The Search Problem: HMAC Name Tokens
**The problem**: If names are encrypted, you can't search for patients by name. SQL `WHERE first_name LIKE '%Sarah%'` doesn't work on ciphertext because each encryption of "Sarah" produces a different ciphertext (DPAPI uses random IVs).
**The solution**: Store a deterministic, one-way hash of the name in a separate column (`name_search_token`). To search, hash the query the same way and compare hashes.
```csharp
public string ComputeNameSearchToken(string firstName, string lastName)
{
var normalized = $"{firstName.Trim().ToLowerInvariant()}|{lastName.Trim().ToLowerInvariant()}";
using var hmac = new HMACSHA256(_searchKey);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(hash).ToLowerInvariant();
}
```
**What is HMAC?** HMAC (Hash-based Message Authentication Code) is a keyed hash function. Unlike plain SHA-256 (where anyone can compute the same hash), HMAC requires a secret key. Without the key, an attacker who has the hash can't reverse it to find the name, and can't compute hashes for other names to test against.
**Why not just use SHA-256?** Plain SHA-256 is vulnerable to rainbow table attacks — an attacker precomputes hashes for common names ("Sarah Smith" → hash, "John Doe" → hash) and compares them against the stored hashes. HMAC with a secret key makes this impossible because the attacker would need the key to compute valid hashes.
The search flow:
1. User searches for "Sarah Smith"
2. Application computes `HMACSHA256("sarah|smith")``"a1b2c3d4..."`
3. SQL query: `WHERE name_search_token = 'a1b2c3d4...'`
4. Matching rows are returned, and EF Core's value converter decrypts the actual names
---
## PHI Access Logging
Every access to patient data is logged for compliance auditing:
```csharp
public class PhiAccessLogService : IPhiAccessLogService
{
public async Task LogViewAsync(Guid patientId, string resourcePath) =>
await WriteAsync(PhiAccessType.View, patientId, resourcePath);
public async Task LogListAsync(string resourcePath, int resultCount,
string? searchQuery = null)
{
var accessType = string.IsNullOrWhiteSpace(searchQuery)
? PhiAccessType.List
: PhiAccessType.Search;
await WriteAsync(accessType, null, resourcePath, resultCount, searchQuery);
}
private async Task WriteAsync(PhiAccessType accessType, Guid? patientId, ...)
{
_db.PhiAccessLogs.Add(new PhiAccessLog
{
AccessType = accessType,
PatientId = patientId,
UserId = _currentUser.UserId!.Value,
UserDisplayName = _currentUser.DisplayName,
ResourcePath = resourcePath,
SearchQueryHash = searchQuery is null ? null : HashQuery(searchQuery),
IpAddress = _currentUser.IpAddress,
CorrelationId = /* from middleware */,
AccessedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
_metrics.PhiAccessLogsTotal.WithLabels(accessType.ToDbString()).Inc();
}
}
```
The log records who accessed what, when, from where, and what they searched for. Search queries are hashed (not stored in plaintext) to avoid storing potentially sensitive search terms.
---
## One-Time Migration: EncryptPhiCommand
For existing databases with plaintext patient data, a CLI command encrypts all rows in place:
```csharp
public static class EncryptPhiCommand
{
public static async Task RunAsync(IServiceProvider services)
{
var patients = await db.Patients.ToListAsync();
foreach (var p in patients)
{
p.NameSearchToken = crypto.ComputeNameSearchToken(p.FirstName, p.LastName);
}
await db.SaveChangesAsync();
Console.WriteLine($"Encrypted {patients.Count} patient records.");
}
}
```
Run via: `dotnet run -- encrypt-phi`
The EF Core value converters handle the actual encryption — loading each patient triggers `Decrypt` (which passes plaintext through via `IsEncrypted` check), and saving triggers `Encrypt` (which encrypts the now-plaintext values). The command also computes `NameSearchToken` for every patient to enable encrypted name search.
---
## Key Takeaways
- **Encryption at rest protects against data breaches** — even if the database is stolen, patient data is unreadable without the encryption keys
- **EF Core value converters make encryption transparent** — application code works with plaintext; encryption/decryption happens automatically on every read and write
- **DPAPI handles key management** — key generation, rotation, and storage are built-in. You don't manage cryptographic primitives directly.
- **Searchable encryption uses HMAC tokens** — deterministic, keyed hashes enable exact-match search on encrypted columns without decrypting every row
- **PHI access logging creates an audit trail** — every view, search, and modification of patient data is recorded with user identity, timestamp, and IP address
- **The purpose string isolates encryption scopes** — data encrypted with `"VigilCare.PatientPhi.v1"` can only be decrypted with the same purpose, preventing cross-contamination between different parts of the application
+201
View File
@@ -0,0 +1,201 @@
# Guide 17: Password Hashing with BCrypt
## What is Password Hashing?
When a user creates an account with the password `"DemoNurse1!"`, the application must store something that lets it verify the password later — but it should **never store the password itself**. If the database is compromised, plaintext passwords would be immediately usable by the attacker.
**Hashing** converts a password into a fixed-length string of random-looking characters using a one-way mathematical function. "One-way" means you can compute the hash from the password, but you can't compute the password from the hash:
```
"DemoNurse1!" → hash() → "$2a$12$xK7W3M...long hash string..."
```
When the user logs in, you hash the submitted password and compare it to the stored hash. If they match, the password is correct — without ever storing the actual password.
## Why BCrypt Specifically?
Not all hash functions are created equal. General-purpose hash functions like SHA-256 are designed to be **fast** — billions of hashes per second on modern hardware. That's a problem for passwords: an attacker who steals the hashed passwords can try billions of guesses per second.
**BCrypt** is specifically designed for password hashing with two key properties:
1. **It's intentionally slow**: BCrypt has a configurable "cost factor" (also called "work factor") that controls how many iterations the algorithm performs. Cost factor 12 (the default) means 2^12 = 4,096 iterations, making each hash take ~250ms. Fast enough that a single login is imperceptible, but an attacker trying 1 million passwords would need ~70 hours.
2. **It includes a built-in salt**: A **salt** is a random value mixed into the hash. Without a salt, two users with the same password would have the same hash — an attacker could build a precomputed table (a "rainbow table") of common passwords and their hashes, then look up matches instantly. BCrypt generates a random salt for each password and embeds it in the output, so identical passwords produce different hashes.
A BCrypt hash looks like this:
```
$2a$12$xK7W3MqQ5Z6Y8B9A0C1D2EfGhIjKlMnOpQrStUvWxYz0123456789Ab
│ │ │ │
│ │ │ └── The hash itself
│ │ └── The salt (22 chars)
│ └── Cost factor (12 = 2^12 iterations)
└── Algorithm version
```
The salt and cost factor are stored right in the hash string, so you don't need a separate column for them.
---
## How BCrypt is Used in This Project
### NuGet Package
```xml
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
```
`BCrypt.Net-Next` is a .NET implementation of the BCrypt algorithm. It provides two key methods: `HashPassword()` and `Verify()`.
### Hashing on Account Creation
When a new user is created, the plaintext password is hashed before storage:
```csharp
public async Task<ClinicalUserResponse> CreateAsync(CreateUserRequest req)
{
// Validate the password meets minimum requirements
ValidatePassword(req.Password);
var user = new ClinicalUser
{
Id = Guid.NewGuid(),
Username = req.Username.Trim(),
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password), // hash here
DisplayName = req.DisplayName.Trim(),
Role = role,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow,
};
_db.ClinicalUsers.Add(user);
await _db.SaveChangesAsync();
return Map(user);
}
```
`HashPassword(req.Password)` generates a random salt, applies BCrypt with the default cost factor (12), and returns the full hash string. Each call with the same password produces a different hash (because the salt is random).
### Password Validation Rules
```csharp
private static void ValidatePassword(string password)
{
if (string.IsNullOrWhiteSpace(password))
throw new ValidationException("Password is required.", "PASSWORD_REQUIRED");
if (password.Length < 8)
throw new ValidationException(
"Password must be at least 8 characters.", "PASSWORD_TOO_SHORT");
}
```
The minimum length of 8 characters is a baseline. In production, you'd typically also require uppercase, lowercase, digits, and special characters — but the BCrypt hash itself doesn't care about password complexity.
### Verification on Login
When a user logs in, the submitted password is verified against the stored hash:
```csharp
public async Task<LoginResponse> LoginAsync(LoginRequest req)
{
var user = await _db.ClinicalUsers
.FirstOrDefaultAsync(u => u.Username == req.Username && u.IsActive);
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
throw new ValidationException(
"Invalid username or password.", "INVALID_CREDENTIALS");
// ... generate JWT token
}
```
**How does `Verify` work?**
1. Extract the salt and cost factor from the stored hash string
2. Hash the submitted password using the same salt and cost factor
3. Compare the result to the stored hash
4. If they match, the password is correct
**Security note**: The error message says "Invalid username or password" — it does not distinguish between "user not found" and "wrong password." This prevents an attacker from enumerating valid usernames by observing different error messages.
### Seed Data (Development Only)
The user seeder creates demo accounts with hashed passwords:
```csharp
new ClinicalUser
{
Username = "nurse.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"),
DisplayName = "Demo Nurse",
Role = ClinicalRole.Nurse,
},
new ClinicalUser
{
Username = "physician.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
DisplayName = "Dr. Demo Physician",
Role = ClinicalRole.Physician,
},
new ClinicalUser
{
Username = "admin.demo",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
DisplayName = "Demo Admin",
Role = ClinicalRole.Admin,
},
```
Even in development, passwords are never stored in plaintext in the database. The passwords themselves (`"DemoNurse1!"` etc.) appear in the seeder code, but they're only used during the initial seeding and don't persist as plaintext anywhere.
### Database Schema
```csharp
builder.Property(u => u.PasswordHash).HasColumnName("password_hash")
.HasMaxLength(500).IsRequired();
```
`HasMaxLength(500)` accommodates the BCrypt hash string (typically ~60 characters) with room for future algorithm changes that might produce longer hashes.
---
## Cost Factor Considerations
The default cost factor of 12 is a good balance for 2024-era hardware:
| Cost Factor | Iterations | Approximate Time | Use Case |
|-------------|-----------|------------------|----------|
| 10 | 1,024 | ~65ms | Minimum for production |
| 11 | 2,048 | ~130ms | Reasonable for high-traffic APIs |
| **12** | **4,096** | **~250ms** | **Default — good balance** |
| 13 | 8,192 | ~500ms | More security, but login feels slower |
| 14 | 16,384 | ~1s | High-security environments |
The cost factor should be increased over time as hardware gets faster. What takes 250ms today might take 25ms in 10 years. The industry recommendation: choose the highest cost factor that keeps login time under ~500ms for your hardware.
You can customize the cost factor:
```csharp
BCrypt.Net.BCrypt.HashPassword(password, workFactor: 13);
```
Existing hashes with a lower cost factor continue to verify correctly — BCrypt reads the cost factor from the hash string.
---
## What BCrypt Does NOT Protect Against
- **Weak passwords**: BCrypt slows down brute-force attacks, but "password123" will still be cracked quickly. Enforce password complexity rules at the application level.
- **Phishing**: If a user gives their password to an attacker directly, hashing doesn't help.
- **Memory dumps**: While the application is running, the plaintext password exists briefly in memory (during the Verify call). In extremely sensitive environments, you'd use secure memory handling.
- **Credential stuffing**: If a user reuses their password from another breached site, BCrypt can't help. Multi-factor authentication (MFA) addresses this.
---
## Key Takeaways
- **Never store plaintext passwords** — always hash them before writing to the database. There is no valid reason to store or log a user's actual password.
- **BCrypt is purposefully slow** — the cost factor makes brute-force attacks impractical while keeping legitimate logins fast
- **Each hash includes its own salt** — even identical passwords produce different hashes, defeating rainbow table attacks
- **The cost factor is embedded in the hash** — you can increase the cost factor for new passwords without invalidating existing ones
- **Give generic error messages** — "Invalid username or password" prevents username enumeration. Never reveal whether the username or the password was wrong.
- **BCrypt handles the hard parts** — salt generation, iteration count, and comparison are all managed by the library. You call `HashPassword()` and `Verify()` — nothing else.
@@ -0,0 +1,376 @@
# Guide 18: REST API Design & Conventions
## What is a REST API?
A **REST API** (Representational State Transfer) is the most common way to build web APIs. It uses standard HTTP methods and URLs to perform operations on resources. If you've ever used a URL like `GET /api/patients/123`, you've interacted with a REST API.
Key principles:
- **Resources** are the "nouns" — patients, encounters, observations, alerts. Each resource has a URL (called an endpoint).
- **HTTP methods** are the "verbs" — GET (read), POST (create), PUT (replace), PATCH (partial update), DELETE (remove).
- **Status codes** tell the caller what happened — 200 (success), 201 (created), 400 (bad request), 404 (not found), 500 (server error).
- **Stateless** — each request contains all the information the server needs. The server doesn't remember previous requests (that's what JWT tokens are for — they carry identity in every request).
---
## URL Conventions
### Resource Naming
URLs use lowercase, plural nouns with hyphens between words:
```
/api/v1/encounters — collection of encounters
/api/v1/encounters/{encounterId} — one encounter
/api/v1/encounters/{encounterId}/observations — observations within an encounter
/api/v1/alert-thresholds — hyphenated multi-word resource
/api/v1/sepsis-bundles/{bundleId}/elements — nested sub-resource
```
**Why plural?** `GET /api/v1/encounters` returns a list, and `GET /api/v1/encounters/123` returns one item from that list. Using the plural form for both keeps URLs consistent.
**Why `v1`?** Version prefixing lets you introduce breaking changes in a `v2` without breaking existing clients.
### Controller Declaration
```csharp
[ApiController]
[Route("api/v1/encounters")]
[Produces("application/json")]
[Authorize]
public class EncountersController : ControllerBase
```
- **`[ApiController]`** enables automatic model validation, `[FromBody]` inference, and `ProblemDetails` error responses
- **`[Route("api/v1/encounters")]`** sets the base URL for all actions in this controller
- **`[Produces("application/json")]`** declares that all responses are JSON
- **`[Authorize]`** requires authentication for all actions (overridable per-action)
---
## The Response Envelope
Every API response uses a consistent wrapper called `ApiResponse<T>`:
```csharp
public record ApiResponse<T>(bool Success, int StatusCode, T? Data, ApiError? Error)
{
public static ApiResponse<T> Ok(T data) =>
new(true, 200, data, null);
public static ApiResponse<T> Created(T data) =>
new(true, 201, data, null);
public static ApiResponse<T> Fail(int statusCode, string message, string code) =>
new(false, statusCode, default, new ApiError(message, code));
}
public record ApiError(string Message, string Code);
```
**Why an envelope?** Without a wrapper, successful and error responses have completely different shapes, making it harder for clients to parse. With the envelope, every response has the same top-level structure:
```json
// Success
{
"success": true,
"statusCode": 200,
"data": { "id": "...", "status": "Active", ... },
"error": null
}
// Error
{
"success": false,
"statusCode": 400,
"data": null,
"error": {
"message": "Invalid status filter.",
"code": "INVALID_STATUS"
}
}
```
The `code` field (like `"INVALID_STATUS"`) is a machine-readable error identifier. The `message` is human-readable. Clients can switch on the code without parsing the message string, which may change or be localized.
---
## HTTP Methods and Status Codes
### GET — Read (never modifies data)
```csharp
[HttpGet]
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
public async Task<IActionResult> List(
[FromQuery] string? status,
[FromQuery] string? department,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await _encounters.ListAsync(parsedStatus, parsedDepartment, page, pageSize);
return Ok(ApiResponse<object>.Ok(new
{
items = result.Items,
page = result.Page,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = result.TotalPages
}));
}
```
- Returns **200 OK** on success
- Filter parameters go in query strings (`?status=ACTIVE&department=ICU`)
- Pagination parameters are also query strings (`?page=1&pageSize=20`)
### POST — Create a new resource
```csharp
[HttpPost]
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
public async Task<IActionResult> Create([FromBody] AlertThresholdRequest req)
{
var threshold = await _thresholds.CreateAsync(req);
return StatusCode(201, ApiResponse<AlertThreshold>.Created(threshold));
}
```
- Request body is JSON (`[FromBody]`)
- Returns **201 Created** on success (not 200 — 201 is semantically correct for creation)
- Returns **409 Conflict** if the resource already exists
### POST for Ingest (Batch Operations)
```csharp
[HttpPost]
[AuthorizePermission(ClinicalPermissions.ObservationsIngest)]
public async Task<IActionResult> Ingest(Guid encounterId, [FromBody] BatchIngestRequest req)
{
if (req.Observations.Count == 0)
return BadRequest(ApiResponse<object>.Fail(400,
"At least one observation is required.", "EMPTY_BATCH"));
if (req.Observations.Count > 10)
return BadRequest(ApiResponse<object>.Fail(400,
"Batch size cannot exceed 10 observations.", "BATCH_TOO_LARGE"));
// Process each observation...
return StatusCode(201, ApiResponse<object>.Created(results));
}
```
Batch endpoints accept arrays but enforce limits to prevent abuse or accidental huge payloads.
### PATCH — Partial update
```csharp
[HttpPatch("{alertId:guid}/acknowledge")]
[AuthorizePermission(ClinicalPermissions.AlertsAcknowledge)]
public async Task<IActionResult> Acknowledge(Guid alertId, ...)
```
- PATCH means "modify part of the resource" (only the fields you send are changed)
- Returns **200 OK** with the updated resource
- Returns **404 Not Found** if the resource doesn't exist
### PUT — Full replace
```csharp
[HttpPut("{id:guid}")]
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
public async Task<IActionResult> Update(Guid id, [FromBody] AlertThresholdRequest req)
```
- PUT means "replace the entire resource with this new version"
- Returns **200 OK** with the updated resource
---
## Error Handling
The `ExceptionHandlerMiddleware` (from Guide 3) catches all exceptions and converts them to consistent API responses. The controller code throws domain exceptions, and the middleware translates them:
| Exception | HTTP Status | When Used |
|-----------|-------------|-----------|
| `NotFoundException` | 404 Not Found | Resource doesn't exist |
| `BadRequestException` | 400 Bad Request | Invalid input that FluentValidation didn't catch |
| `ValidationException` | 422 Unprocessable Entity | Business rule violation |
| `ConflictException` | 409 Conflict | Duplicate resource (e.g., duplicate MRN) |
| `Exception` (unhandled) | 500 Internal Server Error | Unexpected bugs |
Client errors (4xx) are logged as **Warning** — they're expected. Server errors (5xx) are logged as **Error** with the full stack trace.
The 500 response never leaks exception details to the client:
```json
{
"success": false,
"statusCode": 500,
"data": null,
"error": {
"message": "An unexpected error occurred",
"code": "INTERNAL_ERROR"
}
}
```
---
## Pagination: Offset-Based vs Cursor-Based
This project uses two pagination strategies depending on the use case.
### Offset-Based (Page Number)
For encounter lists where the client needs "page 3 of 10":
```
GET /api/v1/encounters?page=2&pageSize=20
```
```json
{
"items": [...],
"page": 2,
"pageSize": 20,
"totalCount": 157,
"totalPages": 8
}
```
**How it works**: `OFFSET (page - 1) * pageSize LIMIT pageSize`. Simple but has a known limitation — if data is inserted between page requests, items can be duplicated or skipped.
### Cursor-Based (Keyset Pagination)
For observation history where data is frequently appended:
```
GET /api/v1/encounters/{id}/observations?limit=20
GET /api/v1/encounters/{id}/observations?limit=20&cursor=eyJ0Ijoi...
```
```json
{
"items": [...],
"nextCursor": "eyJ0IjoiMjAyNi0wNi0yNFQxNDoyMzowMFoiLCJpIjoiYWJjLTEyMyJ9",
"hasMore": true
}
```
**What is a cursor?** An opaque token that encodes the position of the last item. The server decodes it to construct a `WHERE` clause that fetches the next batch:
```csharp
public async Task<CursorPage<Observation>> GetHistoryAsync(
Guid encounterId, string? code,
DateTimeOffset? from, DateTimeOffset? to,
int limit, string? cursorToken)
{
var cursor = ObservationCursor.Decode(cursorToken);
var query = _db.Observations
.Where(o => o.EncounterId == encounterId);
if (cursor is not null)
{
// Keyset condition: ORDER BY recorded_at DESC, id DESC
// "Give me rows AFTER this position"
query = query.Where(o =>
o.RecordedAt < cursor.RecordedAt ||
(o.RecordedAt == cursor.RecordedAt && o.Id.CompareTo(cursor.Id) < 0));
}
var items = await query
.OrderByDescending(o => o.RecordedAt)
.ThenByDescending(o => o.Id)
.Take(limit + 1) // fetch one extra to detect "has more"
.ToListAsync();
var hasMore = items.Count > limit;
if (hasMore) items.RemoveAt(limit);
var nextCursor = hasMore
? new ObservationCursor(items[^1].RecordedAt, items[^1].Id).Encode()
: null;
return new CursorPage<Observation>(items, nextCursor, hasMore);
}
```
**Why cursor-based for observations?** Observations are append-only and ordered by timestamp. Offset pagination (`OFFSET 100`) gets slower as the offset grows (PostgreSQL must scan and skip 100 rows). Keyset pagination (`WHERE recorded_at < '2026-06-24T14:23:00Z'`) uses the index directly and performs consistently regardless of how deep you paginate.
**The `Take(limit + 1)` trick**: Fetch one more item than requested. If you get `limit + 1` items, there's a next page — remove the extra item and return `hasMore: true`. If you get `limit` or fewer, there's no next page.
---
## Idempotency
**What is idempotency?** An operation is idempotent if performing it multiple times produces the same result as performing it once. `GET` is naturally idempotent (reading doesn't change anything). `POST` is not (creating a resource twice creates two resources).
For observation ingest, the API supports an optional `Idempotency-Key` header:
```
POST /api/v1/encounters/{id}/observations
Idempotency-Key: device-123-reading-456
{ "observationCode": "HEART_RATE", "value": 82, ... }
```
If the same idempotency key is sent twice, the second request returns the original observation instead of creating a duplicate. This is essential for device integrations where network retries are common — a device might send the same reading twice if it doesn't receive an acknowledgment.
The implementation uses a partial unique index in PostgreSQL (see Guide 4) — only non-null idempotency keys are checked for uniqueness.
---
## Route Parameter Constraints
```csharp
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
```
The `:guid` constraint means ASP.NET Core only matches this route if the `{id}` segment is a valid GUID. A request to `/api/v1/encounters/not-a-guid` returns 404 instead of reaching the controller and failing during GUID parsing.
---
## Swagger / OpenAPI Documentation
The API is self-documenting via Swagger:
```csharp
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "VigilCare Clinical API v1");
});
```
Available at `http://localhost:5270/swagger/ui` during development. XML documentation comments on controller actions (the `<summary>` blocks) appear in the Swagger UI, making it easy for frontend developers to understand each endpoint without reading the C# code.
```csharp
/// <summary>
/// Returns cursor-paginated observation history for an encounter.
/// </summary>
/// <param name="encounterId">Encounter id.</param>
/// <param name="code">Optional observation code filter.</param>
/// <param name="limit">Maximum items per page.</param>
/// <param name="cursor">Opaque cursor from a previous page.</param>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<IActionResult> History(...)
```
`[ProducesResponseType]` tells Swagger which response shapes are possible, generating accurate API documentation.
---
## Key Takeaways
- **Consistent envelope** (`ApiResponse<T>`) makes every response predictable — clients always know where to find the data, error message, and status code
- **Machine-readable error codes** (like `"INVALID_STATUS"`) let clients handle errors programmatically without parsing human-readable messages
- **Use the right HTTP method** — GET reads, POST creates, PATCH updates partially, PUT replaces fully. This isn't just convention; proxies, caches, and browsers treat these differently.
- **Use the right status code** — 201 for creation, 409 for conflicts, 422 for validation failures. Don't use 200 for everything.
- **Cursor pagination for append-only data** — performs consistently regardless of dataset size, handles concurrent inserts correctly
- **Offset pagination for browsable lists** — simpler for UI that needs "page X of Y" navigation
- **Idempotency keys prevent duplicate resources** — essential for unreliable networks where requests may be retried
- **Swagger documents the API automatically** — XML comments on controllers become interactive API documentation
@@ -0,0 +1,295 @@
# Guide 19: FHIR R4 Integration Facade
## What is FHIR?
**FHIR** (Fast Healthcare Interoperability Resources, pronounced "fire") is a standard for exchanging healthcare data between systems. If your hospital has an EHR (Electronic Health Record) like Epic or Cerner, a lab system, a pharmacy system, and a monitoring platform, they all need to share patient data. FHIR defines a common language for this — standard data formats (called **resources**) and standard ways to send them (RESTful HTTP endpoints).
Key concepts:
- **Resource**: A structured JSON object representing a healthcare concept. Examples: `Patient`, `Encounter`, `Observation`, `MedicationAdministration`. Each resource type has a defined set of fields and data types.
- **FHIR R4**: The fourth major release of the FHIR standard (Release 4). R4 is the current normative version used by most healthcare systems.
- **Identifier**: A system+value pair that identifies a resource in an external system. For example, `system: "http://hospital.example/mrn"`, `value: "MRN-001"`. A patient might have different identifiers in different systems — FHIR uses these to match records across systems.
- **Coding**: A code from a standard terminology. `system: "http://loinc.org"`, `code: "8867-4"` means "Heart rate" in the LOINC vocabulary. `system: "http://snomed.info/sct"`, `code: "364075005"` also means "Heart rate" in SNOMED CT.
- **Bundle**: A collection of resources sent together. A "transaction Bundle" is like a database transaction — all entries succeed or all fail.
- **CapabilityStatement**: A resource that describes what a FHIR server can do — which resource types it supports and which operations (create, read, search).
- **OperationOutcome**: A FHIR-standard error response. Instead of returning `{"error": "..."}`, FHIR servers return a structured `OperationOutcome` resource with severity, issue type, and diagnostic details.
**What is a "facade"?** This project doesn't implement a full FHIR server. It implements a **facade** — a thin translation layer that accepts FHIR-formatted requests, maps them to the internal data model, and returns FHIR-formatted responses. The internal database schema is not FHIR-native; the facade translates between the two worlds.
---
## Why FHIR in This Project?
Hospital integration engines (like Mirth Connect or Rhapsody) speak FHIR. They send ADT (Admit/Discharge/Transfer) messages with Patient and Encounter resources, vital signs as Observation resources, and medication records as MedicationAdministration resources. By implementing FHIR endpoints, VigilCareClinical can receive data from any FHIR-capable system without custom integration code for each one.
---
## Architecture Overview
```
Hospital Systems VigilCareClinical
┌──────────────┐ ┌──────────────────────────────┐
│ EHR (Epic) │ │ FHIR Ingest Controller │
│ │ POST /fhir/R4/ │ │ │
│ Mirth Connect│ Patient │ ▼ │
│ (integration │ ────────────────► │ PatientFhirMapper │
│ engine) │ │ │ FHIR Patient → internal │
│ │ POST /fhir/R4/ │ ▼ │
│ Lab System │ Observation │ PatientService │
│ │ ────────────────► │ │ save to PostgreSQL │
│ Pharmacy │ │ ▼ │
│ │ POST /fhir/R4/ │ FhirMapper (reverse) │
│ │ Bundle │ │ internal → FHIR response│
│ │ ────────────────► │ ▼ │
│ │ │ Return FHIR JSON │
└──────────────┘ └──────────────────────────────┘
```
---
## Supported Resources and Interactions
The `CapabilityStatement` (available at `GET /fhir/R4/metadata`, no auth required) declares:
| Resource | Create | Read | Search |
|----------|--------|------|--------|
| Patient | ✓ | ✓ | ✓ (by identifier) |
| Encounter | ✓ | ✓ | ✓ (by patient, status) |
| Observation | ✓ | | |
| MedicationAdministration | ✓ | | |
| Bundle (transaction) | ✓ | | |
All FHIR endpoints use `application/fhir+json` as the content type (not `application/json`), following the FHIR specification.
---
## LOINC Code Mapping
FHIR Observations use standardized codes (LOINC, SNOMED CT) to identify what's being measured. The internal data model uses simpler codes like `"HEART_RATE"`. The `LoincCodeMapper` translates between them:
```csharp
public static class LoincCodeMapper
{
private static readonly Dictionary<string, LoincMapping> _map = new()
{
["8867-4"] = new("HEART_RATE", "/min"),
["8310-5"] = new("TEMP_C", "Cel", AllowFahrenheit: true),
["2823-3"] = new("POTASSIUM_MEQ_L", "mmol/L"),
["2708-6"] = new("SPO2", "%"),
["9279-1"] = new("RESP_RATE", "/min"),
["6690-2"] = new("WBC_K_UL", "10*3/uL"),
["8480-6"] = new("SYSTOLIC_BP", "mm[Hg]"),
["8462-4"] = new("DIASTOLIC_BP", "mm[Hg]"),
["2524-7"] = new("LACTATE_MMOL_L", "mmol/L"),
["777-3"] = new("PLATELET_K_UL", "10*3/uL"),
["1975-2"] = new("BILIRUBIN_MG_DL", "mg/dL"),
["2160-0"] = new("CREATININE_MG_DL", "mg/dL"),
["2703-7"] = new("PAO2_MMHG", "mm[Hg]"),
["80288-7"] = new("GCS_EYE", "{score}"),
["80289-5"] = new("GCS_VERBAL", "{score}"),
["80290-3"] = new("GCS_MOTOR", "{score}"),
// ... 19 mappings total
};
// SNOMED CT fallbacks for systems that don't use LOINC
private static readonly Dictionary<string, LoincMapping> _snomedMap = new()
{
["364075005"] = new("HEART_RATE", "/min"),
["431314004"] = new("SPO2", "%"),
["86290005"] = new("RESP_RATE", "/min"),
};
public static bool TryMap(string system, string code, out LoincMapping mapping)
{
if (system.Contains("loinc") && _map.TryGetValue(code, out mapping!))
return true;
if (system.Contains("snomed") && _snomedMap.TryGetValue(code, out mapping!))
return true;
mapping = null!;
return false;
}
}
```
When a FHIR Observation arrives with code `system: "http://loinc.org"`, `code: "8867-4"`, the mapper translates it to internal code `"HEART_RATE"` with expected unit `"/min"`.
### Unit Conversion
Some hospitals send temperatures in Fahrenheit. The `FhirUnitConverter` handles this:
```csharp
public static (decimal Value, string Unit) Normalize(
string internalCode, decimal value, string? fhirUnit, bool allowFahrenheit)
{
if (internalCode == "TEMP_C" && allowFahrenheit &&
(fhirUnit == "[degF]" || fhirUnit == "degF"))
{
var celsius = (value - 32m) * 5m / 9m;
return (Math.Round(celsius, 2), "Cel");
}
return (value, fhirUnit ?? "1");
}
```
The internal system always stores temperature in Celsius. If a FHIR Observation arrives in Fahrenheit, it's converted transparently.
---
## The Observation Mapper
The `ObservationFhirMapper` handles the most complex mapping because FHIR Observations can have multiple formats:
```csharp
public async Task<IReadOnlyList<MappedObservation>> ToIngestRequestsAsync(
Hl7.Fhir.Model.Observation fhir)
{
var encounterId = await _refs.ResolveEncounterReferenceAsync(fhir.Encounter);
var source = MapSource(fhir); // vital-signs → Device, laboratory → Lab
var recordedAt = fhir.Effective.ToUtcDateTimeOffset() ?? DateTimeOffset.UtcNow;
var idempotencyKey = fhir.Identifier?.FirstOrDefault()?.Value ?? fhir.Id;
// FHIR Observations can be single-value or multi-component
if (fhir.Component?.Count > 0)
{
// Multi-component (e.g., blood pressure with systolic + diastolic)
foreach (var component in fhir.Component)
results.AddRange(MapSingleCoding(component.Code, component.Value, ...));
}
else
{
// Single value (e.g., heart rate)
results.AddRange(MapSingleCoding(fhir.Code, fhir.Value, ...));
}
}
```
A FHIR blood pressure Observation has two **components** (systolic and diastolic). The mapper produces two internal observations from one FHIR resource.
---
## Transaction Bundle Processing
A transaction Bundle groups multiple resources into one atomic operation — like a database transaction. The `FhirBundleProcessor` handles this:
```csharp
public async Task<Bundle> ProcessTransactionAsync(Bundle transaction)
{
var response = new Bundle { Type = Bundle.BundleType.TransactionResponse };
// Sort entries: Patient first, then Encounter, then everything else
var entries = transaction.Entry
.OrderBy(e => Priority(e.Resource))
.ToList();
await using var tx = await _db.Database.BeginTransactionAsync();
foreach (var entry in entries)
{
try
{
var location = resource switch
{
Patient p => await ProcessPatientAsync(p),
Encounter e => await ProcessEncounterAsync(e),
Observation o => await ProcessObservationAsync(o),
MedicationAdministration m => await ProcessMedAsync(m),
_ => throw new FhirMappingException("Unsupported resource type")
};
response.Entry.Add(new Bundle.EntryComponent
{
Response = new Bundle.ResponseComponent
{ Status = "201 Created", Location = location }
});
}
catch (Exception ex)
{
await tx.RollbackAsync(); // all-or-nothing
response.Entry.Add(/* error entry */);
return response;
}
}
await tx.CommitAsync();
return response;
}
```
**Why sort by priority?** An Encounter references a Patient, and an Observation references an Encounter. If the Bundle contains all three, the Patient must be created first (so the Encounter can reference it), then the Encounter (so the Observation can reference it). The `Priority()` function ensures this ordering.
**All-or-nothing**: If any entry fails, the entire transaction rolls back — no partially-created data.
---
## FHIR Error Responses: OperationOutcome
FHIR has its own error format. Instead of the project's standard `ApiResponse<T>` envelope, FHIR endpoints return `OperationOutcome` resources:
```csharp
public static class FhirOperationOutcomeBuilder
{
public static OperationOutcome FromException(Exception ex) => ex switch
{
FhirMappingException fme => Create(fme.HttpStatus, fme.FhirIssueCode, fme.Message),
NotFoundException => Create(404, "not-found", ex.Message),
ValidationException => Create(422, "invalid", ex.Message),
ConflictException => Create(409, "conflict", ex.Message),
_ => Create(500, "exception", "An unexpected error occurred.")
};
}
```
The `FhirExceptionFilter` catches exceptions on `/fhir/*` paths and converts them to `OperationOutcome` responses with `Content-Type: application/fhir+json`:
```json
{
"resourceType": "OperationOutcome",
"issue": [{
"severity": "warning",
"code": "not-found",
"diagnostics": "Patient not found."
}]
}
```
---
## External Identifier Resolution
When a FHIR Encounter references a Patient as `"subject": {"reference": "Patient/MRN-001"}`, the system needs to find the internal UUID for that patient. The `ExternalResourceIdentifier` table maps between external identifiers (hospital MRNs, visit numbers) and internal UUIDs:
| resource_type | system | value | internal_id |
|--------------|--------|-------|-------------|
| Patient | `http://hospital.example/mrn` | MRN-001 | `3fa85f64-...` |
| Encounter | `http://hospital.example/visit` | VISIT-100 | `7e4b2a1f-...` |
This mapping is created when a resource is first ingested and used for all subsequent references. It's what makes the FHIR endpoints **idempotent** — sending the same Patient twice (same identifier) updates the existing record instead of creating a duplicate.
---
## Authentication for FHIR Endpoints
FHIR endpoints accept two authentication methods (see Guide 15):
1. **JWT token** — for admin users accessing via the dashboard or Swagger
2. **API key** — for integration engines like Mirth Connect
```
POST /fhir/R4/Observation
X-Api-Key: dev-integration-key-change-in-production
Content-Type: application/fhir+json
```
The FHIR API key creates an identity with the `Integration` role, which has permissions for `fhir:ingest`, `fhir:read`, `patients:write`, `encounters:write`, `observations:ingest`, and `medications:write`.
---
## Key Takeaways
- **FHIR is the healthcare data standard** — it defines how to represent patients, encounters, observations, and medications as JSON resources with standard coding systems
- **A facade translates, not stores** — the internal database uses its own schema; the FHIR layer maps between FHIR resources and internal entities
- **LOINC and SNOMED CT codes are translated to internal codes** — `"8867-4"` (LOINC) becomes `"HEART_RATE"` internally; SNOMED fallbacks handle systems that don't use LOINC
- **Transaction Bundles are atomic** — Patient → Encounter → Observation ordering is enforced, and any failure rolls back the entire bundle
- **OperationOutcome is the FHIR error format** — FHIR endpoints return structured error resources instead of the project's standard API envelope
- **External identifiers enable idempotency** — the same resource sent twice (same identifier) updates rather than duplicates
- **Unit conversion happens transparently** — Fahrenheit temperatures are converted to Celsius during mapping so the internal system works with a single unit
@@ -0,0 +1,181 @@
# Guide 20: OpenAPI / Swagger Documentation
## What is OpenAPI and Swagger?
**OpenAPI** (formerly called Swagger Specification) is a standard format for describing REST APIs. An OpenAPI specification is a JSON or YAML file that lists every endpoint, its parameters, request/response shapes, authentication requirements, and error codes. Think of it as a machine-readable instruction manual for your API.
**Swagger** is a set of tools that work with OpenAPI specifications:
- **Swagger UI**: A web-based interactive API explorer. Developers can browse endpoints, see parameter descriptions, and make live test requests — all from the browser, without writing any code.
- **Swashbuckle**: A .NET library that automatically generates the OpenAPI specification from your controller code and XML documentation comments, and hosts Swagger UI.
**Why does this matter?** Without API documentation, frontend developers need to read the backend C# code (or ask the backend developer) to understand how to call each endpoint. With Swagger UI, they open a browser, see every endpoint, and can test them immediately. The documentation stays in sync with the code automatically because it's generated from the code.
---
## Setup in Program.cs
```csharp
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
// Basic API information
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "VigilCare Clinical API",
Version = "v1",
Description = "Clinical monitoring and alerting platform API"
});
// Tell Swagger UI that the API requires a JWT Bearer token
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter your JWT token"
});
// Apply the Bearer requirement to all endpoints by default
options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
{
{
new OpenApiSecuritySchemeReference("Bearer", document),
new List<string>()
}
});
// Include XML documentation comments from the compiled assembly
var xmlPath = Path.Combine(AppContext.BaseDirectory,
$"{Assembly.GetExecutingAssembly().GetName().Name}.xml");
options.IncludeXmlComments(xmlPath);
});
```
**What does each part do?**
| Setting | Purpose |
|---------|---------|
| `AddEndpointsApiExplorer()` | Enables the metadata extraction that Swashbuckle needs to discover your endpoints |
| `SwaggerDoc("v1", ...)` | Names the API spec "v1" with a title and description shown at the top of Swagger UI |
| `AddSecurityDefinition("Bearer", ...)` | Adds an "Authorize" button to Swagger UI where developers can paste their JWT token |
| `AddSecurityRequirement(...)` | Shows a lock icon on every endpoint, indicating authentication is required |
| `IncludeXmlComments(xmlPath)` | Reads the `///` XML doc comments from your C# code and displays them as endpoint descriptions |
### Enabling Swagger UI
```csharp
app.UseSwagger(); // Serves the OpenAPI spec at /swagger/v1/swagger.json
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "VigilCare Clinical API v1");
});
```
Available at: `http://localhost:5270/swagger/ui`
---
## Writing Good Swagger Documentation
Swagger UI shows two things about each endpoint: information from your C# code attributes, and information from XML documentation comments.
### Controller and Action Attributes
```csharp
[ApiController]
[Route("api/v1/alert-thresholds")]
[Produces("application/json")]
[Authorize]
public class AlertThresholdsController : ControllerBase
{
[HttpPost]
[AuthorizePermission(ClinicalPermissions.ThresholdsWrite)]
[ProducesResponseType(typeof(ApiResponse<AlertThreshold>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Create([FromBody] AlertThresholdRequest req)
```
- **`[Produces("application/json")]`** tells Swagger the response format
- **`[ProducesResponseType(...)]`** documents which status codes the endpoint can return and what the response body looks like. Swagger UI shows these as expandable response examples.
- **`[FromBody]`** tells Swagger the request body schema comes from `AlertThresholdRequest`
### XML Documentation Comments
```csharp
/// <summary>
/// Creates a new alert threshold for an observation code.
/// </summary>
/// <param name="req">Threshold bounds and display metadata.</param>
/// <returns>The created threshold.</returns>
[HttpPost]
public async Task<IActionResult> Create([FromBody] AlertThresholdRequest req)
```
The `<summary>` appears as the endpoint description in Swagger UI. The `<param>` tags describe individual parameters. The `<returns>` tag describes the response.
**How does this work?** When you build a C# project with `<GenerateDocumentationFile>true</GenerateDocumentationFile>` in the `.csproj` file, the compiler creates an XML file containing all `///` comments. Swashbuckle reads this XML file at runtime and merges the comments into the OpenAPI specification.
---
## What Swagger UI Shows
When you open `http://localhost:5270/swagger/ui`, you see:
1. **API title and description** from `SwaggerDoc()`
2. **Grouped endpoints** organized by controller (Encounters, Observations, Alert Thresholds, FHIR, etc.)
3. **For each endpoint**:
- HTTP method and URL (e.g., `POST /api/v1/encounters/{encounterId}/observations`)
- Summary from `<summary>` XML comment
- Parameter descriptions from `<param>` XML comments
- Request body schema (auto-generated from the C# request class)
- Response schemas for each status code (from `[ProducesResponseType]`)
4. **"Authorize" button** — paste a JWT token to authenticate all subsequent requests
5. **"Try it out" button** — fill in parameters and execute real requests against the running API
---
## Security Scheme in Swagger UI
The security definition adds an "Authorize" button at the top of Swagger UI:
```csharp
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter your JWT token"
});
```
**How to use it:**
1. Call `POST /api/v1/auth/login` with username/password to get a token
2. Click "Authorize" in Swagger UI
3. Paste the token (without the "Bearer " prefix — Swagger adds it automatically)
4. Click "Authorize" — all subsequent requests include the `Authorization: Bearer <token>` header
---
## The Generated OpenAPI Specification
The raw specification is available at `/swagger/v1/swagger.json`. It's a standard OpenAPI 3.0 document that can be consumed by:
- **Code generators**: Generate API client libraries for TypeScript, Python, Java, etc. using tools like `openapi-generator` or `nswag`
- **Testing tools**: Import into Postman, Insomnia, or Bruno for manual testing
- **Documentation platforms**: Host on ReadMe, Stoplight, or Redocly for public documentation
- **Contract testing**: Validate that the API implementation matches the specification
---
## Key Takeaways
- **Swagger UI makes your API self-documenting** — developers can explore and test endpoints from a browser without reading C# code
- **Write `<summary>` comments on every controller action** — they become the endpoint descriptions in Swagger UI. Without them, endpoints are listed without any explanation.
- **Use `[ProducesResponseType]` for every status code** — this documents the possible responses and their shapes, making it clear what success and error responses look like
- **The security definition enables authenticated testing** — developers can paste a JWT token in the UI and test protected endpoints without using curl or Postman
- **The OpenAPI spec is a machine-readable contract** — frontend teams can generate TypeScript API clients from it, ensuring type-safe API calls without manual typing
- **Documentation stays in sync with code** — because it's generated from the actual controller code and attributes, it can never go stale (unlike a manually-written Wiki page)
@@ -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.