377 lines
14 KiB
Markdown
377 lines
14 KiB
Markdown
# 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
|