Generated clients
The hardened-web template writes src/Todos.Client, a project with no hand-written code. Its build generates a C# client from src/Todos/openapi/Todos.json, the document the library's build writes, and compiles it.
dotnet build
dotnet pack src/Todos.Client -p:PackageVersion=1.2.0The examples come from an application scaffolded with dotnet new hardened-web -n Todos. The name given with -n sets Todos in the paths, the namespaces and the client's name.
dotnet build in the solution writes the document, generates the client and compiles it. The default generator is Kiota, which writes the class TodosClient. With --client refit, Refitter writes the interface ITodosClient. Project templates covers the option.
dotnet pack on the client project writes a package. The Kiota client's only dependency is Microsoft.Kiota.Bundle 2.0.0. The Refit client's only dependency is Refit 16.3.0.
The OpenAPI document covers the file: what reaches it and how the build writes it.
The Kiota project
The project targets net8.0, whatever the library targets. It references the library with ReferenceOutputAssembly="false" and SkipGetTargetFrameworkProperties="true". The library builds first. The client compiles against none of it.
Kiota writes into obj/kiota/. The client class is in the namespace Todos.Client, and the models are in Todos.Client.Models.
.config/dotnet-tools.json pins the tool as microsoft.openapi.kiota 1.34.1. Directory.Packages.props pins the runtime as KiotaBundleVersion 2.0.0.
src/Todos.Client/Todos.Client.csproj declares five targets:
| Target | Runs | Does |
|---|---|---|
RestoreKiota | Before CheckKiotaPins | Runs dotnet tool restore. A failure is HTPL002 |
CheckKiotaPins | Before GenerateClient | Asks the tool, with kiota info, which bundle version it expects. A mismatch with KiotaBundleVersion is HTPL003 |
GenerateClient | Before CoreCompile | Runs kiota generate over the document into obj/kiota/ |
IncludeClient | After GenerateClient | Adds obj/kiota/**/*.cs to the compilation |
CleanClient | After CoreClean | Deletes obj/kiota/ |
The first build on a machine needs network, to restore the tool.
GenerateClient uses kiota-lock.json in obj/kiota/ as its up-to-date stamp. When the document, the tool manifest and Directory.Packages.props have not changed, the build skips the target:
<Target Name="GenerateClient"
DependsOnTargets="RestoreKiota;CheckKiotaPins"
BeforeTargets="CoreCompile"
Condition="'$(DesignTimeBuild)' != 'true'"
Inputs="$(KiotaSpec);$(MSBuildProjectDirectory)/../../.config/dotnet-tools.json;$(MSBuildProjectDirectory)/../../Directory.Packages.props"
Outputs="$(KiotaOutput)kiota-lock.json">
<Exec Command="dotnet kiota generate --language CSharp --openapi "$(KiotaSpec)" --class-name TodosClient --namespace-name $(RootNamespace) --output "$(KiotaOutput)" --clean-output --exclude-backward-compatible --log-level warning"
WorkingDirectory="$(MSBuildProjectDirectory)"
IgnoreStandardErrorWarningFormat="true"
EnvironmentVariables="KIOTA_OFFLINE_ENABLED=true;KIOTA_TUTORIAL_ENABLED=false;DOTNET_NOLOGO=1" />
</Target>The project file sets KiotaSpec to ../Todos/openapi/Todos.json and KiotaOutput to $(BaseIntermediateOutputPath)kiota/.
The generated constructor sets the request adapter's BaseUrl to the document's first servers entry when the adapter has none. In the template that entry is http://localhost:5080, from [Server] on the library module.
Kiota warns when the document has no servers entry. It also warns when the document has more than one. It then uses the first. The build prints the warning and continues.
A consumer of the package sets BaseUrl to the service's address. Kiota's documentation covers the request adapter, authentication and registering the client in a container.
The Refit project
With --client refit, src/Todos.Client holds .refitter beside the project file:
{
"openApiPath": "../Todos/openapi/Todos.json",
"namespace": "Todos.Client",
"contractsNamespace": "Todos.Client.Models",
"naming": {
"useOpenApiTitle": false,
"interfaceName": "TodosClient"
},
"returnIApiResponse": true,
"useCancellationTokens": true,
"optionalParameters": true,
"generateXmlDocCodeComments": true,
"generateStatusCodeComments": true,
"addAutoGeneratedHeader": true
}Refitter reads .refitter and writes the interface and its models into obj/refitter/TodosClient.cs. Refit's source generator implements the interface in the same project. The models are in Todos.Client.Models.
returnIApiResponse makes each operation return Task<IApiResponse<T>>, or Task<IApiResponse> where the success has no body. useCancellationTokens adds the CancellationToken parameter. optionalParameters makes each optional parameter an optional C# argument. Without it, an optional header such as the If-None-Match that [ConditionalGet] adds became a required argument, and every existing call stopped compiling.
The Refit project also targets net8.0. It references the library the same way as the Kiota project.
.config/dotnet-tools.json pins the tool as refitter 2.2.1. Directory.Packages.props pins Refit 16.3.0. Nothing checks that the two agree.
The targets are RestoreRefitter, GenerateClient, IncludeClient and CleanClient. RestoreRefitter reports HTPL004 when the tool does not restore. GenerateClient runs again when the document, .refitter, a Liquid template or the tool manifest changes.
The generated interface carries no address. A consumer gives Refit the service's address when it creates the client. Refit's documentation covers creating the client.
Refitter's documentation and its repository cover the settings file.
Build errors
The client project reports these errors:
| Code | Reported when | Message |
|---|---|---|
HTPL002 | The Kiota tool does not restore | The Kiota tool could not be restored, so the client cannot be generated. The pin is microsoft.openapi.kiota in .config/dotnet-tools.json; a fresh machine needs network for the first restore, and two builds restoring at once on one machine can fail it, each holding a file the other needs. dotnet tool restore in the solution folder shows what NuGet said. |
HTPL003 | The tool and KiotaBundleVersion disagree | The Kiota tool and the Kiota bundle disagree: microsoft.openapi.kiota in .config/dotnet-tools.json expects Microsoft.Kiota.Bundle 2.0.0, and KiotaBundleVersion in Directory.Packages.props is 2.1.2. The two move together; set both to one Kiota release. |
HTPL004 | The Refitter tool does not restore | The Refitter tool could not be restored, so the client cannot be generated. The pin is refitter in .config/dotnet-tools.json; a fresh machine needs network for the first restore, and two builds restoring at once on one machine can fail it, each holding a file the other needs. dotnet tool restore in the solution folder shows what NuGet said. |
The HTPL003 message names the two versions that CheckKiotaPins found. The table shows the message for a KiotaBundleVersion of 2.1.2.
Keeping the document current
The template commits src/Todos/openapi/Todos.json and not the generated client. The build rewrites the file when the document changes. A change to a route, a model or a doc comment shows as a change to the file.
In CI, build, then fail when the file differs from the committed one:
dotnet build
git diff --exit-code src/Todos/openapiWhat each generator writes
Kiota and Refitter write different code for these parts of the document:
| In the document | Kiota 1.34.1 | Refitter 2.2.1 |
|---|---|---|
| A success with a body | The method returns the model: Task<Todo?> | Task<IApiResponse<Todo>> |
An error status with a body, such as the 404 with NotFound | A model deriving from ApiException, thrown for that status | Returned rather than thrown, on the IApiResponse |
A stream: an array under schema and the item under itemSchema | Task<Stream>, the raw body | IAsyncEnumerable<Todo>, read one item at a time as the lines arrive |
A Smithy @streaming union, as a oneOf of its members | A composed type, after a warning that the oneOf has no discriminator. The member's name is the SSE event: field, which is not in the data, so the document has no property to name | IAsyncEnumerable<PetAdopted>, the first member's type. Refit reads each event's data: as that type, so another member's own fields are lost without an error |
Two cases at one status, as oneOf | A composed type with one member per case, after a warning that the oneOf has no discriminator | The first case's type |
| The operation's media types | An Accept header listing them, each after the first with q=0.9: application/json, text/plain;q=0.9 | [Headers("Accept: application/json, text/plain")], in the document's order |
The template's tests assert the 404 as Returns<NotFound<ClientModels.NotFound>>() with either generator. Typed clients covers the client in a test.
Any other generator reads the same file. A generator that refuses OpenAPI 3.2 reads a file written with <HardenedOpenApiOutputVersion>. The OpenAPI document covers that setting.
MessagePack clients
With --serializer message-pack-named or message-pack-keyed and --client refit, the client project also holds two Liquid templates in templates/, MessagePackContentSerializer.cs, and a reference to the MessagePack package, 3.1.9. .refitter then names the folder in customTemplateDirectory. It also sets generateDefaultAdditionalProperties to false.
The templates put these attributes on the generated models:
| Serializer | Model | Each property |
|---|---|---|
message-pack-keyed | [MessagePack.MessagePackObject] | [MessagePack.Key(n)], where n is its x-message-pack-index |
message-pack-keyed, for a schema without indexes, such as ErrorModel | [MessagePack.MessagePackObject(true)] | Keyed by name |
message-pack-named | [MessagePack.MessagePackObject(true)] | [MessagePack.Key("name")] |
A Kiota project gets no templates. MessagePack covers the serializer and the wire.
Where the client and the service disagree
A client generated from the file inherits each place where the document and the service disagree. The OpenAPI document lists them.
A member named with a leading acronym is the one the Kiota client gets wrong with no error. The document publishes ID as iD. From the body {"id":7,"urlPath":"a"}, the client reads ID and URLPath as null.
A route registered at startup has no call in the client, because the file does not hold it. Registered routes covers those routes.
Next
| Page | Covers |
|---|---|
| The OpenAPI document | The file, and what reaches it |
| Typed clients | The generated client in a test |
| Project templates | The --client option |
| MessagePack | MessagePack on the wire |