The OpenAPI document
The build writes an OpenAPI document from the application's handlers: their paths, verbs, parameters, request bodies and responses. [Enable<OpenApiDocumentPublishing>] on the module that declares the routes serves the document at /openapi.json.
This example gives the template's handler in src/Todos/TodoController.cs a doc comment of its own:
using Hardened.Requests.Abstract.Responses;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Responses;
using ValidationModules.Constraints;
namespace Todos;
public class TodoController
{
/// <summary>One todo, or 404.</summary>
/// <remarks>Answers 404 when no todo has the id.</remarks>
/// <param name="id">The todo's id.</param>
[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;
}
}The document lists the operation under /todos/{id}:
"get": {
"tags": [
"Todo"
],
"operationId": "getTodo",
"summary": "One todo, or 404.",
"description": "Answers 404 when no todo has the id.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The todo's id.",
"schema": {
"type": "integer",
"format": "int32",
"minimum": 1
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Todo"
}
}
}
},
"400": {
"description": "The request failed validation.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/RequestValidationError"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/NotFound"
}
}
}
}
}
}In the example, operationId comes from [Operation], summary from <summary> and description from <remarks>. The parameter's description comes from <param>, and its minimum from [Range(Min = 1)]. The 200 and the 404 come from the return type. The build adds the 400 because id is validated.
The template puts [Enable<OpenApiDocumentPublishing>] on its library module. [HardenedOpenApiUi] on its application module serves a reference page over the document at /docs.
Serving the document
OpenApiDocumentPublishing and HardenedOpenApiUi are in the namespace Hardened.Web.Runtime.OpenApi, in the package Hardened.Web.Runtime. Hardened.Web.SourceGenerator writes the document.
Put [Enable<OpenApiDocumentPublishing>] on the module that declares the routes. The build writes the document from the routes compiled with that module. This is src/Todos/TodosLibrary.cs as the template writes it, with its comments left out:
using DependencyModules.Runtime.Interfaces;
using Hardened.Shared.Runtime.Attributes;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.DependencyInjection;
using Hardened.Web.Runtime.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Serialization.Metadata;
namespace Todos;
[HardenedModule]
[HardenedWebModule]
[BasePath("/todos")]
[Server("http://localhost:5080", "Local")]
[Enable<OpenApiDocumentPublishing>]
public partial class TodosLibrary : IServiceCollectionConfiguration
{
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IJsonTypeInfoResolver>(TodosJsonContext.Default);
}
}On a module that declares no routes, such as the template's application module, the document is "paths": {}. The build warns HRDOA003. With the attribute on both modules, the empty document answers /openapi.json. The template with the attribute added to Application as well builds with this warning:
CSC : warning HRDOA003: 'Application' enables OpenApiDocumentPublishing and declares no routes, so the document served at /openapi.json is "paths": {}. The document is written from the routes in the same compilation as the attribute - move [Enable<OpenApiDocumentPublishing>] to the module that declares them. With it on both, the empty one shadows the real one.The same application answers with the empty document:
GET /openapi.json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-cache
Vary: Accept-Encoding
{"openapi":"3.2.0","info":{"title":"Todos.Host","version":"1.0.0"},"paths":{}}Without the attribute, the build writes no document and the assembly carries none.
The document answers GET and HEAD. The response carries Cache-Control: no-cache and Vary: Accept-Encoding:
HEAD /openapi.json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-cache
Vary: Accept-EncodingAnother method answers 405 with Allow: GET, HEAD:
POST /openapi.json
HTTP/1.1 405 Method Not Allowed
Content-Type: application/problem+json
Allow: GET, HEAD
{"detail":"This resource does not answer POST. It answers GET, HEAD.","type":"urn:hardened:problem:method-not-allowed","title":"Method Not Allowed","status":405}A request that sends Accept-Encoding: gzip gets the document compressed, with Content-Encoding: gzip. Any other request gets it uncompressed. Both answers carry Vary: Accept-Encoding, because that header decides which one a request gets.
The document has no [AllowAnonymous]. Under [RequireAuthorization] on the application module, /openapi.json answers 401 to a caller with no credentials. Authorization covers both attributes. An OpenApiDocumentConfiguration registered with AllowAnonymous serves the document and the reference page to any caller:
services.AddSingleton(new OpenApiDocumentConfiguration { AllowAnonymous = true });OpenApiDocumentConfiguration is in Hardened.Web.Runtime.OpenApi. With it, a convention can no longer narrow either one.
The document does not describe /openapi.json, the reference page or the health endpoints. It answers ahead of a route that the application declares at the same path.
Serving the document at another path
[OpenApiDocumentPath(path)] on a class makes the class a marker that serves the document at path. [Enable<T>] with that class takes the place of [Enable<OpenApiDocumentPublishing>]. OpenApiDocumentPublishing is the same kind of marker, with the path /openapi.json. OpenApiDocumentPath is in Hardened.Web.Runtime.OpenApi. Enable<T> is in Hardened.Shared.Runtime.Attributes.
src/Todos/SpecEndpoint.cs declares a marker for /spec.json:
using Hardened.Web.Runtime.OpenApi;
namespace Todos;
[OpenApiDocumentPath("/spec.json")]
public sealed class SpecEndpoint;src/Todos/TodosLibrary.cs enables it:
using DependencyModules.Runtime.Interfaces;
using Hardened.Shared.Runtime.Attributes;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Serialization.Metadata;
namespace Todos;
[HardenedModule]
[HardenedWebModule]
[BasePath("/todos")]
[Server("http://localhost:5080", "Local")]
[Enable<SpecEndpoint>]
public partial class TodosLibrary : IServiceCollectionConfiguration
{
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IJsonTypeInfoResolver>(TodosJsonContext.Default);
}
}The document then answers at /spec.json. /openapi.json answers 404:
HEAD /spec.json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-cache
Vary: Accept-EncodingGET /openapi.json
HTTP/1.1 404 Not FoundThe reference page names the new path in DocumentPath, as src/Todos.Host/Application.cs does here:
using Hardened.Shared.Runtime.Attributes;
using Hardened.Web.Kestrel.Runtime;
using Hardened.Web.Runtime.OpenApi;
namespace Todos.Host;
[HardenedModule]
[KestrelRuntime]
[HardenedOpenApiUi(Title = "Todos", DocumentPath = "/spec.json", Environments = "development")]
[TodosLibrary]
public partial class Application;<HardenedOpenApiOutput> writes the document whichever marker enabled it.
The reference page
[HardenedOpenApiUi] is a module attribute. It serves an HTML page that loads Scalar's API reference script and renders the document at DocumentPath. The template serves the page in the development environment only. Project templates covers that choice.
| Property | Default | Sets |
|---|---|---|
Path | /docs | Where the page is served |
Title | API Reference | The page's <title> |
DocumentPath | /openapi.json | Where the page fetches the document |
Environments | Not set: every environment | The environments the page is served in |
ScriptUrl | @scalar/api-reference 1.65.1, the standalone build on jsDelivr | Where the browser loads the reference UI |
ScriptIntegrity | The sha384 hash of that build | The script's integrity attribute. "" writes none |
DecodeMessagePack | false | Decoding MessagePack response bodies on the page |
MessagePackScriptUrl | @msgpack/msgpack 3.1.3, the UMD build on jsDelivr | Where the decoder loads from |
Environments is a list of environment names separated by , or ;. The attribute serves the page where one of them matches the application's environment name, ignoring case. Without Environments, it serves the page in every environment. The Environments page covers the environment name.
In the development environment, the page's request panel sends to the origin the page was served from. It ignores the document's servers. A host started with PORT=5181 serves /openapi.json listing http://localhost:5080, and the panel still sends to port 5181. The served document is unchanged. In any other environment, the panel sends to the document's servers.
A module can carry the attribute more than once, for one page per Path. Two with the same Path serve the first. A Path written without a leading slash gets one.
The page answers GET and HEAD, as text/html; charset=utf-8. Another method answers 405 with Allow: GET, HEAD. The page has no [AllowAnonymous]. Under [RequireAuthorization] on the application module, /docs answers 401 to a caller with no credentials, unless OpenApiDocumentConfiguration.AllowAnonymous opens it with the document.
ScriptUrl with ScriptIntegrity = "" loads the script from that URL with no integrity attribute. A ScriptUrl with ScriptIntegrity left unset keeps the default hash. Any other file fails that hash. Hardened does not serve a copy of the script. The file at a ScriptUrl of the application's own is the application's to serve.
Here src/Todos.Host/Application.cs adds a second page over a self-hosted script:
using Hardened.Shared.Runtime.Attributes;
using Hardened.Web.Kestrel.Runtime;
using Hardened.Web.Runtime.OpenApi;
namespace Todos.Host;
[HardenedModule]
[KestrelRuntime]
[HardenedOpenApiUi(Title = "Todos", Environments = "development")]
[HardenedOpenApiUi(
Path = "/docs/self-hosted",
Title = "Todos",
ScriptUrl = "/assets/api-reference.js",
ScriptIntegrity = ""
)]
[TodosLibrary]
public partial class Application;The two pages answer:
GET /docs
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Todos</title>
</head>
<body>
<script id="api-reference" data-url="/openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.65.1/dist/browser/standalone.js" integrity="sha384-G6dkutu2k5IYVyNESLoFIpgaHx38IJTZ/HhrwN0fecTle9te75y8Kru3rJEJ0ZJV" crossorigin="anonymous"></script>
</body>
</html>GET /docs/self-hosted
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Todos</title>
</head>
<body>
<script id="api-reference" data-url="/openapi.json"></script>
<script src="/assets/api-reference.js"></script>
</body>
</html>DecodeMessagePack = true makes the page decode MessagePack response bodies in its request panel. The page then starts Scalar from an inline <script>. It loads a decoder from MessagePackScriptUrl. A script-src policy without 'unsafe-inline' blocks the inline script. MessagePack covers the rest.
Serving a contract's document
In an OpenAPI-first or Smithy-first project, metadata on the item that declares the contract serves the document and the page. No attribute is involved. --contract openapi writes this item in src/Todos/Todos.csproj, shown here without its comments:
<ItemGroup>
<HardenedOpenApiSpec Include="contracts\todos.yaml">
<PublishUrl>/openapi.json</PublishUrl>
<SourceUrl>/openapi.yaml</SourceUrl>
<UiUrl>/docs</UiUrl>
<UiEnvironments>development</UiEnvironments>
</HardenedOpenApiSpec>
</ItemGroup>| Metadata | Serves |
|---|---|
PublishUrl | The document generated from the contract, as application/json |
SourceUrl | The contract file as written |
UiUrl | A reference page over the PublishUrl document |
UiEnvironments | Limits the UiUrl page to these environments |
EmbedDocument | false leaves the contract file out of the assembly |
PublishUrl serves the generated document as application/json whatever the contract file's format. The generated document keeps the contract's whole info object, including license and contact. It keeps the contract's servers, including a relative URL such as /, and its descriptions. It adds what a code-first document adds, such as the validation 400. It is OpenAPI 3.2.0 whatever version the contract declares.
An operation whose contract declares a validation mode publishes it as x-hardened-validation, spelled stop-on-first-error or collect-all. The mode comes from an OpenAPI document's own key or a Smithy model's @validation. An operation whose contract declares none publishes none, unless the module declares a mode, as Operations describes.
SourceUrl serves the contract file as written, comments included. The file goes out as application/yaml for a .yaml or .yml file and application/json otherwise. For the template's contracts/todos.yaml, the two URLs answer:
HEAD /openapi.json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-cache
Vary: Accept-EncodingHEAD /openapi.yaml
HTTP/1.1 200 OK
Content-Type: application/yaml
Cache-Control: no-cache
Vary: Accept-EncodingUiUrl serves a reference page over the PublishUrl document. Its title is API Reference. UiEnvironments limits that page to the environments it names, by the rules of Environments in the reference page. A URL written without a leading slash gets one.
The build embeds the contract file in the assembly unless EmbedDocument metadata or the <HardenedOpenApiEmbedDocument> property is false. Only SourceUrl needs it. UiUrl without PublishUrl fails the build with HOAT016. SourceUrl with the contract not embedded fails it with HOAT017. A Smithy project reports the same under HSMT.
With neither PublishUrl nor UiUrl, the application serves no document and no page. <HardenedOpenApiOutput> still writes the document.
In a Smithy project, PublishUrl, UiUrl and UiEnvironments go on a HardenedSmithyModel item and apply to the whole model. Two items with different values fail the build with HSMT015. SourceUrl on a HardenedSmithyModel item serves nothing, and the build reports nothing. A HardenedSmithyAst item reads it.
Generating from OpenAPI and Generating from Smithy cover declaring the item.
The title, servers and tags
info.title is the name of the assembly that serves the document. info.version is 1.0.0. The template's document is titled Todos, as its reference page is.
[OpenApiInfo(title, version, description)] on that module sets them. Its version defaults to 1.0.0, and its description is optional. [Server(url, description)] on that module adds an entry to servers. It can be repeated, and the entries keep their order. Its description is optional. Without [Server], the document has no servers. OpenApiInfo and Server are in the namespace Hardened.Web.Runtime.Attributes.
Here src/Todos/TodosLibrary.cs sets a title and two servers:
using DependencyModules.Runtime.Interfaces;
using Hardened.Shared.Runtime.Attributes;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.DependencyInjection;
using Hardened.Web.Runtime.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Serialization.Metadata;
namespace Todos;
[HardenedModule]
[HardenedWebModule]
[BasePath("/todos")]
[OpenApiInfo("Todos API", "2.1.0")]
[Server("http://localhost:5080", "Local")]
[Server("https://todos.example.com", "Production")]
[Enable<OpenApiDocumentPublishing>]
public partial class TodosLibrary : IServiceCollectionConfiguration
{
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IJsonTypeInfoResolver>(TodosJsonContext.Default);
}
}The document starts like this:
{
"openapi": "3.2.0",
"info": {
"title": "Todos API",
"version": "2.1.0"
},
"servers": [
{
"url": "http://localhost:5080",
"description": "Local"
},
{
"url": "https://todos.example.com",
"description": "Production"
}
],
"tags": [
{
"name": "Todo"
}
],tags lists every tag that the operations use.
On any other module, including the template's application module, [OpenApiInfo] and [Server] change nothing. [assembly: Server(...)] compiles and changes nothing. A contract's own info and servers win over both attributes.
The template's [Server("http://localhost:5080", "Local")] is the default address of the client the template generates. Generated clients covers the client. PORT does not change it. In development, the reference page sends to the port it was served from regardless.
Operations
Each field of an operation comes from the handler or its class:
| Field | Comes from | Without it |
|---|---|---|
operationId | [Operation("id")] on the method | The method name with its first letter lower-cased: Documented publishes documented |
tags | [Tag("name")] on the controller class | The class name without a Controller suffix: TodoController publishes Todo |
summary | The method's <summary> | No summary |
description | The method's <remarks> | No description |
A parameter's description | The method's <param name="..."> | No description |
deprecated: true | [Obsolete] on the method or its class | No deprecated |
x-hardened-validation | [ValidationMode] on the method or its class, written stop-on-first-error or collect-all | No x-hardened-validation |
Two handlers without [Operation] whose method names match each get their tag in front. List on a controller tagged Params publishes paramsList, and on one tagged Body publishes bodyList. Two handlers that declare the same [Operation] id fail the build with HRDOA004.
Operation and Tag are in the namespace Hardened.Web.Runtime.Attributes.
x-hardened-validation is the operation's last field, after responses. It carries the nearest declaration's mode, so a method that declares CollectAll under a class that declares StopOnFirstError publishes collect-all. [ValidationMode(ValidationStopMode.CollectAll)] on the module that serves the document adds "x-hardened-validation": "collect-all" to every operation that declares no mode, including one that validates nothing, such as GET /todos. The same attribute on the template's application module adds nothing. StopOnFirstError on a module class fails the build, as Validation describes. Route links names its link types after the same tag.
Parameters and request bodies
A value bound from the path, the query string, a header or a cookie is a parameter, with in set to path, query, header or cookie. Its name is the name the request uses, such as X-Tenant for [FromHeader("X-Tenant")] string tenant. A query string model publishes one query parameter per member.
A path parameter is always required. Any other parameter is required unless its type is nullable or it has a default value. The document leaves out a parameter's default value.
The path key carries no route constraint: /lab/constrained/{id:int} publishes as /todos/lab/constrained/{id}. A catch-all token, {*path}, publishes as {path}. Its parameter carries "x-hardened-catch-all": true.
| Body | Publishes |
|---|---|
| A JSON body | A requestBody with required: true, under application/json, as a $ref to its model's schema |
A byte[] or Stream body | application/octet-stream with {"type": "string", "format": "binary"} |
[FromForm] fields | One requestBody of application/x-www-form-urlencoded: an object with one property per field |
A [FromForm] model | A $ref to its schema |
A [FromForm] model and fields | The two joined with allOf |
A file among the fields, or in a form model, makes the body multipart/form-data. A file is {"type": "string", "format": "binary"}, and a list of files is an array of that. A form's requestBody is required when any field or member is required. Parameter binding and Forms and files cover the binding.
Each C# type publishes this schema, in a parameter and in a body:
| C# type | Schema |
|---|---|
string, char | {"type": "string"} |
bool | {"type": "boolean"} |
byte, sbyte, short, ushort, int, uint | {"type": "integer", "format": "int32"} |
long, ulong | {"type": "integer", "format": "int64"} |
float | {"type": "number", "format": "float"} |
double | {"type": "number", "format": "double"} |
decimal | {"type": "number"} in a parameter, {"type": "number", "format": "decimal"} in a body |
DateTime, DateTimeOffset | {"type": "string", "format": "date-time"} |
DateOnly | {"type": "string", "format": "date"} |
TimeOnly, TimeSpan | {"type": "string"} |
Guid | {"type": "string", "format": "uuid"} |
Uri | {"type": "string", "format": "uri"} |
| An enum | A $ref to the enum's schema, described under Schemas |
| An array or a list | {"type": "array", "items": ...} |
object, in a body | {} |
Responses
An operation's responses come from the handler's return type, SuccessStatus on the verb attribute, and [Throws<T>]. Declared responses covers what each response type publishes: one response per status, a oneOf for two cases at one status, and the headers a case carries, such as the Location of Created<T>.
A handler that returns nothing publishes a 200 with no content. A 204 or 304 response has no content. Each response's description is the status's reason phrase, such as OK or Not Found. The generator describes a status it has no phrase for as Status and its number.
The document publishes the body under each media type in [Produces], in that order. Without [Produces], it publishes the body under application/json. Content negotiation covers [Produces].
A status of 400 or above whose body is a problem type, such as NotFound, is published under application/problem+json in place of application/json, because that is how a JSON serializer sends it. A status with two bodies, one of them a problem type, lists both media types. The generic forms, such as NotFound<T>, send a body of your own type and stay application/json. The framework's own refusals, ErrorModel and RequestValidationError, are published under application/problem+json too. Declared responses covers the problem types.
A handler that returns IAsyncEnumerable<T> publishes the stream's media type, with the items as an array under schema and one item under itemSchema. Streaming responses covers the framing.
The 400 and 404 the build adds
An operation that validates a parameter or its body lists a 400, described "The request failed validation.", with the RequestValidationError schema under application/problem+json. Validation covers it.
The build adds the same 400 to an operation that binds a value from the path, the query string, a header, a cookie or a form into a type other than string. An operation that declares a 400 itself gets no validation 400.
A path token whose route constraint guarantees the conversion, such as {id:int} for an int, adds no 400. It adds a 404 with no content, described "The path did not name a resource: a token failed its route constraint." When that operation declares a 404 of its own, its description gains the sentence "A token that fails its route constraint answers this status too, before the handler and with no body." Routing covers route constraints.
Schemas
A class, record, struct or interface publishes a schema under components/schemas, named after the type. Each use is a $ref to it. A generic type's name spells its type arguments: Paged<Todo> publishes PagedOfTodo. Two different types with one name publish one schema between them. The build warns HRDOA005.
A public instance property with a getter is a property of the schema. A property's name is the one [JsonPropertyName] gives, or its own name with the first letter lower-cased. A type's <summary> is its schema's description. A property's <summary> is that property's description.
required lists each member whose type is a non-nullable reference type or a non-nullable value type, and each member with [Required]. A member with a property initializer or a constructor default is left out. A constructor default publishes as the member's default. A property initializer does not.
A nullable member adds "null" to its type: string? publishes {"type": ["string", "null"]}. A nullable member whose schema is a $ref publishes {"anyOf": [{"$ref": ...}, {"type": "null"}]}.
| Type | Schema |
|---|---|
| An enum | {"type": "string", "enum": [...]}, holding the values the JSON serializer writes |
| A dictionary | {"type": "object", "additionalProperties": ...}. An enum key adds propertyNames with a $ref to the enum |
An array, List<T>, IReadOnlyList<T>, ICollection<T>, IEnumerable<T>, HashSet<T> and their interfaces | An array |
byte[], Stream and IFormFile | {"type": "string", "format": "binary"} |
Task<T> or ValueTask<T>, returned by a handler | T |
A stream of SseItem<T> | Its items as T |
JSON serialization covers the enum values. A member with [Key(n)] from the MessagePack package publishes "x-message-pack-index": n. MessagePack covers keys.
A constraint from ValidationModules.Constraints publishes its keyword on the member's or the parameter's schema, such as minLength for [StringLength]. Validation lists the keyword of each constraint, and the ones that publish nothing. A constraint on a member whose schema is a $ref publishes nothing. An exclusive bound publishes as the number: [Range(0, 1, ExclusiveMin = true)] publishes "exclusiveMinimum": 0, whatever the document's version.
The template declares NewTodo in src/Todos/TodoStore.cs, shown here without its <remarks>:
using ValidationModules.Constraints;
namespace Todos;
/// <summary>
/// What a client sends to create one.
/// </summary>
public record NewTodo([property: StringLength(64, Min = 1)] string Title);It publishes this schema:
"NewTodo": {
"type": "object",
"description": "What a client sends to create one.",
"required": [
"title"
],
"properties": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
}
}Statuses a filter attribute publishes
An attribute that installs a filter publishes the statuses the filter can answer, and the headers it writes and reads. The build reads the attribute on the method, its class, the module class and the assembly.
| Declaration | Publishes |
|---|---|
[AuthorizeGrants], or any attribute implementing IAuthorizeAttribute | A 401 and a 403, each with a WWW-Authenticate header. The 403's description is "The caller does not hold what this operation requires." |
[Authorize<TScheme>] | A security requirement naming TScheme, the scheme under components.securitySchemes, and the 401. It publishes no 403, because it requires only an authenticated caller. A grants attribute or a policy beside it adds the 403 |
[RateLimit] | A 429 with a Retry-After header, and RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, RateLimit-Policy and RateLimit headers on every response |
[Timeout] | A 504, or the Status it sets, and x-hardened-timeout |
[ConditionalGet] | A 304 with an ETag header, an ETag header on the 200, and an optional If-None-Match header parameter. An optional If-Modified-Since header parameter as well where the 200 declares Last-Modified |
The refusals use the ErrorModel schema under application/problem+json. It requires type, title and status. detail is a string or null, type and title are strings, and status is an int32 integer.
x-hardened-timeout is the budget in milliseconds, such as 2000. With a Status other than 504 or a RetryAfterSeconds, it is an object: {"milliseconds": 1500, "status": 503, "retryAfterSeconds": 5}.
[Authorize<TScheme>] with [AuthorizeGrants("todos:read")] on an OAuth2 scheme lists todos:read as the requirement's scope. Authentication covers the scheme attributes.
An operation whose requirement names no scheme, such as [AuthorizeGrants] alone, is published under the application's scheme when the document declares exactly one. Its grants become that scheme's scopes when it is OAuth2 or OpenID Connect. When the document declares several schemes, or none, the operation publishes its 401 and 403 and no security.
A filter attribute on the implementation of a contract publishes the same way. The 504 of a [Timeout] there comes without x-hardened-timeout. Only a budget declared in the contract writes x-hardened-timeout.
A filter added at run time, with AddGlobalFilter, IGlobalFilterRegistry or [Enable<ConditionalGet>], publishes nothing. A filter attribute of the application's own publishes a status with [AnswersStatus]. The execution pipeline covers run-time filters, [AnswersStatus] and IAuthorizeAttribute.
Rate limiting, Request timeouts and Conditional requests cover the attributes themselves.
Routes registered at startup
The document the application serves includes the routes registered at startup. The file that <HardenedOpenApiOutput> writes does not. Registered routes covers what such a route publishes.
The document version
The document is OpenAPI 3.2.0. <HardenedOpenApiVersion> in the project that writes the document sets 3.0.0, 3.1.0 or 3.2.0. It also takes 3.0, 3.1 and 3.2. Any other value fails the build with HRDOA001. This block in src/Todos/Todos.csproj sets 3.1.0:
<PropertyGroup>
<HardenedOpenApiVersion>3.1.0</HardenedOpenApiVersion>
</PropertyGroup>Below 3.2, a streamed response has no itemSchema. The build warns HRDOA002 for each handler that streams.
The property changes nothing else. Body schemas keep the 3.1 forms at 3.0.0: a nullable member stays {"type": ["string", "null"]} and an exclusive bound stays exclusiveMinimum: 0. A contract's document is 3.2.0 whatever the property says. <HardenedOpenApiOutputVersion> writes a 3.0.0 file that uses the 3.0 forms.
Writing the document to a file
<HardenedOpenApiOutput> writes the document the application serves to a file after the compile. A relative path is relative to the project's directory. .json writes indented JSON, and .yaml or .yml writes YAML.
<HardenedOpenApiOutputVersion> set to 3.0.0 or 3.1.0 lowers the file for a reader that refuses 3.2. The served document does not change. These properties in src/Todos/Todos.csproj write openapi/Todos.json at 3.0.0:
<PropertyGroup>
<HardenedOpenApiOutput>openapi/Todos.json</HardenedOpenApiOutput>
<HardenedOpenApiOutputVersion>3.0.0</HardenedOpenApiOutputVersion>
</PropertyGroup>| In the served document | At 3.1.0 | At 3.0.0 |
|---|---|---|
"openapi": "3.2.0" | "openapi": "3.1.0" | "openapi": "3.0.0" |
itemSchema beside the array under schema | Removed. The array stays | Removed. The array stays |
A numeric exclusiveMinimum or exclusiveMaximum | Unchanged | The bound as minimum or maximum, with exclusiveMinimum: true or exclusiveMaximum: true |
{"type": [T, "null"]} | Unchanged | {"type": T, "nullable": true} |
{"anyOf": [{"$ref": ...}, {"type": "null"}]} | Unchanged | {"allOf": [{"$ref": ...}], "nullable": true} |
propertyNames | Unchanged | Removed |
The template sets <HardenedOpenApiOutput> to openapi/Todos.json in src/Todos/Todos.csproj when it writes a client project. It writes one unless --client none. The template's client project generates from the file. Generated clients covers it, and the check that keeps the file current.
The build task reads the document out of the compiled assembly, so a class library with no entry point writes the file. The file holds the served document, except the routes registered at startup.
The build rewrites the file only when its content changes. When the assembly has not changed, the target does not run. A design-time build writes nothing.
In a code-first project, the module needs [Enable<OpenApiDocumentPublishing>] or a marker of its own. Without one, the build fails with HRDOA018. This is the output for the template with [Enable<OpenApiDocumentPublishing>] removed from TodosLibrary:
error HRDOA018: <HardenedOpenApiOutput> is set to 'openapi/Todos.json', but Todos.dll carries no served OpenAPI document. The document is written only for a module that enables publishing, and the export reads that one copy. Add [Enable<OpenApiDocumentPublishing>] to the module that declares the routes, or remove the property.An OpenAPI-first or Smithy-first project always carries the document. Its diagnostic codes start HOAT or HSMT where a code-first project's start HRDOA. The lowered file names each streaming operation in warning 030.
Diagnostics
| Code | Severity | Reported when |
|---|---|---|
HRDOA001 | Error | <HardenedOpenApiVersion> is not a version the generator writes |
HRDOA002 | Warning | A handler streams and the document is below 3.2 |
HRDOA003 | Warning | A module that serves the document declares no routes |
HRDOA004 | Error | Two handlers declare the same [Operation] id |
HRDOA005 | Warning | Two types publish under one schema name |
HRDOA018 | Error | <HardenedOpenApiOutput> is set and the assembly serves no document |
HRDOA019 | Error | The assembly serves more than one document |
HRDOA028 | Error | The file's extension is not .json, .yaml or .yml |
HRDOA029 | Error | <HardenedOpenApiOutputVersion> is not 3.0.0 or 3.1.0 |
HRDOA030 | Warning | A streaming operation lost its itemSchema in the lowered file |
Diagnostics lists every code.
Limits
WARNING
A member whose name starts with more than one capital letter publishes with only the first letter lowered: ID as iD and URLPath as uRLPath. The JSON body writes id and urlPath. The Kiota client generated from the document reads nothing for such a member. Give it [JsonPropertyName].
[property: JsonPropertyName("id")] on such a member publishes the name the body uses:
using System.Text.Json.Serialization;
namespace Todos;
public record Link(
[property: JsonPropertyName("id")] int ID,
[property: JsonPropertyName("urlPath")] string URLPath
);The document and the service also disagree in these cases:
| Case | The document | The service |
|---|---|---|
| An operation whose only input is a JSON body with no constraint | Lists no 400 | Answers 400 to an empty or malformed body |
| A GET handler that returns null, or returns nothing | Lists no 404 unless the handler declares one | Answers 404 |
A filter attribute that declares its own 400 with [AnswersStatus] | Lists that 400 in place of the validation 400 | Answers RequestValidationError to a failed constraint on the operation |
A [ValidationMode] on the implementation of a contract operation that declares no mode | Lists no x-hardened-validation, or collect-all under a module's [ValidationMode(ValidationStopMode.CollectAll)] | Answers in the implementation's mode, such as with the first failure only |
Routing covers what a null return answers.
A model declared in a referenced project publishes its members that have a property initializer as required.
The template's tests/Todos.Tests/DocumentStatusTests.cs sends one request for each status the template's routes answer. The test fails when the served document lists a status that no request answered, or misses one that a request did.
Next
- Generated clients: generating a client from the file
- Declared responses: the response types a handler declares
- Generating from OpenAPI: a contract as the source of the document
- Registered routes: routes registered at startup
- The execution pipeline:
[AnswersStatus]on a filter attribute of the application's own