Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Microsoft.AspNetCore.OpenApi does not generate a schema for an enum that is a key in a dictionary #60163

Open
1 task done
PatrezDev opened this issue Feb 2, 2025 · 3 comments
Labels
area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc feature-openapi
Milestone

Comments

@PatrezDev
Copy link

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

WebAPI .NET 9
Nuget: Microsoft.AspNetCore.OpenApi 9.0.1

Hello, when I use attribute:

[ProducesResponseType<Dictionary<AuthErrorType, ErrorDetail>>(403)]

the enum values are not generated in the OpenApi.json under the components/schemas section.

The enum is not generated even when using minimal API
app.MapGet("/minimal-api-approach", () => { return "Hello World!"; }).Produces<Dictionary<AuthErrorType, ErrorDetail>>();

`public class WeatherForecastController : ControllerBase
{
public WeatherForecastController() {

[HttpGet(Name = "controller-approach")]
[ProducesResponseType<FailureResult<AuthErrorType>>(401)]
[ProducesResponseType<Dictionary<AuthErrorType, ErrorDetail>>(403)]

public Task<IActionResult> Get()
{
    throw new NotImplementedException();
}

public record FailureResult<T>(Dictionary<T, ErrorDetail> Errors) where T : Enum;

public record ErrorDetail(string Description);

[JsonConverter(typeof(JsonStringEnumConverter<AuthErrorType>))]
public enum AuthErrorType
{
    InvalidCredentials,
    InvalidLogin
}}`

Expected Behavior

The expected result is a generated enum values in the components/schemas section.
With NSwag the result is below:

`
"components": {

"schemas": {
  "AuthErrorType": {
    "type": "integer",
    "description": "",
    "x-enumNames": [
      "InvalidCredentials",
      "InvalidLogin"
    ],
    "enum": [
      0,
      1
    ]
  }
}

}`

Steps To Reproduce

https://github.com/PatrezDev/openapispec-net-nswag

Exceptions (if any)

No response

.NET Version

.NET 9

Anything else?

Image

@dotnet-issue-labeler dotnet-issue-labeler bot added the old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels label Feb 2, 2025
@martincostello martincostello added feature-openapi area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc and removed old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels labels Feb 2, 2025
@UltraWelfare
Copy link

Can also confirm. This happens to our project outside of a dictionary (inside records).
Enums are being created as schemas with just type. This creates a slight problem for people that use codegen tools from the openapi json schemas.

@mikekistler
Copy link
Contributor

@PatrezDev Thank you for filing this issue. I think there is no schema generated for the enum because it would not be referenced anywhere. Unlike NSwag, the ASP.NET OpenAPI generation does not produce an x-dictionaryKey extension for additionalProperties.

In .NET 10 you should be able to write a transformer to do this. Unfortunately because it involves adding a schema this is not possible with .NET 9.

Actually I just tried this with .NET 10 preview 1 (will be released next week) and found it more difficult that I hoped. This generated some good ideas for making this easier and we'll try to get some of these done in upcoming preview releases.

I'm going to mark this as a feature request so we can link it to the features we deliver in .NET 10.

@mikekistler mikekistler added this to the Backlog milestone Feb 15, 2025
@fredyadriano90
Copy link

fredyadriano90 commented Mar 28, 2025

.net 9: Using the schema transformer:

services.AddOpenApi(static options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0;
options.AddSchemaTransformer();
});

internal sealed class GenericSchemaTransformer : IOpenApiSchemaTransformer
{
	public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
	{
		OpenApiSchema? newSchema = null;

		if (context.JsonTypeInfo.Type.IsEnum)
		{
			newSchema = EnumOpenApiSchemaGenerator.GetBaseJsonSchema(context.JsonTypeInfo.Type);
		}

		if (newSchema is null)
		{
			return Task.CompletedTask;
		}

		// copy properties from new schema to existing schema
		schema.AdditionalProperties = newSchema.AdditionalProperties;
		schema.AdditionalPropertiesAllowed = newSchema.AdditionalPropertiesAllowed;
		schema.AllOf = newSchema.AllOf;
		schema.Annotations = newSchema.Annotations;
		schema.AnyOf = newSchema.AnyOf;
		schema.Default = newSchema.Default;
		schema.Deprecated = newSchema.Deprecated;
		schema.Description = newSchema.Description;
		schema.Discriminator = newSchema.Discriminator;
		schema.Enum = newSchema.Enum;
		schema.Example = newSchema.Example;
		schema.ExclusiveMaximum = newSchema.ExclusiveMaximum;
		schema.ExclusiveMinimum = newSchema.ExclusiveMinimum;
		schema.Extensions = newSchema.Extensions;
		schema.ExternalDocs = newSchema.ExternalDocs;
		schema.Format = newSchema.Format;
		schema.Items = newSchema.Items;
		schema.Maximum = newSchema.Maximum;
		schema.MaxItems = newSchema.MaxItems;
		schema.MaxLength = newSchema.MaxLength;
		schema.MaxProperties = newSchema.MaxProperties;
		schema.Minimum = newSchema.Minimum;
		schema.MinItems = newSchema.MinItems;
		schema.MinLength = newSchema.MinLength;
		schema.MinProperties = newSchema.MinProperties;
		schema.MultipleOf = newSchema.MultipleOf;
		schema.Not = newSchema.Not;
		schema.Nullable = newSchema.Nullable;
		schema.OneOf = newSchema.OneOf;
		schema.Pattern = newSchema.Pattern;
		schema.Properties = newSchema.Properties
			.OrderBy(static it => it.Value.Title)
			.ToDictionary(static it => it.Key, static it => it.Value);
		schema.ReadOnly = newSchema.ReadOnly;
		schema.Reference = newSchema.Reference;
		schema.Required = newSchema.Required.Select(OpenApiSchemaExtensions.ToCamelCase).ToHashSet();
		schema.Title = newSchema.Title;
		schema.Type = newSchema.Type;
		schema.UniqueItems = newSchema.UniqueItems;
		schema.UnresolvedReference = newSchema.UnresolvedReference;
		schema.WriteOnly = newSchema.WriteOnly;
		schema.Xml = newSchema.Xml;

		return Task.CompletedTask;
	}
}

public static class EnumOpenApiSchemaGenerator
{
	public static OpenApiSchema GetBaseJsonSchema<TEnum>(TEnum? defaultValue = null)
		where TEnum : struct, Enum
	{
		return GetBaseJsonSchema(typeof(TEnum).Name, Enum.GetNames<TEnum>(), defaultValue?.ToString());
	}

	public static OpenApiSchema GetBaseJsonSchema(Type enumType, string? defaultValue = null)
	{
		if (!enumType.IsEnum)
		{
			throw new ArgumentException("Type must be an enum.", nameof(enumType));
		}

		return GetBaseJsonSchema(enumType.Name, Enum.GetNames(enumType), defaultValue);
	}

	private static OpenApiSchema GetBaseJsonSchema(
		string title,
		string[] names,
		string? defaultValue = null)
	{
		var openApiSchema = new OpenApiSchema()
		{
			Title = title,
			AdditionalPropertiesAllowed = false,
			Type = SchemaType.String.ToStringFast(true),
			Enum = [.. names.Select(name => new OpenApiString(name))],
			Annotations = new Dictionary<string, object>
			{
				["x-schema-id"] = title,
			},
		};

		if (defaultValue is not null)
			openApiSchema.Default = new OpenApiString(defaultValue);

		return openApiSchema;
	}
}

public enum SchemaType
{
	String,
	Integer,
	Number,
	Boolean,
	Object,
	Array,
}

// Test Endpoints

public static void MapCultureEndpoints(this WebApplication application)
{
	var router = application
		.MapGroup("/api/cultures")
		.WithTags("cultures");

	router.MapGet("/schema-type", () => SchemaTypeExtensions.GetNames())
		.WithName("cultures-schema-type")
		.WithDisplayName("schema-type")
		.Produces<SchemaType>();

	router.MapGet("/schema-type-45", () => SchemaTypeExtensions.GetNames())
		.WithName("cultures-schema-typev2")
		.WithDisplayName("schema-typev2")
		.Produces<SchemaType>();
}

open-api.json

{
  "openapi": "3.0.1",
  "info": {
    "title": "Portal API",
    "version": "v1"
  },
  "paths": {
    "/api/cultures/schema-type": {
      "get": {
        "tags": [
          "cultures"
        ],
        "operationId": "cultures-schema-type",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SchemaType"
                }
              }
            }
          }
        }
      }
    },
    "/api/cultures/schema-type-45": {
      "get": {
        "tags": [
          "cultures"
        ],
        "operationId": "cultures-schema-typev2",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SchemaType"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "SchemaType": {
        "title": "SchemaType",
        "enum": [
          "String",
          "Integer",
          "Number",
          "Boolean",
          "Object",
          "Array"
        ],
        "type": "string",
        "additionalProperties": false
      }
    }
  },
  "tags": [
    {
      "name": "cultures"
    }
  ]
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc feature-openapi
Projects
None yet
Development

No branches or pull requests

5 participants