feature: MIMIC-IV Replay Scenario Generator
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user