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,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