Declared responses
A handler declares the statuses it answers in its return type. Response<T1..Tn> is the set, with one case for each type argument.
This page covers code-first handlers. Generating from OpenAPI and Generating from Smithy cover the interfaces a contract generates.
The hardened-web template writes this handler in src/Todos/TodoController.cs. The examples' routes answer under /todos, the [BasePath("/todos")] on the template's library module.
using Hardened.Requests.Abstract.Responses;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Responses;
using ValidationModules.Constraints;
namespace Todos;
public class TodoController
{
[Operation("getTodo")]
[Get("/{id}")]
public async Task<Response<Todo, NotFound>> ById(ITodoStore store, [Range(Min = 1)] int id)
{
var todo = await store.Find(id);
if (todo is null)
{
return new NotFound("todo", $"No todo has id {id}.");
}
return todo;
}
}GET /todos/1
HTTP/1.1 200 OK
Content-Type: application/json
{"id":1,"title":"Read the generated code","done":true}GET /todos/99
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"resource":"todo","detail":"No todo has id 99.","type":"urn:hardened:problem:not-found","title":"Not Found","status":404}Each case's type carries its status. NotFound answers 404. The handler returns the value of one case. An implicit conversion turns that value into the set.
Response<T1..Tn> is in Hardened.Requests.Abstract.Responses. NotFound and the other built-in response types are in Hardened.Web.Runtime.Responses. Both namespaces come with the Hardened.Web.Runtime package.
The OpenAPI document lists every case, with its status and its schema. The template's build writes the document to src/Todos/openapi/Todos.json. These are the responses of GET /todos/{id}:
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Todo"
}
}
}
},
"400": {
"description": "The request failed validation.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RequestValidationError"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFound"
}
}
}
}
}The 400 answers a request that fails validation, such as GET /todos/0. Validation covers it.
Response models
Each handler's return type decides its response model. One class can hold handlers of different models.
| Model | The handler's return type | A status other than the success | Needs |
|---|---|---|---|
| Response | Response<T1..Tn> | Returned, as a case | net8.0 or later |
| Union | A C# union declaration | Returned, as a case | net11.0 and <LangVersion>preview</LangVersion> |
| Throws | The success type | Thrown with AsException(), and declared with [Throws<T>] | net8.0 or later |
A Response handler and a Union handler with the same cases send the same responses. The document lists the same statuses for both. A thrown problem type or generic form sends the same status, headers and body as the same response returned. Built-in response types describes the problem types and their generic forms.
dotnet new hardened-web writes Response handlers. --response-model union and --response-model throws write the others. Project templates covers the option.
[ResponseModel] on a module changes nothing a code-first build generates. On the class that carries [HardenedModule], the attribute compiles only with ResponseModel.Throws. ResponseModel.Response and ResponseModel.Union fail the build with CS1503. The error is in the generated module file, such as the template's TodosLibrary.Module.g.cs.
Cases and statuses
A set takes 2 to 8 type arguments.
A case answers the status in the [HttpStatus(n)] on its type. A type without [HttpStatus] answers the success status: 200, or the status the verb attribute's SuccessStatus names. [HttpStatus] is in Hardened.Web.Runtime.Responses.
A type can be a case once in a set. Response<Todo, NotFound, NotFound> fails with CS0457 where the handler returns a NotFound.
Two cases at one status are one response in the document, with a oneOf of the two schemas.
A set that holds no case answers 500 with an empty body. A handler that runs return default; answers that way. So does a handler that returns a null case value.
A handler can return one built-in response type without a set. A handler that returns Task<Created<Todo>> answers 201 with Location and the Todo as the body. The document lists the 201 with its Location header.
Built-in response types
Each type in this table is a sealed record in Hardened.Web.Runtime.Responses that carries [HttpStatus].
| Type | Status | Constructor | Header it sets |
|---|---|---|---|
Ok<T> | 200 | (T Value, IReadOnlyDictionary<string, string>? Headers = null) or (T value, string headerName, string headerValue) | The headers it is given |
Created<T> | 201 | (T Value, string Location) | Location |
Accepted | 202 | (string? Location = null) | Location, when it is given |
Accepted<T> | 202 | (T Value, string? Location = null) | Location, when it is given |
NoContent | 204 | () | None |
NotModified | 304 | (string? ETag = null) | ETag, when it is given |
BadRequest | 400 | (string? Detail = null) | None |
Unauthorized | 401 | (string? Detail = null, AuthorizationChallenge? Challenge = null) | WWW-Authenticate: Bearer when no challenge is given |
PaymentRequired | 402 | (string? Detail = null) | None |
Forbidden | 403 | (string? Detail = null) | None |
NotFound | 404 | (string Resource, string? Detail = null) | None |
MethodNotAllowed | 405 | (string Allow) | Allow |
NotAcceptable | 406 | () | None |
RequestTimeout | 408 | (string? Detail = null) | None |
Conflict | 409 | (string? Detail = null) | None |
Gone | 410 | (string? Detail = null) | None |
PreconditionFailed | 412 | (string? Detail = null) | None |
ContentTooLarge | 413 | (string? Detail = null) | None |
UnsupportedMediaType | 415 | (string? Detail = null) | None |
UnprocessableContent | 422 | (string? Detail = null) | None |
PreconditionRequired | 428 | (string? Detail = null) | None |
RateLimited | 429 | (TimeSpan RetryAfter, string? Detail = null) | Retry-After |
InternalServerError | 500 | (string? Detail = null) | None |
NotImplemented | 501 | (string? Detail = null) | None |
BadGateway | 502 | (string? Detail = null) | None |
ServiceUnavailable | 503 | (TimeSpan? After = null, string? Detail = null) | Retry-After, when After is given |
GatewayTimeout | 504 | (string? Detail = null) | None |
Ok<T>, Created<T> and Accepted<T> send Value as the body. Accepted, NoContent and NotModified send no body.
The types with a Detail parameter are the problem types. A problem type sends itself as the body: its constructor members first, then type, title and status. The 404 at the top of this page shows that order. type is urn:hardened:problem: followed by the record's name in lower case with hyphens. NotFound sends urn:hardened:problem:not-found. RateLimited sends urn:hardened:problem:rate-limited. A Detail that is not given is sent as "detail":null.
A problem type's body is a problem details object as RFC 9457 defines it. type, title, status and detail are RFC 9457's members, and the rest, such as resource, are extension members. Sent as JSON, a problem type goes out as application/problem+json, the media type RFC 9457 registers for it. The OpenAPI document declares that media type for each status a problem type answers. The generic forms send a body of your own type, so they go out as application/json. The framework's own refusals, such as the 401 and the validation 400, are problem details too and go out as application/problem+json.
Every problem type implements IProblemDetails, in Hardened.Requests.Abstract.Responses. A record of your own that implements it is sent as application/problem+json too, when it answers a status of 400 or above.
RateLimited also sends its wait in the body, as a time span: "retryAfter":"00:00:30". ServiceUnavailable sends "after" the same way. Unauthorized sends "challenge" in its body. Retry-After is the wait in whole seconds, rounded up, and at least 1.
Each problem type except RateLimited has a static Default: one shared instance with a general detail. NotFound.Default sends "resource":"resource","detail":"The requested resource does not exist.".
Each problem type has a generic form, such as NotFound<T>. MethodNotAllowed has one too. A generic form answers the same status. It sends a body of the application's own type in place of the record. The document describes a generic form's case with the schema of its type argument. NoContent, NotAcceptable and NotModified have no generic form. Accepted<T> is the form of Accepted with a body, and takes a Location as Accepted does.
A generic form's constructor is (T Body), except for these:
| Generic form | Constructor |
|---|---|
MethodNotAllowed<T> | (T Body, string Allow) |
RateLimited<T> | (TimeSpan RetryAfter, T Body) |
ServiceUnavailable<T> | (T Body, TimeSpan? After = null) |
Unauthorized<T> | (T Body, AuthorizationChallenge? Challenge = null) |
A status with no built-in type
Status<TCode, TBody>(TBody Body) answers the status that TCode names. It sends Body.
Http holds a marker type for each status in this table. It is in Hardened.Web.Runtime.Responses. None of these statuses has a built-in type.
| Statuses | Markers |
|---|---|
| 2xx | NonAuthoritativeInformation 203, ResetContent 205, PartialContent 206, MultiStatus 207, AlreadyReported 208, IMUsed 226 |
| 3xx | MultipleChoices 300, MovedPermanently 301, Found 302, SeeOther 303, UseProxy 305, TemporaryRedirect 307, PermanentRedirect 308 |
| 4xx | ProxyAuthenticationRequired 407, LengthRequired 411, UriTooLong 414, RangeNotSatisfiable 416, ExpectationFailed 417, ImATeapot 418, MisdirectedRequest 421, Locked 423, FailedDependency 424, TooEarly 425, UpgradeRequired 426, RequestHeaderFieldsTooLarge 431, UnavailableForLegalReasons 451 |
| 5xx | HttpVersionNotSupported 505, VariantAlsoNegotiates 506, InsufficientStorage 507, LoopDetected 508, NotExtended 510, NetworkAuthenticationRequired 511 |
Two statuses with one body type are two different case types. One set can hold both. The set in src/Todos/LockController.cs holds NotFound<ApiError> and Status<Http.Locked, ApiError>:
using Hardened.Requests.Abstract.Responses;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Responses;
namespace Todos;
public record ApiError(string Code, string Message);
[BasePath("/locks")]
public class LockController
{
[Get("/{id}")]
public async Task<Response<Todo, NotFound<ApiError>, Status<Http.Locked, ApiError>>> ById(
ITodoStore store,
int id
)
{
var todo = await store.Find(id);
if (todo is null)
{
return new NotFound<ApiError>(new ApiError("todo_missing", $"No todo has id {id}."));
}
if (!todo.Done)
{
return new Status<Http.Locked, ApiError>(
new ApiError("todo_open", $"Todo {id} is still open.")
);
}
return todo;
}
}In the template's store, todo 1 is done and todo 2 is not.
GET /todos/locks/2
HTTP/1.1 423 Locked
Content-Type: application/json
{"code":"todo_open","message":"Todo 2 is still open."}GET /todos/locks/99
HTTP/1.1 404 Not Found
Content-Type: application/json
{"code":"todo_missing","message":"No todo has id 99."}The document lists the 404 and the 423 of GET /todos/locks/{id} with the ApiError schema. The 423's description is Status 423.
A marker of the application's own is a struct that implements IStatusCode. It carries [HttpStatus] with the number its Status returns. IStatusCode is in Hardened.Requests.Abstract.Responses. src/Todos/QuotaExhausted.cs declares a marker for 466:
using Hardened.Requests.Abstract.Responses;
using Hardened.Web.Runtime.Responses;
namespace Todos;
[HttpStatus(466)]
public readonly struct QuotaExhausted : IStatusCode
{
public static int Status => 466;
}A case of Status<QuotaExhausted, ApiError> answers 466. The document lists a 466 with the ApiError schema.
WARNING
A case whose marker has no [HttpStatus] answers the success status, 200, whatever the marker's Status returns. The document adds the case's body to the 200 as a oneOf. The build reports nothing.
More than one success status
A type of the application's own with [HttpStatus(n)] is a case at status n. It sends itself as the body. A set can hold two success statuses like any other two statuses.
In src/Todos/ExportController.cs, ExportProgress is a case at 202:
using Hardened.Requests.Abstract.Responses;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Responses;
namespace Todos;
public record Export(int Id, string Url);
[HttpStatus(202)]
public record ExportProgress(int Id, int Percent);
[BasePath("/exports")]
public class ExportController
{
[Get("/{id}")]
public Response<Export, ExportProgress> ById(int id)
{
if (id == 1)
{
return new Export(1, "/files/todos-1.json");
}
return new ExportProgress(id, 40);
}
}GET /todos/exports/1
HTTP/1.1 200 OK
Content-Type: application/json
{"id":1,"url":"/files/todos-1.json"}GET /todos/exports/2
HTTP/1.1 202 Accepted
Content-Type: application/json
{"id":2,"percent":40}The document lists the 200 with the Export schema and the 202 with the ExportProgress schema.
In the Throws model, the return type is the one success. A handler that answers two success statuses returns a set.
Response headers
A response type that implements IProvidesResponseHeaders sets its headers when it is sent. The interface is in Hardened.Requests.Abstract.Responses. Its one member is void ApplyHeaders(IDictionary<string, StringValues> headers).
The document lists a header for each string parameter of such a type's primary constructor. The parameter's name is the header's name. Detail, Value and Body are not headers.
| Type | Header | In the document |
|---|---|---|
Created<T> | Location | Listed |
Accepted and Accepted<T> | Location | Listed |
MethodNotAllowed | Allow | Listed |
NotModified | ETag | Listed |
Ok<T> | The headers it is given | Not listed |
Unauthorized | WWW-Authenticate | Not listed |
RateLimited and ServiceUnavailable | Retry-After | Not listed |
[AnswersHeader(status, name)] on a handler lists a header on the response with that status. Description sets the header's description. AnswersHeaderAttribute is in Hardened.Requests.Abstract.Responses. The attribute adds nothing for a status the operation does not list. [AnswersHeader(name)], with no status, lists the header on every response the operation lists. It is for a header a filter writes before the handler runs, as [RateLimit] writes RateLimit-Remaining.
src/Todos/VersionedTodoController.cs sends an ETag through Ok<T> and lists it with [AnswersHeader]:
using Hardened.Requests.Abstract.Responses;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Responses;
namespace Todos;
[BasePath("/versioned")]
public class VersionedTodoController
{
[Get("/{id}")]
[AnswersHeader(200, "ETag", Description = "The version of the todo.")]
public async Task<Response<Ok<Todo>, NotFound>> ById(ITodoStore store, int id)
{
var todo = await store.Find(id);
if (todo is null)
{
return new NotFound("todo", $"No todo has id {id}.");
}
return new Ok<Todo>(todo, "ETag", $"\"{todo.Id}\"");
}
}GET /todos/versioned/1
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "1"
{"id":1,"title":"Read the generated code","done":true}The document lists the ETag header on the 200 of GET /todos/versioned/{id}:
"200": {
"description": "OK",
"headers": {
"ETag": {
"description": "The version of the todo.",
"schema": {
"type": "string"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Todo"
}
}
}
}Unions
A C# union declaration can take the place of Response<T1..Tn>. The handler's body does not change. The union's cases follow the same rules as a set's: the status from [HttpStatus], and the same bodies and document entries.
The compiler checks the code that reads a union, not the code that returns one. A case added to TodoResult compiles with the handler unchanged, and the document lists its status. The template's tests/Todos.Tests/DocumentStatusTests.cs fails when the document lists a status that no request answered.
dotnet new hardened-web -n Todos --response-model union writes the handler this way:
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Responses;
using ValidationModules.Constraints;
namespace Todos;
public union TodoResult(Todo, NotFound);
public class TodoController
{
[Operation("getTodo")]
[Get("/{id}")]
public async Task<TodoResult> ById(ITodoStore store, [Range(Min = 1)] int id)
{
var todo = await store.Find(id);
if (todo is null)
{
return new NotFound("todo", $"No todo has id {id}.");
}
return todo;
}
}The union scaffold's library project builds only under the first of these settings:
| Target framework | LangVersion | Result |
|---|---|---|
net11.0 | preview | Builds |
net11.0 | Not set | CS1001: the union declaration does not parse |
net11.0 | 15 | CS1617: 15 is not a value the compiler accepts |
net8.0 or net10.0 | preview | CS0518 and CS0656: IUnion and UnionAttribute are missing |
The AWS Lambda managed runtime and the Azure Functions worker do not run net11.0. Project templates lists the host and model combinations the template refuses.
The Throws model
In the Throws model, the return type names the success. Every other status is thrown. AsException() is an extension method in Hardened.Web.Runtime.Responses. It turns a built-in response into a ResponseException<T>.
The success answers 200, or the status the verb attribute's SuccessStatus names. Routing covers SuccessStatus.
[Throws<T>] on the handler lists the status of T in the document. Without the attribute, the document does not list a thrown status.
dotnet new hardened-web -n Todos --response-model throws writes the handler this way:
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Responses;
using ValidationModules.Constraints;
namespace Todos;
public class TodoController
{
[Operation("getTodo")]
[Get("/{id}")]
[Throws<NotFound>]
public async Task<Todo> ById(ITodoStore store, [Range(Min = 1)] int id)
{
var todo = await store.Find(id);
if (todo is null)
{
throw new NotFound("todo", $"No todo has id {id}.").AsException();
}
return todo;
}
}GET /todos/99
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"resource":"todo","detail":"No todo has id 99.","type":"urn:hardened:problem:not-found","title":"Not Found","status":404}The document lists 200, 400 and 404 for GET /todos/{id}, as it does for the Response handler at the top of this page.
[Throws<T>] takes the status from the [HttpStatus] on T. For a type without one, the attribute states the status: [Throws<ApiError>(409)]. An attribute that gives no status for a type without [HttpStatus] fails the build with HRDT001. Description replaces the response's description in the document: [Throws<NotFound>(Description = "No todo has that id.")].
The attribute goes on a method. One method can carry several. It does not check what the handler throws. It changes the document and nothing else, except for [Throws<RequestValidationError>(422)]. That declaration also sets the status of the handler's validation failures. Validation covers it.
A handler that throws a generic form declares the body type and the status. A handler that throws Conflict<ApiError> declares [Throws<ApiError>(409)]. The document then lists the 409 with the ApiError schema.
What a thrown exception answers
| Thrown | Status | Body |
|---|---|---|
A problem type or a generic form, through AsException() | Its status | Its body, with its headers |
ValidationException | 400 | The validation failure body, which Validation covers |
StatusCodeException | Its status code | {"detail":"The todo is locked.","type":"urn:hardened:problem:conflict","title":"Conflict","status":409} for new StatusCodeException(409, message: "The todo is locked.") |
BadRequestException, or a type derived from it | 400 | The exception's message as detail: {"detail":"The filter is malformed.","type":"urn:hardened:problem:bad-request","title":"Bad Request","status":400} for new BadRequestException("The filter is malformed.") |
| Any other exception | 500 | {"detail":"The server could not complete this request.","type":"urn:hardened:problem:internal-server-error","title":"Internal Server Error","status":500} |
A handler generated from a contract answers a problem type without a type argument, such as NotFound, with the body the contract declares for its status, filled from the record. Generating from OpenAPI and Smithy cover the rule.
StatusCodeException is in Hardened.Requests.Abstract.Errors. BadRequestException is in Hardened.Requests.Runtime.Errors.
The message of an exception that answers 500 is not sent.
The bodies in the last three rows are ErrorModel, in Hardened.Requests.Abstract.Errors. Every refusal the framework raises is a problem details document, sent as application/problem+json. That covers a 401, a rate limit's 429, a timeout's 504, a 405 and an unmatched path's 404. ErrorModel has four members:
| Member | Value |
|---|---|
type | urn:hardened:problem: followed by the status's reason phrase in lower case with hyphens, such as urn:hardened:problem:gateway-timeout. A 429 is urn:hardened:problem:rate-limited. A status with no reason phrase is about:blank |
title | The status's reason phrase, such as Gateway Timeout |
status | The status code |
detail | A sentence for the caller. A 500's detail never comes from the exception |
These are the type and title a problem type sends at the same status, so a refusal and a returned NotFound read the same. ErrorModel.For(status, detail) builds one. A validation failure is RequestValidationError, which adds the errors member. Validation covers it.
A refusal at a status the operation declares a body for sends that body instead of ErrorModel or the validation body. The OpenAPI document publishes the declared body at that status, so this keeps the two in agreement. The refusals this covers are the ones the framework raises itself: authorization, a rate limit, a body over its limit, validation, a body that cannot be read, a deadline, a 406 and a 500. A problem type such as ContentTooLarge in a response set or in [Throws<T>] is sent as its Default. A contract's declared body is sent with the status and the title the build fills in. RateLimited and the generic forms have no instance to send, so a refusal at their status keeps the framework's shape, and the document lists both.
A FormatException from int.Parse("ten") in a handler answers 500, like any other exception. A FormatException thrown while the request is bound answers 400. A custom binding attribute or a JSON converter of your own can throw one. The response keeps the message as detail:
{"detail":"The input string 'ten' was not in a correct format.","type":"urn:hardened:problem:bad-request","title":"Bad Request","status":400}An exception that answers a 4xx status is logged at Warning as a refusal:
GET /todos/99 refused with 404: The request produced status 404.An exception that answers any other status is logged at Error with its stack trace. A thrown ServiceUnavailable is logged at Error too.
Diagnostics
| Code | Severity | Reported when | Fix |
|---|---|---|---|
HRDT001 | Error | [Throws<T>] names a type without [HttpStatus] and states no status | State the status, as in [Throws<ApiError>(409)], or put [HttpStatus] on the type |
HRDRM003 | Error | A case is object or dynamic | Name a specific type |
HRDRM004 | Error | Two cases at different statuses, where one type is assignable to the other | Derive both from a base type that is not a case, or give them one status |
HRDRM004 comes with CS8120 in the generated handler: "The switch case is unreachable."
Limits
Status<TCode, TBody> sets no headers. A redirect through a marker such as Http.Found sends no Location.
A header whose name is not a C# identifier, such as Retry-After, cannot be a constructor parameter. [AnswersHeader] lists such a header.
Next
- Validation: the 400 a failed constraint answers, and changing it to 422
- The OpenAPI document: the rest of what reaches the document
- Routing:
SuccessStatus, and what a null return answers - Typed clients: asserting a declared response in a test
- Generating from OpenAPI: the interfaces a contract generates