Accept the Official Hack: Build-Time OpenAPI Detection in .NET 10 Minimal APIs

It is straightforward to configure a minimal API to produce an OpenAPI document at build time. This runs the API during build, requests the OpenAPI document from it, and saves it to disk.
The slightly trickier part is to put checks in Program.cs to exclude any startup code that cannot run at build time. This is typically done because configuration key/value pairs are not available at that time. For example:
if (!isBuildTime)
{
connString = builder.Configuration.GetConnectionString("AppDB") ??
throw new InvalidOperationException("Connection string 'AppDB' is not configured.");
builder.Services.AddDbContext<AppDbContext>(
options =>
{
options.UseNpgsql(connString);
}
);
}
The question is how to deduce that the API has been launched at build time, i.e. isBuildTime should be true?
The official way of doing this is to check that the assembly that invoked the API is "GetDocument.Insider":
var isBuildTime =
Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider";
GetDocument.Insider.dll is the command line tool that automatically runs during build of the API if the .csproj includes the following reference:
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server"
Version="10.0.7">
...
</PackageReference>
This package is a shim. It only provides build targets and props and hooks into the build of the API to run the command-line tool dotnet-getdocument. This tool in turn runs the command line tool GetDocument.Insider that we check for.
This is a pretty convoluted sequence:
Microsoft.Extensions.ApiDescription.Serverprovides targets that run during build of the API.One of those targets runs the command line tool
dotnet-getdocumentThat in turn runs the command line tool
GetDocument.InsiderThat in turn runs the API and fetches the
/openapi/v1/json(or other configured endpoint) to get the OpenAPI document and saves it to disk.
Checking in Program.cs if the API was invoked by the assembly GetDocument.Insider.dll to determine if it is running during build bothers me. It is a hack, and an uncomfortable one, for two reasons.
First, the name of the tool could change. If the sequence above gets cleaned up or modified in the future, the assembly GetDocument.Insider might vanish. This means we would need to change the check we do to compute if the API is running at build time for OpenAPI document generation.
Second, there is a standard, time-worn way for checking in ASP.NET if Program.cs is running in a specific environment and executing code conditionally based on that: runtime environments.
We could define a custom environment name, say BuildTime, that sits alongside the predefined environments Production, Development and Staging, and check for it in Program.cs:
isBuildTime = builder.Environment.IsEnvironment("BuildTime")
This is the canonical solution to the problem of determining if the API was launched at build time. The environment name is not going to change either - unlike the assembly name used in the official solution - because this is a custom environment name that we have handpicked for our build.
Unfortunately, there is no official way of declaring the environment name under which the API should be launched during build. Nor would you have any success with setting the environment variables ASPNETCORE_ENVIRONMENT or DOTNET_ENVIRONMENT on the command line before building the API. In other words, this would not work:
export ASPNETCORE_ENVIRONMENT=BuildTime
dotnet build
The environment simply doesn't get picked up and passed to the API by the tooling that generates the OpenAPI document at build time.
However, I did succeed in setting the environment name so that it gets picked up by the API when it runs during build.
I show my solution in the next section. But my conclusion is that, even though it sounds like the right thing to do, actually doing it is even more fragile and hacky than the official solution.
So, until .NET provides an official way of providing a custom environment name to an API during build-time OpenAPI document generation - and there is an open issue in dotnet/aspnetcore repo requesting exactly this feature - I would just (grit my teeth and) use the official solution:
var isBuildTime =
Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider";
if (!isBuildTime) {
// load services and middlewares that require
// configuration key values that are not available at build time
}
Providing a custom environment to an API for build-time for OpenAPI generation - DON'T DO THIS
I define a custom .NET environment named BuildTime in my minimal API project.
While you can pass command-line arguments to the dotnet-getdocument tool using property <OpenApiGenerateDocumentsOptions> in the API's csproj:
<OpenApiGenerateDocumentsOptions>--file-name openapi_v1</OpenApiGenerateDocumentsOptions>
none of these corresponds to the environment name of the API that would be launched.
Also, you cannot pass --environment Development command line argument for dotnet run (dotnet build does not have an --environment argument):
dotnet run --environment BuildTime
using the <OpenApiGenerateDocumentsOptions> csproj property.
Nor can you set ASPNETCORE_ENVIRONMENT or DOTNET_ENVIRONMENT environment variable to "BuildTime" in such a way that it would be available to the API that would be launched during build (as explained in the previous section).
So to get the GetDocument.Insider tool to launch the API in environment "BuildTime", you essentially have to first construct the command line for running the GetDocument.Insider yourself then execute the command with environment variable ASPNETCORE_ENVIRONMENT set to BuildTime. This is done like this:
add a reference to
Microsoft.Extensions.ApiDescription.Serveras before usingdotnet add package Microsoft.Extensions.ApiDescription.Serverwhich reflects in thecsprojlike this:<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="9.0.6"> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference>But DISABLE automatic OpenAPI document generation on build to via these properties in the csproj:
<PropertyGroup> <OpenApiGenerateDocuments>false</OpenApiGenerateDocuments> <OpenApiGenerateDocumentsOnBuild>false</OpenApiGenerateDocumentsOnBuild> </PropertyGroup>Also REMOVE the following properties we added originally. They are not going to be used any more:
<PropertyGroup> <OpenApiDocumentsDirectory>.</OpenApiDocumentsDirectory> <OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions>
```
Construct the command line for running
GetDocument.Insidertool yourself, then execute this command line, by putting these targets in yourcsproj:<Target Name="ResolveApiDescriptionPackage"> <ItemGroup> <_ApiDescriptionPackage Include="@(PackageReference)" Condition="'%(Identity)' == 'Microsoft.Extensions.ApiDescription.Server'" /> </ItemGroup> </Target> <Target Name="GenerateOpenApiDocumentsAfterBuild" DependsOnTargets="ResolveReferences;ResolveApiDescriptionPackage" AfterTargets="Build"> <PropertyGroup> <_DetectedApiVersion>%(_ApiDescriptionPackage.Version)</_DetectedApiVersion> <_DotNetGetDocumentCommand>dotnet "\((NuGetPackageRoot)microsoft.extensions.apidescription.server/\)(_DetectedApiVersion)/tools/dotnet-getdocument.dll" --assembly "\((TargetPath)" --file-list "\)(MSBuildProjectDirectory)/obj/cloudcartapi.OpenApiFiles.cache" --framework "\((TargetFrameworkIdentifier),Version=\)(TargetFrameworkVersion)" --output "\((MSBuildProjectDirectory)" --project "\)(MSBuildProjectFullPath)" --assets-file "\((ProjectAssetsFile)" --platform "\)(Platform)" --file-name openapi_v1</_DotNetGetDocumentCommand> </PropertyGroup> <Exec Command="$(_DotNetGetDocumentCommand)" EnvironmentVariables="ASPNETCORE_ENVIRONMENT=BuildTime;DOTNET_ENVIRONMENT=BuildTime" LogStandardErrorAsError="true" /> </Target>Finally, for robustness, I add this flourish to the
csproj:<!-- The command run in GenerateOpenApiDocumentsAfterBuild target above is a little bit brittle and breaks if the major version of the ApiDescription package does not match the major version of .NET set in <TargetFramework> property a the top. While this is a very unlikely scenario, because the major version of every .NET package should be/would be the same as the major version of .NET framework that the project compiles against, still f the scenario does arise, instead of getting to the point where GenerateOpenApiDocumentsAfterBuild target runs and fails with an unedifying error, in this task we detect the situation early, show an informative error message and stop the build. --> <Target Name="ValidateApiDescriptionVersion" BeforeTargets="Build" DependsOnTargets="ResolveApiDescriptionPackage"> <PropertyGroup> <_TFMajorVersion>\(([System.Text.RegularExpressions.Regex]::Match('\)(TargetFramework)', 'net(\d+)').Groups[1].Value)</_TFMajorVersion> <_ApiDescriptionVersion>@(_ApiDescriptionPackage->'%(Version)')</_ApiDescriptionVersion> <_ApiDescriptionMajorVersion>\(([System.Text.RegularExpressions.Regex]::Match('\)(_ApiDescriptionVersion)', '^(\d+)').Groups[1].Value)</_ApiDescriptionMajorVersion> </PropertyGroup> <Error Condition="'\((_TFMajorVersion)' != '\)(_ApiDescriptionMajorVersion)'" Text="Version mismatch: TargetFramework '\((TargetFramework)' has major version \)(_TFMajorVersion) but Microsoft.Extensions.ApiDescription.Server version '\((_ApiDescriptionVersion)' has major version \)(_ApiDescriptionMajorVersion). Update this package to a version whose major version number is same as that of the target framework (specified in TargetFramework property in this csproj file)." /> </Target>
The real issue isn't that this solution is a bit messy, it is that it is even more fragile: if in a future version Microsoft change the name of the file dotnet-getdocument.dll or its location within the package microsoft.extensions.apidescription.server or names of any of the numerous arguments we are passing to it, this logic would break.
On the other hand, in the solution given in MS Docs (and excerpted from there in the article above):
var isBuildTime = Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider";
if (!isBuildTime) {
// load services and middlewares that DO require configuration key values that are not available at build time
}
if the name of assembly changes we would know about it through the Release Notes of the .NET version in which this happens, the new official solution would again be documented/updated, and we can update our code.
Hence why the official, if fragile-looking, solution for conditionally excluding code in Program.cs that requires configuration data is the one that should be used.