Skip to content

HTTP Conventions

This page documents the HTTP conventions used across all Granit endpoints. These conventions ensure consistent behavior for API consumers, generated clients, and OpenAPI documentation.

CodeNameWhen to useResponse body
200OKRequest processed, result in responseYes
201CreatedResource createdYes + Location header
202AcceptedAsynchronous processing startedYes (tracking ID or job reference)
204No ContentOperation succeeded, nothing to returnNo
207Multi-StatusBatch operation with mixed resultsYes (BatchResult<T>)

200 vs 202: Use 200 when the response is immediate and complete. Use 202 when processing is deferred (export generation, GDPR erasure, background jobs). A 202 response must always include a tracking mechanism (body with requestId/jobId, Location header for polling, or webhook notification).

204 use cases: DELETE operations, PUT /settings, acknowledgment endpoints like POST /notifications/mark-read.

CodeNameWhen to use
400Bad RequestSyntactically invalid request (malformed JSON, wrong type)
401UnauthorizedToken absent or expired
403ForbiddenAuthenticated but not authorized
404Not FoundResource not found (routes with {id} parameter)
409ConflictConcurrency conflict (stale version, duplicate)
422Unprocessable EntityBusiness validation failed (FluentValidation rules)
429Too Many RequestsRate limiting triggered
CodeNameWhen to use
500Internal Server ErrorUnexpected server-side error
502Bad GatewayUpstream service unavailable (Keycloak, Vault, S3)
503Service UnavailableApplication in maintenance or overloaded

All error responses (4xx and 5xx) use the application/problem+json content type defined by RFC 7807.

Always use TypedResults.Problem(). Never use TypedResults.BadRequest<string>().

// Correct
return TypedResults.Problem(
detail: "The template name exceeds 200 characters.",
statusCode: StatusCodes.Status422UnprocessableEntity);
// Wrong - produces application/json, not application/problem+json
return TypedResults.BadRequest("The template name exceeds 200 characters.");

The Roslyn analyzer GRAPI002 flags TypedResults.BadRequest calls that include a body argument and offers an automatic code fix.

{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "Validation failed",
"status": 422,
"detail": "The NISS format is invalid.",
"instance": "/api/v1/patients"
}

Use union types so OpenAPI generates correct response schemas:

private static async Task<Results<Ok<TemplateDetailResponse>, NotFound, ProblemHttpResult>>
HandleGetDetailAsync(/* parameters */)

The ProblemDetailsResponseOperationTransformer automatically declares error responses in the OpenAPI documentation.

FluentValidationEndpointFilter<T> returns 422 Unprocessable Entity with HttpValidationProblemDetails containing structured error codes:

{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "One or more validation errors occurred.",
"status": 422,
"errors": {
"Name": ["Granit:Validation:NotEmptyValidator"]
}
}
SuffixUsageExample
*RequestInput bodies (POST/PUT/PATCH)TemplateCreateRequest
*ResponseTop-level return typesTemplateDetailResponse

Never use the *Dto suffix. EF Core entities must never be returned directly from endpoints. Always create a *Response record.

Module-specific DTOs must be prefixed with their module context. OpenAPI flattens C# namespaces, so generic names cause schema conflicts.

// Correct - prefixed with module context
public sealed record WorkflowTransitionRequest(string TargetState);
public sealed record TemplateCreateRequest(string Name, string Content);
// Wrong - ambiguous in flattened OpenAPI schema
public sealed record TransitionRequest(string TargetState);
public sealed record CreateRequest(string Name, string Content);

Shared cross-cutting types are exempt from the prefix rule: PagedResult<T>, ProblemDetails, BatchResult<T>.

Granit supports two pagination modes via QueryDefinition<T>.

GET /api/v1/patients?page=1&pageSize=20&sort=-createdAt
ParameterTypeDefaultDescription
pageint1Page number (1-based)
pageSizeint20Page size (capped at MaxPageSize, default 100)
sortstringPer QueryDefinitionComma-separated fields, - prefix for descending
skipTotalCountboolfalseSkip the COUNT(*) query for large tables

Response:

{
"items": [],
"totalCount": 142,
"hasMore": true
}

When skipTotalCount=true, totalCount is null and hasMore is determined by fetching pageSize + 1 rows.

Opt-in mode for real-time feeds, infinite scroll, and large datasets. Enabled via SupportsCursorPagination() in the QueryDefinition<T>.

GET /api/v1/events?pageSize=50
GET /api/v1/events?cursor=eyJpZCI6MTIzfQ&pageSize=50
ParameterTypeDescription
cursorstringOpaque cursor (Base64). Absent for first page
pageSizeintPage size

Response:

{
"items": [],
"totalCount": null,
"nextCursor": "eyJpZCI6MTczfQ",
"hasMore": true
}

The cursor is opaque. Clients must never decode or construct it.

public sealed record PagedResult<T>(
IReadOnlyList<T> Items,
int? TotalCount,
bool HasMore,
string? NextCursor = null);
CriterionOffsetCursor
Page number navigationYesNo
Total countYes (opt-out available)No
Frequently changing dataResults may skip/duplicateStable
Performance on large tablesOFFSET N degrades with NConstant (WHERE id > X)
Infinite scroll / real-timeNot recommendedRecommended

An endpoint can support both modes when the QueryDefinition declares SupportsCursorPagination(). The mode is determined by the presence of the cursor parameter.

Comma-separated field names. Prefix - for descending order:

GET /api/v1/patients?sort=-createdAt,lastName

Only fields declared Sortable() in the QueryDefinition are accepted. An unsortable field returns 400 Bad Request.

Syntax: filter[field.operator]=value

GET /api/v1/patients?filter[name.contains]=Alice&filter[age.gte]=18

Supported operators: eq, contains, startsWith, endsWith, gt, gte, lt, lte, in, between.

GET /api/v1/patients?presets[status]=active,pending
GET /api/v1/patients?quickFilters=MyPatients,Unread
GET /api/v1/patients?search=Alice

Searches fields declared via GlobalSearch() in the QueryDefinition.

Content typeUsage
application/jsonDefault for all request/response bodies
application/problem+jsonAll error responses (RFC 7807)
multipart/form-dataFile uploads
application/octet-streamFile downloads
  • Properties: camelCase (default System.Text.Json behavior)
  • Enums: PascalCase strings via JsonStringEnumConverter (e.g., "InProgress")
  • Dates: ISO 8601 with timezone (2025-03-15T10:30:00Z)
  • Identifiers: UUID v7 with dashes ("0193a5b2-7c3d-7def-8a12-bc3456789abc")

For operations processing multiple resources where individual items can fail independently:

{
"results": [
{ "value": { "id": "abc", "status": "Created" }, "isSuccess": true, "error": null },
{ "value": null, "isSuccess": false, "error": { "status": 422, "detail": "Invalid format" } }
],
"successCount": 1,
"failureCount": 1
}

Use 207 for: data imports, bulk updates, multi-recipient notifications. Do not use 207 for single-resource operations or transactional (all-or-nothing) operations.

  • Resource segments: kebab-case (/background-jobs, /reference-data)
  • Route parameters: camelCase ({patientId}, {tenantId})
  • Prefix: /api is an application-level decision, not enforced by the framework
// Application with UI - /api prefix
var api = app.MapGroup("api/v{version:apiVersion}")
.WithApiVersionSet(apiVersionSet);
// Pure API service - no prefix
var api = app.MapGroup("v{version:apiVersion}")
.WithApiVersionSet(apiVersionSet);

Endpoints are organized by MapGroup with OpenAPI tags:

RouteGroupBuilder group = endpoints.MapGroup(prefix)
.RequireAuthorization()
.WithTags("MobilePush");

Attach FluentValidation to endpoints via .ValidateBody<T>():

group.MapPost("/", HandleCreate)
.ValidateBody<TemplateCreateRequest>();

Modules with [assembly: WolverineHandlerModule] get automatic validator discovery via AddGranitWolverine(). Modules without Wolverine handlers must call AddGranitValidatorsFromAssemblyContaining<TValidator>() manually. Without registration, FluentValidationEndpointFilter<T> silently skips validation.

The Granit.Idempotency package provides Stripe-style idempotency via the Idempotency-Key HTTP header, backed by Redis.

HeaderFormatDescription
Idempotency-KeyClient-generated string (typically UUID)Unique key per operation
ScenarioResponse
First requestExecutes handler, caches response for 24h
Duplicate with same bodyReplays cached response + X-Idempotency-Replayed: true
Duplicate with different body422 with detail “payload does not match”
Concurrent duplicate409 with Retry-After header
Missing required key422 with detail about missing header
Multipart request422 (non-deterministic boundaries prevent hashing)
{
"Idempotency": {
"HeaderName": "Idempotency-Key",
"CompletedTtl": "1.00:00:00",
"InProgressTtl": "00:00:30",
"ExecutionTimeout": "00:00:25"
}
}

Cached status codes: 2xx + 400, 404, 409, 410, 422. Authentication errors (401/403) are never cached.

URL-segment versioning with query string fallback, powered by Asp.Versioning.

  1. URL segment: /api/v{version:apiVersion}/resource (primary)
  2. Query string: ?api-version=1.0 (fallback, visible in access logs)
{
"ApiVersioning": {
"DefaultMajorVersion": 1,
"ReportApiVersions": true
}
}

When ReportApiVersions is true, every response includes api-supported-versions and api-deprecated-versions headers.

When an endpoint or API version is deprecated:

HeaderFormatDescription
DeprecationtrueEndpoint is deprecated
SunsetHTTP-date (RFC 7231)Date after which endpoint will be removed
Link<url>; rel="deprecation"Link to migration documentation

Configured via the Cors section in appsettings.json:

{
"Cors": {
"AllowedOrigins": ["https://app.example.com", "https://admin.example.com"],
"AllowCredentials": false
}
}

The default policy allows any header and any method for the configured origins. Wildcard (*) origins are rejected in non-development environments (ISO 27001 compliance).

Endpoint typeCache-ControlRationale
User data (GET /me, /settings)private, no-cachePersonal data, must revalidate
Reference data (GET /reference-data)public, max-age=3600Rarely changes, shared
Paginated lists (GET /patients)private, no-storeSensitive data (GDPR)
Static resources (images, documents)public, max-age=86400, immutableContent-addressable
Query metadata (GET /meta)public, max-age=3600Stable configuration

GDPR constraint: responses containing personal data (PII) must never use public in Cache-Control. Use private or no-store to prevent intermediate caches (CDN, reverse proxy) from storing sensitive data.