Skip to content

OpenAPI and Swagger

Fix duplicate schema IDs caused by nested request types, in both Swashbuckle and Microsoft.AspNetCore.OpenApi.

2 min read

Immediate.Apis endpoints are ordinary minimal API endpoints, so every OpenAPI tool works with them unchanged. There is one wrinkle: the convention of nesting Query, Command and Response types inside the handler class means several different types share a simple name, and OpenAPI schema identifiers are derived from simple names by default.

The duplicate schema ID error

With Swashbuckle you will see something like this at startup:

Swashbuckle.AspNetCore.SwaggerGen.SwaggerGeneratorException: Failed to generate schema for type - MyApp.Api.DeleteUser+Command. See inner exception
System.InvalidOperationException: Can't use schemaId "$Command" for type "$MyApp.Api.DeleteUser+Command". The same schemaId is already used for type "$MyApp.Api.CreateUser+Command"

Two handlers in different namespaces each declare a nested type called Command, and the default schema-ID selector cannot tell them apart. The fix is to include the declaring type in the identifier.

Swashbuckle

Configure CustomSchemaIds to use the full name, replacing the CLR nested-type separator + with a .:

Program.cs
builder.Services.AddSwaggerGen(options =>
{
	options.CustomSchemaIds(t => t.FullName?.Replace('+', '.') ?? t.Name);
});

DeleteUser+Command then becomes MyApp.Api.DeleteUser.Command, which is unique.

Microsoft.AspNetCore.OpenApi and Scalar

The built-in OpenAPI document generator has the same problem and a different knob: OpenApiOptions.CreateSchemaReferenceId. Return your own identifier for nested types and fall back to the default for everything else:

Program.cs
builder.Services
	.AddOpenApi(o => o.CreateSchemaReferenceId = t =>
		t.Type.IsNested && t.Type.FullName is { } fullName
			? fullName.Replace('+', '.')
			: OpenApiOptions.CreateDefaultSchemaReferenceId(t));

var app = builder.Build();

app.MapOpenApi().CacheOutput();
app.MapScalarApiReference();

app.MapUsersApiEndpoints();

CreateSchemaReferenceId receives a JsonTypeInfo, so the CLR type is on t.Type. This produces MyApp.Api.DeleteUser.Command and MyApp.Api.CreateUser.Command — distinct even when handlers with the same names exist in different namespaces. It needs Microsoft.AspNetCore.OpenApi; the MapScalarApiReference() call comes from Scalar.AspNetCore and is optional.

Describing endpoints

Once schema IDs are unique, the rest is standard minimal API metadata. There are two places to put it:

  • Attributes on Handle/HandleAsync are copied onto the generated delegate — [ProducesResponseType<T>], [Produces], [Tags], and so on.
  • CustomizeEndpoint gives you the builder, for WithSummary, WithDescription, ProducesValidationProblem, WithOpenApi and the rest.
GetForecast.cs
[Handler]
[MapGet("/forecast/{id:int}")]
public static partial class GetForecast
{
	internal static void CustomizeEndpoint(IEndpointConventionBuilder endpoint)
		=> endpoint.WithDescription("Gets the current weather forecast");

	public sealed record Query
	{
		public required int Id { get; init; }
	}

	[ProducesResponseType<IReadOnlyList<Result>>(StatusCodes.Status200OK)]
	[ProducesResponseType(StatusCodes.Status404NotFound)]
	private static async ValueTask<IReadOnlyList<Result>> HandleAsync(
		Query _,
		CancellationToken token
	)
	{
		// ...
	}
}

XML doc comments on the request type’s properties and on the Handle method flow through to the OpenAPI document when the project has GenerateDocumentationFile enabled, exactly as they would for a hand-written endpoint.

See Customizing endpoints for the full rules on both mechanisms.