Hardened.Web.Kestrel.Runtime
0.17.0-rc1000
dotnet add package Hardened.Web.Kestrel.Runtime --version 0.17.0-rc1000
NuGet\Install-Package Hardened.Web.Kestrel.Runtime -Version 0.17.0-rc1000
<PackageReference Include="Hardened.Web.Kestrel.Runtime" Version="0.17.0-rc1000" />
<PackageVersion Include="Hardened.Web.Kestrel.Runtime" Version="0.17.0-rc1000" />
<PackageReference Include="Hardened.Web.Kestrel.Runtime" />
paket add Hardened.Web.Kestrel.Runtime --version 0.17.0-rc1000
#r "nuget: Hardened.Web.Kestrel.Runtime, 0.17.0-rc1000"
#:package Hardened.Web.Kestrel.Runtime@0.17.0-rc1000
#addin nuget:?package=Hardened.Web.Kestrel.Runtime&version=0.17.0-rc1000&prerelease
#tool nuget:?package=Hardened.Web.Kestrel.Runtime&version=0.17.0-rc1000&prerelease
Hardened.Framework
A compile-time, source-generated .NET framework for web APIs and serverless functions. The dependency injection, routing, parameter binding, configuration and request filters are written by source generators during the build, not resolved by reflection at startup. What runs is ordinary C# you can open and read.
The core is provider-agnostic: a handler never learns what host it runs on, and swapping the runtime module is the whole migration. AWS Lambda is the function compute supported today, through Hardened.Amz.
Full documentation: ipjohnson.github.io/Hardened.Docs
Start here
dotnet new install Hardened.Templates
dotnet new hardened-web -n Todos
cd Todos
dotnet run --project src/Todos.Host
That is a working todo API with tests, on http://localhost:5080, with a reference page at
/docs.
$ curl localhost:5080/todos/1
{"id":1,"title":"Read the generated code","done":true}
Four routes. GET /todos has one answer. GET /todos/{id}, POST /todos and
DELETE /todos/{id} each declare more than one. Every example below is from that application.
Start from a template rather than from bare packages. The runtime packages carry no analyzers, so a project that references only them compiles to an application that answers 404 to everything. The templates wire the generators, pin every version in one place, and split the projects so the host can be swapped without touching the code.
| Template | What you get |
|---|---|
hardened-web |
The todo API above: an implementation library, a host, and tests. --host kestrel\|aspnet\|aws-lambda, --contract code\|openapi\|smithy, --response-model standard\|response\|union |
hardened-function |
A serverless function and tests, on AWS Lambda today. --trigger invoke\|sqs |
hardened-library |
A reusable module an application picks up with one attribute |
See the templates guide for every option, and getting started for the same project assembled by hand.
The contract is yours to choose
Hardened builds the same application from any of three contract styles. Pick with
--contract code|openapi|smithy on the template, or change your mind later.
Code-first
The C# is the contract. A route is an attribute on a method of a plain class: no base type, no interface, no registration. The OpenAPI document is generated from your handlers.
[HardenedModule]
[HardenedWebModule]
[BasePath("/todos")] // every route below is relative to this
public partial class TodosLibrary;
public class TodoController {
[Get("/{id}")]
public Todo ById(ITodoStore store, int id) =>
store.Find(id) ?? throw new NotFound("todo", $"No todo has id {id}.").AsException();
}
That is the GET /todos/1 from the quickstart. Services arrive as method parameters, alongside
the route and body values, so anything the container knows about can be asked for that way and
nothing has to be stored on the class. A parameter typed as a concrete class is bound from the
request body instead.
The 404 is thrown here because the return type names only the success case. Putting it in the signature instead is what the three return models below are about.
The application names its runtime and the libraries it composes, and that is the whole bootstrap:
[HardenedModule]
[KestrelRuntime] // or [AspNetCoreRuntime], or [LambdaWebModule] from Hardened.Amz
[TodosLibrary]
public partial class Application;
OpenAPI-first
An OpenAPI document is the contract. Add it to the project as a HardenedOpenApiSpec item and the
build generates the models, a service interface per tag, the routes and the validation its
constraints describe.
# contracts/todos.yaml
paths:
/todos/{id}:
get:
tags: [Todos]
operationId: getTodo
parameters:
- { name: id, in: path, required: true, schema: { type: integer, minimum: 1 } }
responses:
'200':
content:
application/json:
schema: { $ref: '#/components/schemas/Todo' }
'404':
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
You implement the interface it wrote. [Handler] is the whole wiring; the verb and the path came
from the document, so neither is restated in C#.
[Handler]
public class TodoService : ITodosService {
private readonly ITodoStore _store;
public TodoService(ITodoStore store) => _store = store;
// The ? is generated from the declared 404: returning null answers it, with the body the
// document names. Without a declared 404 there is no ?, and the compiler says so.
public Task<Todo?> GetTodo(int id) => Task.FromResult(_store.Find(id));
}
Todo, Problem and ITodosService are all written by the build, and minimum: 1 becomes a
validation filter in front of the handler.
There are no route attributes anywhere in the project. Add an operation to the contract and the build writes the model, the route and the validation, then stops compiling until your service implements the new method. See generating from OpenAPI.
Smithy-first
The same generated output from a Smithy model instead of an OpenAPI document.
service Todos {
version: "2024-01-01"
operations: [GetTodo]
}
@error("client")
@httpError(404)
structure TodoNotFound {
@required
message: String
}
@http(method: "GET", uri: "/todos/{id}", code: 200)
@readonly
operation GetTodo {
input := {
@httpLabel
@required
@range(min: 1)
id: Integer
}
output: Todo
errors: [TodoNotFound]
}
The implementation side is identical. ITodosService, Todo and the TodoNotFound body come
from the model exactly as they came from the document above. TodoService is the same class
either way, which is what lets one template generate both.
Constraint traits like @required and @range become validation filters in front of the handler.
Needs the Smithy CLI on PATH; the build names the version it expects if yours differs. See
generating from Smithy.
Whichever you choose
The application serves its OpenAPI document at /openapi.json and a reference page at /docs.
Code-first, the document is generated from the routing table. Contract-first, it is generated from
your contract, and an OpenAPI project can serve the source file itself at a second URL, so a client
can read what the build understood or what you wrote. Hardened does not generate clients. The
document is the deliverable, and Kiota or NSwag pointed at it does the rest. See
the OpenAPI document.
Three return models
A handler that can answer more than one way has to say so somewhere. There are three places to say it. The choice decides what the compiler checks and what the generated document describes, and all three work side by side.
| The handler says | Other statuses | Needs | |
|---|---|---|---|
| Standard | one success type | thrown | any SDK |
| Response | the whole set, as Response<T1..Tn> |
in the return type | any SDK |
| Union | the whole set, as a C# union |
in the return type | .NET 11, LangVersion preview |
Standard is the default. The signature names the success type and every other status is thrown. Nothing in the signature says the route can answer a 404, so nothing checks that you handled it, and the document describes only the 200.
[Get("/{id}")]
public Todo ById(ITodoStore store, int id) {
var todo = store.Find(id);
if (todo is null) {
throw new NotFound("todo", $"No todo has id {id}.").AsException();
}
return todo;
}
Response puts the whole set in the return type. Response<T1..Tn> is an ordinary struct with an
implicit conversion per case, so the handler returns payloads and never names the wrapper. The
compiler knows the set and the document describes all of it.
[Get("/{id}")]
public Response<Todo, NotFound> ById(ITodoStore store, int id) {
var todo = store.Find(id);
if (todo is null) {
return new NotFound("todo", $"No todo has id {id}.");
}
return todo;
}
Union declares the same set as a C# language union, which adds exhaustiveness wherever you
pattern-match on the result. The handler body is identical to the Response version.
public union TodoResult(Todo, NotFound);
[Get("/{id}")]
public TodoResult ById(ITodoStore store, int id) { /* same body */ }
Unions need net11.0 and <LangVersion>preview</LangVersion>, which rules out AWS Lambda's
net8.0 managed runtime today. Hardened matches Response and union structurally, so moving
between them rewrites no handler. Cases like NotFound, Conflict, NoContent and Created<T>
are built-in records that carry their status, and most have a <T> form that takes your own body
in place of the default one.
Code-first, the return type alone decides. Contract-first, the statuses come from the contract and
<HardenedResponseModel>Standard|Response|Union</HardenedResponseModel> decides the generated
interface's shape. Declared 404s as nullable returns, and operations with two success statuses, are
in declared responses.
--response-model standard|response|union on the template generates the todo API in whichever of
the three you pick, so the difference between them is something to read rather than to take on
trust.
Filters
Every request runs through the same pipeline, whatever the transport: an HTTP call, a function
invocation, a queue message. A pipeline is an ordered list of filters, and the handler you wrote is
the last one. A filter does its work around chain.Next(); not calling it short-circuits
everything after it, which is how authorization and caching return without reaching the handler.
public class TimingFilter : IExecutionFilter {
public async Task Execute(IExecutionChain chain) {
var start = MachineTimestamp.Now;
try {
await chain.Next();
}
finally {
chain.Context.RequestMetrics.Record(
RequestMetrics.TotalRequestDuration, start.GetElapsedMilliseconds());
}
}
}
Attach a filter to one handler with an attribute ([Retry] is the shipped example), or to every
handler through IGlobalFilterRegistry. Serialization is itself a filter: the response carries the
handler's return value, so a filter that changes the payload changes the value rather than the
bytes. The ordering, the context and the shipped positions are in
the execution pipeline.
What else the build writes
The same generate-don't-reflect treatment runs through the rest of the framework:
- Parameter binding — path, query, header, body and injected services bind through code emitted for each handler's exact signature; a binding that cannot work is a build error.
- Configuration — a configuration model is a partial class of private fields; the generator writes the interface, the implementation and the environment-variable reads.
- Authorization — a handler says what it needs; the pipeline decides whether the caller has it.
- Streaming responses — return
IAsyncEnumerable<T>and the response streams. - Content negotiation and System.Text.Json configuration follow the same shape.
Everything lands as readable source: EmitCompilerGeneratedFiles is on in the templates, so the
routing table, the handlers and the binding sit under obj/<configuration>/<tfm>/generated/.
Testing
A test method declares what it needs as parameters. The framework boots the real application around
the test, injects them, and substitutes a mock wherever a parameter is marked [Mock]. There is no
socket, port or running host: ITestWebApp sends the request through the actual pipeline — routing,
filters, binding, the handler and serialization.
Two assembly attributes are the whole wiring: the harness, and the module under test.
[assembly: WebTesting]
[assembly: HardenedTestEntryPoint(typeof(TodosLibrary))]
public class TodoTests {
[HardenedTest]
public async Task GetTodo_ReturnsTheTodo(ITestWebApp app) {
var response = await app.Get("/todos/1");
response.Assert.Ok();
Assert.Equal(1, response.Deserialize<TodoResponse>().Id);
}
[HardenedTest]
public async Task GetTodo_UnknownId_IsNotFound(ITestWebApp app) {
(await app.Get("/todos/9999")).Assert.NotFound();
}
}
See testing and testing web apps.
Packages
Everything ships to nuget.org as Hardened.*, and the templates reference the right set for each
project shape. Assembling by hand, the source generators are not optional and do not flow
transitively: the project that owns the application references them directly. The full list is in
the package reference.
Related repositories
- Hardened.Amz — the AWS provider: Lambda runtimes, test harnesses, DynamoDB client, CDK constructs
- Hardened.Docs — the documentation site
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Hardened.Requests.Abstract (>= 0.17.0-rc1000)
- Hardened.Requests.Runtime (>= 0.17.0-rc1000)
- Hardened.Shared.Runtime (>= 0.17.0-rc1000)
- Hardened.Web.Runtime (>= 0.17.0-rc1000)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.17.0-rc1000 | 41 | 8/31/2026 |
| 0.16.0-rc1000 | 41 | 8/31/2026 |
| 0.15.0-rc1000 | 48 | 8/29/2026 |
| 0.14.0-rc1000 | 54 | 8/26/2026 |
| 0.13.0-rc1000 | 54 | 8/25/2026 |
| 0.12.0-rc1000 | 65 | 8/21/2026 |
| 0.11.0-rc1000 | 58 | 8/20/2026 |
| 0.10.0-rc1000 | 59 | 8/19/2026 |
| 0.9.0-rc1000 | 62 | 8/19/2026 |
| 0.8.0-rc1000 | 58 | 8/18/2026 |
| 0.6.0-rc1000 | 64 | 8/18/2026 |
| 0.5.0-rc1000 | 55 | 8/17/2026 |
| 0.4.0-rc1000 | 62 | 8/15/2026 |
| 0.3.0-rc1000 | 63 | 8/15/2026 |
| 0.2.0-rc1000 | 70 | 8/14/2026 |
| 0.1.0-rc1 | 60 | 8/14/2026 |