Files
vigilcare-clinical/docs/guides/18-rest-api-design-conventions.md
T

14 KiB

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

[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>:

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:

// 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)

[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

[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)

[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

[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

[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:

{
  "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
{
  "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...
{
  "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:

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

[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:

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.

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