Skip to content

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.

csharp
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;
    }
}
http
GET /todos/1

HTTP/1.1 200 OK
Content-Type: application/json

{"id":1,"title":"Read the generated code","done":true}
http
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}:

json
"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.

ModelThe handler's return typeA status other than the successNeeds
ResponseResponse<T1..Tn>Returned, as a casenet8.0 or later
UnionA C# union declarationReturned, as a casenet11.0 and <LangVersion>preview</LangVersion>
ThrowsThe success typeThrown 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].

TypeStatusConstructorHeader 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
Accepted202(string? Location = null)Location, when it is given
Accepted<T>202(T Value, string? Location = null)Location, when it is given
NoContent204()None
NotModified304(string? ETag = null)ETag, when it is given
BadRequest400(string? Detail = null)None
Unauthorized401(string? Detail = null, AuthorizationChallenge? Challenge = null)WWW-Authenticate: Bearer when no challenge is given
PaymentRequired402(string? Detail = null)None
Forbidden403(string? Detail = null)None
NotFound404(string Resource, string? Detail = null)None
MethodNotAllowed405(string Allow)Allow
NotAcceptable406()None
RequestTimeout408(string? Detail = null)None
Conflict409(string? Detail = null)None
Gone410(string? Detail = null)None
PreconditionFailed412(string? Detail = null)None
ContentTooLarge413(string? Detail = null)None
UnsupportedMediaType415(string? Detail = null)None
UnprocessableContent422(string? Detail = null)None
PreconditionRequired428(string? Detail = null)None
RateLimited429(TimeSpan RetryAfter, string? Detail = null)Retry-After
InternalServerError500(string? Detail = null)None
NotImplemented501(string? Detail = null)None
BadGateway502(string? Detail = null)None
ServiceUnavailable503(TimeSpan? After = null, string? Detail = null)Retry-After, when After is given
GatewayTimeout504(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 formConstructor
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.

StatusesMarkers
2xxNonAuthoritativeInformation 203, ResetContent 205, PartialContent 206, MultiStatus 207, AlreadyReported 208, IMUsed 226
3xxMultipleChoices 300, MovedPermanently 301, Found 302, SeeOther 303, UseProxy 305, TemporaryRedirect 307, PermanentRedirect 308
4xxProxyAuthenticationRequired 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
5xxHttpVersionNotSupported 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>:

csharp
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.

http
GET /todos/locks/2

HTTP/1.1 423 Locked
Content-Type: application/json

{"code":"todo_open","message":"Todo 2 is still open."}
http
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:

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

csharp
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);
    }
}
http
GET /todos/exports/1

HTTP/1.1 200 OK
Content-Type: application/json

{"id":1,"url":"/files/todos-1.json"}
http
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.

TypeHeaderIn the document
Created<T>LocationListed
Accepted and Accepted<T>LocationListed
MethodNotAllowedAllowListed
NotModifiedETagListed
Ok<T>The headers it is givenNot listed
UnauthorizedWWW-AuthenticateNot listed
RateLimited and ServiceUnavailableRetry-AfterNot 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]:

csharp
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}\"");
    }
}
http
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}:

json
"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:

csharp
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 frameworkLangVersionResult
net11.0previewBuilds
net11.0Not setCS1001: the union declaration does not parse
net11.015CS1617: 15 is not a value the compiler accepts
net8.0 or net10.0previewCS0518 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:

csharp
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;
    }
}
http
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 ​

ThrownStatusBody
A problem type or a generic form, through AsException()Its statusIts body, with its headers
ValidationException400The validation failure body, which Validation covers
StatusCodeExceptionIts 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 it400The 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 exception500{"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:

MemberValue
typeurn: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
titleThe status's reason phrase, such as Gateway Timeout
statusThe status code
detailA 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:

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

text
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 ​

CodeSeverityReported whenFix
HRDT001Error[Throws<T>] names a type without [HttpStatus] and states no statusState the status, as in [Throws<ApiError>(409)], or put [HttpStatus] on the type
HRDRM003ErrorA case is object or dynamicName a specific type
HRDRM004ErrorTwo cases at different statuses, where one type is assignable to the otherDerive 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 ​

Released under the MIT License.