Generating from Smithy
Point the build at a Smithy model and the generator writes the models, a service interface, the routes and the request validation — the same output an OpenAPI document produces, from the same generator. Smithy is the contract language; the C# you write against it is identical.
The model
// contracts/todos.smithy
$version: "2"
namespace com.example.todos
@title("Todo API")
service Todos {
version: "2024-01-01"
operations: [GetTodo, CreateTodo, RemoveTodo]
}
structure Todo {
@required
id: Integer
@required
title: String
@required
done: Boolean
}
structure NewTodo {
@required
@length(min: 1, max: 64)
title: String
}
@error("client")
@httpError(404)
structure TodoNotFound {
@required
message: String
}
@error("client")
@httpError(409)
structure TodoTitleTaken {
@required
message: String
}
@documentation("One todo by id.")
@http(method: "GET", uri: "/todos/{id}", code: 200)
@readonly
operation GetTodo {
input := {
@httpLabel
@required
@range(min: 1)
id: Integer
}
output: Todo
errors: [TodoNotFound]
}
@documentation("Creates a todo.")
@http(method: "POST", uri: "/todos", code: 201)
operation CreateTodo {
input: NewTodo
output: Todo
errors: [TodoTitleTaken]
}
@documentation("Removes a todo.")
@http(method: "DELETE", uri: "/todos/{id}", code: 204)
@idempotent
operation RemoveTodo {
input := {
@httpLabel
@required
@range(min: 1)
id: Integer
}
errors: [TodoNotFound]
}Reference the generator and declare the model:
<ItemGroup>
<PackageReference Include="Hardened.Smithy.SourceGenerator" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<HardenedSmithyModel Include="contracts\todos.smithy">
<PublishUrl>/openapi.json</PublishUrl>
<UiUrl>/docs</UiUrl>
</HardenedSmithyModel>
</ItemGroup>The application module needs the web module and nothing else:
[HardenedModule]
[HardenedWebModule]
public partial class Application { }The build runs the Smithy CLI
HardenedSmithyModel compiles .smithy sources into a JSON AST, which needs the Smithy CLI on PATH. Two CLI versions can produce different ASTs and therefore different C#, so the version is pinned:
<HardenedSmithyCliVersion>1.73.0</HardenedSmithyCliVersion>A mismatch fails with HSMT011 naming both versions on a CI build, and warns on a local one. Set <HardenedSmithyPinCliVersion>true</HardenedSmithyPinCliVersion> to pin locally too. To build with no CLI at all, see Committing the AST.
The interface it produces
One interface per service shape, in <RootNamespace>.Services, named for the service:
public partial interface ITodosService {
/// <summary>
/// GET /todos/{id} → 200
///
/// One todo by id.
/// </summary>
Task<Todo?> GetTodo(int id);
/// <summary>
/// POST /todos → 201
///
/// Creates a todo.
/// </summary>
Task<Todo> CreateTodo(NewTodo body);
/// <summary>
/// DELETE /todos/{id} → 204
///
/// Removes a todo.
/// </summary>
Task RemoveTodo(int id);
}The models are positional records in <RootNamespace>.Models, carrying each shape's constraints as validation attributes:
public partial record Todo(int Id, string Title, bool Done);
public partial record NewTodo(
[property: Required] [property: StringLength(Min = 1, Max = 64)] string Title);
public partial record TodoNotFound([property: Required] string Message);
public partial record TodoTitleTaken([property: Required] string Message);Each declared error also produces an exception named for the operation and the status it belongs to, carrying that error's body:
public partial class GetTodoNotFoundException : StatusCodeException { }
public partial class CreateTodoConflictException : StatusCodeException { }
public partial class RemoveTodoNotFoundException : StatusCodeException { }Three things in that interface come from the model rather than from a choice:
GetTodoreturnsTodo?because the operation declaresTodoNotFound. Returningnullanswers 404 with that shape.RemoveTodoreturns bareTaskbecause its@httpcode is 204 and it declares no output.idisintrather thanstringbecause@httpLabelbinds a member whose type the model states.
Alongside them the build emits a handler per operation, the routing table, and a validation filter that enforces @required, @length, @range and @pattern before your code runs.
The implementation
Implement the interface and mark the class [Handler]:
using Hardened.Requests.Abstract.Attributes;
using Todos.Models;
using Todos.Services;
[Handler]
public class TodoService : ITodosService {
private readonly ITodoStore _store;
public TodoService(ITodoStore store) {
_store = store;
}
public Task<Todo?> GetTodo(int id) =>
Task.FromResult(_store.Find(id));
public Task<Todo> CreateTodo(NewTodo body) {
if (_store.TitleExists(body.Title)) {
throw new CreateTodoConflictException(
new TodoTitleTaken($"A todo titled '{body.Title}' already exists."));
}
return Task.FromResult(_store.Add(body.Title));
}
public Task RemoveTodo(int id) {
if (!_store.Remove(id)) {
throw new RemoveTodoNotFoundException(new TodoNotFound($"No todo has id {id}."));
}
return Task.CompletedTask;
}
}That is the whole wiring. There are no route attributes and nothing to register.
HTTP bindings
Members bind from the traits the model puts on them, and the generated signature follows:
| Trait | Binds from | Generated parameter |
|---|---|---|
@httpLabel | a path segment named by uri | positional, typed from the member |
@httpQuery("name") | the query string | positional, nullable when not @required |
@httpHeader("X-Name") | a request header | positional, named from the wire spelling |
| none | the request body | one body parameter of the input shape |
An output member carrying @httpHeader leaves as a header rather than in the JSON, and stays an ordinary member of the returned record:
operation CreatePet {
output := {
@required
pet: Pet
@httpHeader("Location")
location: String
}
}public Task<CreatePetOutput> CreatePet(CreatePetInput body) =>
Task.FromResult(new CreatePetOutput(created, "/pets/" + created.Id));Shapes
| Smithy | C# |
|---|---|
structure | a positional record |
list | List<T> |
map | Dictionary<string, T> |
enum | a C# enum, with the declared string values as its wire vocabulary |
union | a struct with one implicit conversion per member and an object? Value |
document | JsonElement |
Timestamp | DateTimeOffset |
Blob | byte[] |
@jsonName("x") | [JsonPropertyName("x")] on the property |
Authentication
@httpBearerAuth on the service requires every operation to authenticate; @auth([]) on an operation opts it back out:
@httpBearerAuth
service PetStore {
operations: [GetPet, GetSecuredPet]
}
@auth([])
@http(method: "GET", uri: "/pets/{petId}", code: 200)
operation GetPet { ... }Smithy has no scopes, so a model can require an authenticated caller and never a particular grant. To require grants, put [AuthorizeGrants] on the implementation — a contract can narrow what is admitted and never widen it.
Declaring the whole response set
Set HardenedResponseModel and each operation's declared errors become cases on a generated response container, which the compiler checks you handled:
<PropertyGroup>
<HardenedResponseModel>Response</HardenedResponseModel>
</PropertyGroup>public Task<GetTodoResponse> GetTodo(int id) {
var todo = _store.Find(id);
if (todo is null) {
return Task.FromResult<GetTodoResponse>(
new GetTodoNotFound(new TodoNotFound($"No todo has id {id}.")));
}
return Task.FromResult<GetTodoResponse>(todo);
}Standard, Response or Union; absent means Standard. See Declared responses.
Serving the document
PublishUrl serves the OpenAPI document generated from the model, not the Smithy AST, so the usual clients and the reference page at UiUrl can read it. UiEnvironments limits which environments serve the page:
<HardenedSmithyModel Include="contracts\todos.smithy">
<PublishUrl>/openapi.json</PublishUrl>
<UiUrl>/docs</UiUrl>
<UiEnvironments>development</UiEnvironments>
</HardenedSmithyModel>There is no SourceUrl. A Smithy model is not a document an OpenAPI client can read.
Committing the AST
HardenedSmithyAst takes a JSON AST directly, which resolves no tool and builds on a machine with no Smithy CLI:
smithy ast --flatten contracts/todos.smithy > contracts/todos.json<ItemGroup>
<HardenedSmithyAst Include="contracts\todos.json" />
</ItemGroup>Everything downstream is identical, and a project can use both inputs together. Pointing HardenedSmithyAst at a .smithy file is HSMT003.
Build properties
| Property | Effect |
|---|---|
HardenedSmithyModelName | Names the generated AST and the generated source. Defaults to the project name |
HardenedSmithyNamespace | Root for the generated types. Defaults to RootNamespace |
HardenedSmithyServiceShapeId | Selects one service when the model declares several |
HardenedSmithyCliVersion | The CLI version this build expects. Defaults to 1.73.0 |
HardenedSmithyPinCliVersion | Whether a version mismatch fails or warns. Defaults to ContinuousIntegrationBuild |
HardenedResponseModel | Standard, Response or Union |
ExcludeGeneratedCodeFromCoverage | [ExcludeFromCodeCoverage] on generated types. Defaults to true |
All HardenedSmithyModel items in a project form one model in one CLI invocation, since a Smithy service is routinely written across several files. A project needing two independent services generates their ASTs separately and points HardenedSmithyAst at the results.
Import the targets below the item group
An in-repo <Import> of Hardened.Smithy.SourceGenerator.targets has to come after the HardenedSmithyAst/HardenedSmithyModel item group, or no generated source reaches the compilation. The build reports HSMT005 and names the fix. A PackageReference imports the targets for you and is unaffected.
Starting from a template
dotnet new hardened-web -n Todos --contract smithyThat writes the model, the wiring, the implementation and tests. See Project templates.
Testing
Generated routes are ordinary Hardened routes, so the web test client drives them:
[HardenedTest]
public async Task GetsATodo(ITestWebApp testWebApp) {
var response = await testWebApp.Get("/todos/1");
response.Assert.Ok();
Assert.Equal(1, response.Deserialize<Todo>().Id);
}Next
- Generating from OpenAPI — the same generated output from an OpenAPI document
- Declared responses — the three response models in full
- The OpenAPI document — serving a document and a reference page