Skip to content

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

smithy
// 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:

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

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

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

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

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

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

  • GetTodo returns Todo? because the operation declares TodoNotFound. Returning null answers 404 with that shape.
  • RemoveTodo returns bare Task because its @http code is 204 and it declares no output.
  • id is int rather than string because @httpLabel binds 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]:

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

TraitBinds fromGenerated parameter
@httpLabela path segment named by uripositional, typed from the member
@httpQuery("name")the query stringpositional, nullable when not @required
@httpHeader("X-Name")a request headerpositional, named from the wire spelling
nonethe request bodyone 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:

smithy
operation CreatePet {
    output := {
        @required
        pet: Pet

        @httpHeader("Location")
        location: String
    }
}
csharp
public Task<CreatePetOutput> CreatePet(CreatePetInput body) =>
    Task.FromResult(new CreatePetOutput(created, "/pets/" + created.Id));

Shapes

SmithyC#
structurea positional record
listList<T>
mapDictionary<string, T>
enuma C# enum, with the declared string values as its wire vocabulary
uniona struct with one implicit conversion per member and an object? Value
documentJsonElement
TimestampDateTimeOffset
Blobbyte[]
@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:

smithy
@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:

xml
<PropertyGroup>
    <HardenedResponseModel>Response</HardenedResponseModel>
</PropertyGroup>
csharp
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:

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

bash
smithy ast --flatten contracts/todos.smithy > contracts/todos.json
xml
<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

PropertyEffect
HardenedSmithyModelNameNames the generated AST and the generated source. Defaults to the project name
HardenedSmithyNamespaceRoot for the generated types. Defaults to RootNamespace
HardenedSmithyServiceShapeIdSelects one service when the model declares several
HardenedSmithyCliVersionThe CLI version this build expects. Defaults to 1.73.0
HardenedSmithyPinCliVersionWhether a version mismatch fails or warns. Defaults to ContinuousIntegrationBuild
HardenedResponseModelStandard, 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

bash
dotnet new hardened-web -n Todos --contract smithy

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

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

Released under the MIT License.