# 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`: ```csharp public record ApiResponse(bool Success, int StatusCode, T? Data, ApiError? Error) { public static ApiResponse Ok(T data) => new(true, 200, data, null); public static ApiResponse Created(T data) => new(true, 201, data, null); public static ApiResponse 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 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.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 Create([FromBody] AlertThresholdRequest req) { var threshold = await _thresholds.CreateAsync(req); return StatusCode(201, ApiResponse.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 Ingest(Guid encounterId, [FromBody] BatchIngestRequest req) { if (req.Observations.Count == 0) return BadRequest(ApiResponse.Fail(400, "At least one observation is required.", "EMPTY_BATCH")); if (req.Observations.Count > 10) return BadRequest(ApiResponse.Fail(400, "Batch size cannot exceed 10 observations.", "BATCH_TOO_LARGE")); // Process each observation... return StatusCode(201, ApiResponse.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 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 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> 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(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 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 `` blocks) appear in the Swagger UI, making it easy for frontend developers to understand each endpoint without reading the C# code. ```csharp /// /// Returns cursor-paginated observation history for an encounter. /// /// Encounter id. /// Optional observation code filter. /// Maximum items per page. /// Opaque cursor from a previous page. [HttpGet] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task History(...) ``` `[ProducesResponseType]` tells Swagger which response shapes are possible, generating accurate API documentation. --- ## Key Takeaways - **Consistent envelope** (`ApiResponse`) 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