Skip to content

API & Web Infrastructure

Six packages that form the HTTP infrastructure layer of a Granit application. CORS policy enforcement (ISO 27001-compliant wildcard rejection), URL-based API versioning with deprecation headers, Scalar OpenAPI documentation with OAuth2/PKCE, RFC 7807 Problem Details exception handling, Stripe-style idempotency middleware with Redis state machine, and per-tenant rate limiting with four algorithms.

PackageRoleDepends on
Granit.CorsCORS default policy, ISO 27001 wildcard rejectionGranit.Core
Granit.ApiVersioningAsp.Versioning, URL segment + query stringGranit.Core
Granit.ApiDocumentationScalar OpenAPI, OAuth2/PKCE, multi-version docsGranit.ApiVersioning, Granit.Security
Granit.ExceptionHandlingRFC 7807 Problem Details, IExceptionStatusCodeMapper chainGranit.Core
Granit.IdempotencyIdempotency-Key middleware, Redis state machineGranit.Caching, Granit.Security
Granit.RateLimitingPer-tenant rate limiting, 4 algorithms, Wolverine middlewareGranit.Core, Granit.ExceptionHandling, Granit.Features, Granit.Security
graph TD
    CORS[Granit.Cors] --> CO[Granit.Core]
    AV[Granit.ApiVersioning] --> CO
    AD[Granit.ApiDocumentation] --> AV
    AD --> SEC[Granit.Security]
    EH[Granit.ExceptionHandling] --> CO
    ID[Granit.Idempotency] --> CA[Granit.Caching]
    ID --> SEC
    RL[Granit.RateLimiting] --> CO
    RL --> EH
    RL --> FT[Granit.Features]
    RL --> SEC

Standardized CORS configuration driven by appsettings.json. Wildcard origins are rejected at startup in non-development environments (ISO 27001 compliance).

[DependsOn(typeof(GranitCorsModule))]
public class AppModule : GranitModule { }
{
"Cors": {
"AllowedOrigins": ["https://app.example.com", "https://admin.example.com"],
"AllowCredentials": false
}
}

In Program.cs:

app.UseCors(); // Uses the default policy configured by the module
RuleEnforced atEnvironment
At least one origin requiredStartupAll
Wildcard * forbiddenStartupNon-development
AllowCredentials + wildcard rejectedStartupAll (CORS specification)
PropertyDefaultDescription
AllowedOrigins[]Allowed CORS origins (required, minimum 1)
AllowCredentialsfalseInclude Access-Control-Allow-Credentials: true

The default policy applies AllowAnyHeader() and AllowAnyMethod(), which is standard for REST APIs. Origins are restricted to the configured list.


URL-based API versioning using Asp.Versioning. Primary reader: URL segment (/api/v{version:apiVersion}/resource). Fallback reader: query string (?api-version=1.0).

[DependsOn(typeof(GranitApiVersioningModule))]
public class AppModule : GranitModule { }
{
"ApiVersioning": {
"DefaultMajorVersion": 1,
"ReportApiVersions": true
}
}
var v1 = app.NewVersionedApi("Appointments").MapGroup("/api/v{version:apiVersion}");
var v1Group = v1.MapGroup("/appointments").HasApiVersion(1);
v1Group.MapGet("/", GetAppointments);
v1Group.MapPost("/", CreateAppointment);
var v2Group = v1.MapGroup("/appointments").HasApiVersion(2);
v2Group.MapGet("/", GetAppointmentsV2);

Mark endpoints as deprecated with automatic Deprecation, Sunset, and Link response headers:

v1Group.MapGet("/legacy-patients", GetLegacyPatients)
.Deprecated(sunsetDate: "2026-06-01", link: "https://docs.example.com/migration/v2");

Response headers:

Deprecation: true
Sunset: Sun, 01 Jun 2026 00:00:00 GMT
Link: <https://docs.example.com/migration/v2>; rel="deprecation"

Each call to a deprecated endpoint is logged at Warning level.

PropertyDefaultDescription
DefaultMajorVersion1Default API version when client does not specify one
ReportApiVersionstrueInclude api-supported-versions and api-deprecated-versions headers

OpenAPI document generation and Scalar interactive UI. Generates one OpenAPI document per declared API major version. Supports JWT Bearer and OAuth2 Authorization Code with PKCE for interactive authentication.

[DependsOn(typeof(GranitApiDocumentationModule))]
public class AppModule : GranitModule { }
{
"ApiDocumentation": {
"Title": "Clinic API",
"MajorVersions": [1, 2],
"Description": "Patient and appointment management API"
}
}

In Program.cs:

app.UseGranitApiDocumentation(); // Maps /openapi/v1.json, /openapi/v2.json, /scalar

Provide realistic example values for request DTOs without coupling to OpenAPI:

public class AppointmentSchemaExamples : ISchemaExampleProvider
{
public IReadOnlyDictionary<Type, JsonNode> GetExamples() => new Dictionary<Type, JsonNode>
{
[typeof(CreateAppointmentRequest)] = new JsonObject
{
["patientId"] = "d4e5f6a7-1234-5678-9abc-def012345678",
["doctorId"] = "a1b2c3d4-5678-9abc-def0-123456789abc",
["scheduledAt"] = "2026-04-15T09:30:00Z",
["durationMinutes"] = 30
}
};
}

Implementations of ISchemaExampleProvider are auto-discovered at startup.

Exclude inter-service endpoints from public documentation:

app.MapPost("/webhooks/keycloak", HandleKeycloakWebhook)
.WithMetadata(new InternalApiAttribute());

The module registers these OpenAPI transformers automatically:

TransformerPurpose
JwtBearerSecuritySchemeTransformerAdds Bearer security scheme when JWT is configured
OAuth2SecuritySchemeTransformerReplaces Bearer with OAuth2 Authorization Code when configured
SecurityRequirementOperationTransformerAnonymous endpoints override global security
ProblemDetailsSchemaDocumentTransformerAdds RFC 7807 ProblemDetails schema
ProblemDetailsResponseOperationTransformerDocuments 4xx/5xx Problem Details responses
TenantHeaderOperationTransformerDocuments X-Tenant-Id header when enabled
InternalApiDocumentTransformerRemoves [InternalApi] endpoints
WolverineOpenApiOperationTransformerEnhances Wolverine HTTP endpoint documentation
SchemaExampleSchemaTransformerApplies ISchemaExampleProvider examples
PropertyDefaultDescription
Title"API"OpenAPI document and Scalar UI title
MajorVersions[1]Major version numbers to document
DescriptionnullOpenAPI description (Markdown supported)
ContactEmailnullContact email in OpenAPI info
LogoUrlnullLogo URL for Scalar sidebar
FaviconUrlnullFavicon for Scalar page
EnableInProductionfalseExpose docs in Production
EnableTenantHeaderfalseDocument required tenant header
TenantHeaderName"X-Tenant-Id"Tenant header name
AuthorizationPolicynullPolicy for doc endpoints (null = inherit, "" = anonymous)
OAuth2.AuthorizationUrlnullOAuth2 authorization endpoint
OAuth2.TokenUrlnullOAuth2 token endpoint
OAuth2.ClientIdnullPublic OAuth2 client ID (PKCE-capable)
OAuth2.EnablePkcetrueEnable PKCE with S256
OAuth2.Scopes["openid"]OAuth2 scopes to request

Centralized exception-to-HTTP-response pipeline implementing RFC 7807 Problem Details. All exceptions are caught, mapped to status codes via a chain of responsibility, logged at the appropriate level, and serialized as application/problem+json.

[DependsOn(typeof(GranitExceptionHandlingModule))]
public class AppModule : GranitModule { }

In Program.cs (must be the first middleware):

app.UseGranitExceptionHandling(); // Before routing, authentication, authorization
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
flowchart TD
    A[Exception thrown] --> B{OperationCanceledException?}
    B -->|yes| C[Log Info, return true]
    B -->|no| D[IExceptionStatusCodeMapper chain]
    D --> E{5xx?}
    E -->|yes| F[Log Error]
    E -->|no| G{499?}
    G -->|yes| H[Log Info]
    G -->|no| I[Log Warning]
    F --> J[Build ProblemDetails]
    H --> J
    I --> J
    J --> K{IUserFriendlyException?}
    K -->|yes| L[Title = exception.Message]
    K -->|no| M{ExposeInternalErrorDetails?}
    M -->|yes| N[Title = message, Detail = stack trace]
    M -->|no| O["Title = 'An unexpected error occurred.'"]
    L --> P[Write RFC 7807 response]
    N --> P
    O --> P

Chain of responsibility pattern. Mappers are evaluated in registration order; the first non-null result wins. The DefaultExceptionStatusCodeMapper is registered last as a catch-all.

Default mappings:

ExceptionStatus code
EntityNotFoundException404
NotFoundException404
ForbiddenException403
UnauthorizedAccessException403
ValidationException422
IHasValidationErrors422
ConflictException409
BusinessRuleViolationException422
BusinessException400
IHasErrorCode400
NotImplementedException501
OperationCanceledException499
TimeoutException408
(any other)500

Other Granit modules register their own mappers to extend the chain:

internal sealed class InvoiceConcurrencyMapper : IExceptionStatusCodeMapper
{
public int? TryGetStatusCode(Exception exception) => exception switch
{
InvoiceLockedException => StatusCodes.Status423Locked,
_ => null
};
}
// In module ConfigureServices:
services.AddSingleton<IExceptionStatusCodeMapper, InvoiceConcurrencyMapper>();

The response body includes standard RFC 7807 fields plus Granit-specific extensions:

{
"status": 422,
"title": "Invoice amount must be positive.",
"detail": null,
"traceId": "abcd1234ef567890",
"errorCode": "Invoices:InvalidAmount",
"errors": {
"Amount": ["Granit:Validation:GreaterThanValidator"]
}
}
ExtensionSourcePresent when
traceIdActivity.Current?.TraceId or HttpContext.TraceIdentifierAlways
errorCodeIHasErrorCode.ErrorCodeException implements IHasErrorCode
errorsIHasValidationErrors.ValidationErrorsException implements IHasValidationErrors

Stripe-style HTTP idempotency middleware backed by Redis. Ensures that retried POST/PUT/PATCH requests produce the same response without re-executing side effects. Uses SHA-256 composite keys and AES-256-CBC encrypted entries.

[DependsOn(typeof(GranitIdempotencyModule))]
public class AppModule : GranitModule { }
{
"Idempotency": {
"HeaderName": "Idempotency-Key",
"KeyPrefix": "idp",
"CompletedTtl": "24:00:00",
"InProgressTtl": "00:00:30",
"ExecutionTimeout": "00:00:25",
"MaxBodySizeBytes": 1048576
}
}

In Program.cs (after authentication/authorization):

app.UseAuthentication();
app.UseAuthorization();
app.UseGranitIdempotency(); // After auth so ICurrentUserService is populated
app.MapPost("/api/v1/invoices", CreateInvoice)
.WithMetadata(new IdempotentAttribute { Required = true });
// Optional key — middleware is bypassed when header is absent
app.MapPut("/api/v1/invoices/{id}", UpdateInvoice)
.WithMetadata(new IdempotentAttribute { Required = false });
// Custom TTL (2 hours instead of default 24h)
app.MapPost("/api/v1/payments", ProcessPayment)
.WithMetadata(new IdempotentAttribute { CompletedTtlSeconds = 7200 });
stateDiagram-v2
    [*] --> Absent
    Absent --> InProgress: TryAcquire (SET NX PX)
    InProgress --> Completed: SetCompleted (SET XX PX)
    InProgress --> Absent: 5xx / timeout (DELETE)
    Completed --> Absent: TTL expires
    Completed --> Completed: Replay cached response

The Redis key is partitioned by tenant, user, HTTP method, and route to prevent cross-user key collisions:

{prefix}:{tenantId|global}:{userId|anon}:{method}:{routePattern}:{sha256(idempotencyKeyValue)}

Example: idp:global:d4e5f6a7:POST:/api/v1/invoices:a1b2c3d4e5f6...

The middleware computes a SHA-256 digest of the composite input (method + route + idempotency key value + request body). On replay, if the payload hash does not match the stored entry, the request is rejected with 422 to prevent key reuse with a different body.

Status codeCached?Rationale
2xxYesSuccessful responses are replayed
400, 404, 409, 410, 422YesDeterministic client errors
401, 403NoAuthentication state may change
5xxNoLock is released for retry
499 (client disconnect)NoResponse may be truncated
ScenarioStatusTitle
Missing header (required)422Missing Idempotency-Key
Multipart request422Unsupported Content-Type
Key in progress (another pod)409Request In Progress
Payload hash mismatch422Idempotency Key Conflict
Execution timeout503Execution Timeout

Replayed responses include an X-Idempotency-Replayed: true header.

PropertyDefaultDescription
HeaderName"Idempotency-Key"HTTP header name
KeyPrefix"idp"Redis key prefix
CompletedTtl24:00:00TTL for completed entries
InProgressTtl00:00:30Lock TTL (must be > ExecutionTimeout)
ExecutionTimeout00:00:25Max handler execution time
MaxBodySizeBytes1048576Max request body size to hash (1 MiB)

Per-tenant rate limiting with four algorithms, configurable policies, Redis-backed counters (with in-memory fallback), and Wolverine message handler support. Integrates with Granit.Features for plan-based dynamic quotas.

[DependsOn(typeof(GranitRateLimitingModule))]
public class AppModule : GranitModule { }
{
"RateLimiting": {
"Enabled": true,
"KeyPrefix": "rl",
"BypassRoles": ["admin"],
"FallbackOnCounterStoreFailure": "Allow",
"Policies": {
"api-default": {
"Algorithm": "SlidingWindow",
"PermitLimit": 1000,
"Window": "00:01:00",
"SegmentsPerWindow": 6
},
"api-sensitive": {
"Algorithm": "TokenBucket",
"TokenLimit": 50,
"TokensPerPeriod": 10,
"ReplenishmentPeriod": "00:00:10"
}
}
}
}
app.MapGet("/api/v1/appointments", GetAppointments)
.RequireGranitRateLimiting("api-default");
app.MapPost("/api/v1/payments", ProcessPayment)
.RequireGranitRateLimiting("api-sensitive");

Decorate message types with [RateLimited] and register the Wolverine middleware:

[RateLimited("api-default")]
public record SyncPatientCommand(Guid PatientId);
// In Wolverine configuration
opts.Policies.AddMiddleware<RateLimitMiddleware>(
chain => chain.MessageType.GetCustomAttributes(typeof(RateLimitedAttribute), true).Length > 0);

When the rate limit is exceeded, RateLimitExceededException is thrown and handled by Wolverine’s retry policy.

AlgorithmUse caseKey parameters
SlidingWindowGeneral API rate limiting (default)PermitLimit, Window, SegmentsPerWindow
FixedWindowSimple counter, lowest memoryPermitLimit, Window
TokenBucketControlled burst allowanceTokenLimit, TokensPerPeriod, ReplenishmentPeriod
ConcurrencyLimit simultaneous in-flight requestsPermitLimit

Rate limit counters are partitioned by tenant. The Redis key uses hash tags to ensure all keys for a tenant hash to the same Redis Cluster slot:

{prefix}:{{tenantId|global}}:{policyName}

When Redis is unavailable, the FallbackOnCounterStoreFailure setting controls behavior:

ValueBehaviorUse case
AllowLet the request through, log warningPrefer availability over quota enforcement
DenyReject with 429Conservative — prefer safety over availability
{
"status": 429,
"title": "Too Many Requests",
"detail": "Rate limit exceeded for policy 'api-default'. Retry after 10s.",
"policy": "api-default",
"limit": 1000,
"remaining": 0,
"retryAfter": 10
}

The Retry-After header is also set on the response.

When UseFeatureBasedQuotas is enabled, the permit limit is resolved dynamically from Granit.Features (e.g., per-plan quotas). The convention-based feature name is RateLimit.{PolicyName}, overridable via RateLimitPolicyOptions.FeatureName.

PropertyDefaultDescription
EnabledtrueEnable/disable rate limiting globally
KeyPrefix"rl"Redis key prefix
FallbackOnCounterStoreFailureAllowBehavior when Redis is down
BypassRoles[]Roles that skip rate limiting
UseFeatureBasedQuotasfalseUse Granit.Features for dynamic quotas
Policies.*Named rate limiting policies (see below)

Policy options:

PropertyDefaultDescription
AlgorithmSlidingWindowRate limiting algorithm
PermitLimit1000Max permits per window
Window00:01:00Window duration
SegmentsPerWindow6Sliding window segments (accuracy vs. memory)
TokenLimit50Max tokens (TokenBucket only)
TokensPerPeriod10Tokens added per replenishment (TokenBucket only)
ReplenishmentPeriod00:00:10Replenishment interval (TokenBucket only)
FeatureNamenullOverride feature name for dynamic quotas

CategoryKey typesPackage
ModulesGranitCorsModule, GranitApiVersioningModule, GranitApiDocumentationModule, GranitExceptionHandlingModule, GranitIdempotencyModule, GranitRateLimitingModule
CORSGranitCorsOptionsGranit.Cors
VersioningGranitApiVersioningOptions, DeprecatedAttribute, .Deprecated()Granit.ApiVersioning
DocumentationApiDocumentationOptions, OAuth2Options, ISchemaExampleProvider, InternalApiAttributeGranit.ApiDocumentation
Exception handlingIExceptionStatusCodeMapper, ExceptionHandlingOptions, GranitExceptionHandlerGranit.ExceptionHandling
IdempotencyIIdempotencyStore, IIdempotencyMetadata, IdempotentAttribute, IdempotencyOptions, IdempotencyEntry, IdempotencyStateGranit.Idempotency
Rate limitingIRateLimitCounterStore, IRateLimitQuotaProvider, RateLimitResult, RateLimitedAttribute, RateLimitExceededException, GranitRateLimitingOptions, RateLimitPolicyOptionsGranit.RateLimiting
ExtensionsAddGranitCors(), AddGranitApiVersioning(), AddGranitApiDocumentation(), UseGranitApiDocumentation(), AddGranitExceptionHandling(), UseGranitExceptionHandling(), AddGranitIdempotency(), UseGranitIdempotency(), AddGranitRateLimiting(), .RequireGranitRateLimiting()