# 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
