9

I am exploring Function App running on .net5 in the new isolated mode. I have HTTP triggered functions that I want to advertise via OpenAPI / Swagger.

To do so, I am using the package Microsoft.Azure.WebJobs.Extensions.OpenApi in preview (0.7.2) to add the OpenAPI functionality to my Function App.

I am trying to have the enums to be shown as string in the OpenAPI page but I can't have it working properly.

Here is the setup in the Program.cs file:

    public static class Program
    {
        private static Task Main(string[] args)
        {
            IHost host = new HostBuilder()
                .ConfigureAppConfiguration(configurationBuilder =>
                {
                    configurationBuilder.AddCommandLine(args);
                })
                .ConfigureFunctionsWorkerDefaults(builder =>
                {
                    builder.Services.Configure<JsonSerializerOptions>(options =>
                    {
                        options.Converters.Add(new JsonStringEnumConverter());
                        options.PropertyNameCaseInsensitive = true;
                    });
                })
                .ConfigureServices(services =>
                {
                    //  Registers any services.             
                })
                .Build();

            return host.RunAsync();
        }
    }

Here is the enum:

    [JsonConverter(typeof(JsonStringEnumConverter))]
    public enum ApprovalContract
    {
        [EnumMember(Value = "Approved")]
        Approved = 1,

        [EnumMember(Value = "Rejected")]
        Rejected = 2
    }

And one of the class that uses it:

    public sealed class DeletionResponseContract
    {
        [JsonPropertyName("approval")]
        public ApprovalContract Approval { get; set; }
    }

I replaced any references to Newtonsoft.Json by System.Text.Json everywhere.

Here is the output in the Swagger page:

Enum in the Swagger page

Question

How can I serialize enum as string instead of int in the Swagger page with an HTTP triggered Azure Function running on .net5?

Update

I saw that the JsonStringEnumConverter's constructor gives the indication to allow integer values:

     public JsonStringEnumConverter(JsonNamingPolicy? namingPolicy = null, bool allowIntegerValues = true)
    {
      this._namingPolicy = namingPolicy;
      this._converterOptions = allowIntegerValues ? EnumConverterOptions.AllowStrings | EnumConverterOptions.AllowNumbers : EnumConverterOptions.AllowStrings;
    }

I modified my configuration like this, without any success:

builder.Services.Configure<JsonSerializerOptions>(options =>
{
     options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false));
     options.PropertyNameCaseInsensitive = true;
});
Kzryzstof
  • 7,688
  • 10
  • 61
  • 108

4 Answers4

1

According to the Microsoft.Azure.WebJobs.Extensions.OpenApi.Core documentation you should be able to set the [JsonConverter(typeof(StringEnumConverter))] (with the Newtonsoft package) attribute on the enum to trigger the usage of strings in the Swagger.

I had issues however that the OpenAPI document still didn't show the enum as strings, and I believe the issue is related some compatibility between Newtonsoft version 13.0.1 (which is dependency for my project) and Azure Function Core Tools (AFCT) v. 3.41. It is anyway solved when either downgrading Newtonsoft to 12.0.3 or lower OR upgrading the project to use Azure Functions V4 and thus also Azure Function Core Tools v. 4.x.x.

The reason I suspect the Azure Function Core Tools to be the cause and not something else related to the Azure Function version, is that AFCT loads Newtonsoft assembly 12.0.0.0 when you start it, and if you're using Newtonsoft 12.0.3 in the project, the same assembly may be used by Microsoft.Azure.WebJobs.Extensions.OpenApi.Core. But if the project uses 13.0.1, it refers to assembly version 13.0.0.0, which is loaded by Microsoft.Azure.WebJobs.Extensions.OpenApi.Core alongside the 12.0.0.0 assembly. This mismatch in versions could be why the attribute isn't working as expected.

Olov
  • 1,103
  • 11
  • 27
0

You must implemente ISchemaFilter and set it on AddSwaggerGen. It will generate a better description of your enum items.

builder.Services.AddSwaggerGen(c =>
{
    c.SchemaFilter<EnumSchemaFilter>();
});


//your implementation
public class EnumSchemaFilter : ISchemaFilter
    {
        public void Apply(OpenApiSchema model, SchemaFilterContext context)
        {
            if (context.Type.IsEnum)
            {
                model.Enum.Clear();
                Enum.GetNames(context.Type)
                    .ToList()
                    .ForEach(name => model.Enum.Add(new OpenApiString($"{Convert.ToInt64(Enum.Parse(context.Type, name))} - {name}")));
            }
        }
    }
Murilo Maciel Curti
  • 2,677
  • 1
  • 21
  • 26
  • 1
    This looks like a great solution that would allow me to make all sorts of changes to the generated swagger but when I run my Azure Functions the lamda within AddSwaggerGen never gets called, so the filter is not applied. Any idea why not? I am calling it within the Configure Method of my Startup class and that method gets called. I am running on functions version 3 on .net 6. – Alan Hinton May 05 '22 at 12:59
  • This is for Swashbuckle: * https://github.com/domaindrivendev/Swashbuckle.AspNetCore#extend-generator-with-operation-schema--document-filters * There is a feature request for the default Functions implementation of openapi here: https://github.com/Azure/azure-functions-openapi-extension/issues/400 – Peter Jan 08 '23 at 19:02
0

I was inspired by Murilo's answer, but couldn't get it to work. So here is an alternative solution:

Create a document filter that will:

  • find all the enum properties in you swagger schemas

  • then find the matching property in your code using reflection (note: this doesn't take account of namespaces, so if you have multiple classes with the same name it could fail)

  • update the swagger property with the values from the c# enum

     public class EnumDocumentFilter : IDocumentFilter
     {
         public void Apply(IHttpRequestDataObject req, OpenApiDocument document)
         {
             foreach(var schema in document.Components.Schemas)
                 foreach(var property in schema.Value.Properties)
                     if (property.Value.Enum.Any())
                     {
                         var schemaType = Assembly.GetExecutingAssembly().GetTypes().Single(t => t.Name == Camel(schema.Key));
                         var propertyType = schemaType.GetProperty(Camel(property.Key)).PropertyType;
                         property.Value.Enum = Enum.GetNames(propertyType)
                             .Select(name => new OpenApiString(name))
                             .Cast<IOpenApiAny>()
                             .ToList();
                         property.Value.Type = "string";
                         property.Value.Default = property.Value.Enum.First();
                         property.Value.Format = null;
                     }
         }
    
         private static string Camel(string key)
             => $"{char.ToUpperInvariant(key[0])}{key[1..]}";
     }
    

Then register that filter in your OpenApiConfigurationOptions

    public class OpenApiConfigurationOptions : DefaultOpenApiConfigurationOptions
    {
...
        public override List<IDocumentFilter> DocumentFilters { get => base.DocumentFilters.Append(new EnumDocumentFilter()).ToList(); }
    }
Alan Hinton
  • 489
  • 4
  • 6
0

Thanks Alan Hinton for your answer, I was able to get the custom document filter working using reflection.

The problem: The enums were auto generated and I cannot keep adding StringEnumCovertor attribute each time the code was refreshed. Auto-generated code:

public enum Status
{
    [System.Runtime.Serialization.EnumMember(Value = @"new")]
    New = 0,

    [System.Runtime.Serialization.EnumMember(Value = @"confirmed")]
    Confirmed = 1,

    [System.Runtime.Serialization.EnumMember(Value = @"processing")]
    Processing = 2
}

public partial class Order 
{
    /// <summary>Status</summary>
    [Newtonsoft.Json.JsonProperty("status", Required = Newtonsoft.Json.Required.DisallowNull, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)]
    [Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
    public Status Status { get; set; }
}

Solution:

public OpenApiConfigurationOptions()
{
    DocumentFilters.Add(new OpenApiEnumAsStringsDocumentFilter());
}

public class OpenApiEnumAsStringsDocumentFilter : IDocumentFilter
{
    private const string YourNamespace = "your.namespace";
    private const string EnumDefaultMemberValue = "value__";
    private const string StringSchemaType = "string";

    public void Apply(IHttpRequestDataObject request, OpenApiDocument document)
    {
        var assemblyTypes = Assembly
            .GetExecutingAssembly()
            .GetTypes()
            .Where(x => !string.IsNullOrEmpty(x.FullName) && x.FullName.StartsWith(YourNamespace, StringComparison.InvariantCulture));

        // Loop all DTO classes
        foreach (var schema in document.Components.Schemas)
        {
            foreach (var property in schema.Value.Properties)
            {
                if (property.Value.Enum.Any())
                {
                    var schemaType = assemblyTypes.SingleOrDefault(t => t.Name.Equals(schema.Key, StringComparison.InvariantCultureIgnoreCase));
                    if (schemaType == null)
                        continue;

                    var enumType = schemaType.GetProperty(string.Concat(property.Key[0].ToString().ToUpper(), property.Key.AsSpan(1))).PropertyType;

                    UpdateEnumValuesAsString(property.Value, enumType);
                }
            }
        }

        // Loop all request parameters
        foreach (var path in document.Paths)
        {
            foreach (var operation in path.Value.Operations)
            {
                foreach (var parameter in operation.Value.Parameters)
                {
                    if (parameter.Schema.Enum.Any())
                    {
                        var enumType = assemblyTypes.SingleOrDefault(t => t.Name.Equals(parameter.Name, StringComparison.InvariantCultureIgnoreCase));
                        if (enumType == null)
                            continue;

                        UpdateEnumValuesAsString(parameter.Schema, enumType);
                    }
                }
            }
        }
    }

    private static void UpdateEnumValuesAsString(OpenApiSchema schema, Type enumType)
    {
        schema.Enum.Clear();
        enumType
            .GetTypeInfo()
            .DeclaredMembers
            .Where(m => !m.Name.Equals(EnumDefaultMemberValue, StringComparison.InvariantCulture))
            .ToList()
            .ForEach(m =>
            {
                var attribute = m.GetCustomAttribute<EnumMemberAttribute>(false);
                schema.Enum.Add(new OpenApiString(attribute.Value));
            });
        schema.Type = StringSchemaType;
        schema.Default = schema.Enum.FirstOrDefault();
        schema.Format = null;
    }
}
user1356185
  • 86
  • 2
  • 6