Swashbuckle.AspNetCore icon indicating copy to clipboard operation
Swashbuckle.AspNetCore copied to clipboard

Setting `AdditionalPropertiesAllowed` For Properties Is Not Reflected In Generated Swagger JSON

Open danielloganking opened this issue 1 year ago • 1 comments

Background

We have an API which publishes it's contract as on OpenAPI doc using Swashbuckle 6.5.0. One route on that API publishes a dynamic javascript hash object which we define as having type object. We would like to use a SchemaFilter to then add "additionalProperties": true to the document for such properties. However, setting AdditionalPropertiesAllowed does not add the additionalProperties field as expected.

Note, setting AdditionalProperties does work as expected but is not a solution in this case since the hash is schemaless. For instance, specifying schema.AdditionalProperties = new OpenApiSchema() yields "additionalProperties": {} in the resulting document.

Affected Version

<TargetFramework>net6.0</TargetFramework>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="6.5.0" />

Steps to Reproduce

  1. Create a SchemaFilter that sets AdditionalPropertiesAllowed = true for any property of an object schema

Expected Behavior

Property is marked as "additionalProperties": true in generated OpenAPI document

Actual Behavior

Property has no additionalProperties field at all in generated OpenAPI document

Test Case

Below is a test file with a single failing test which demonstrates the issue:

using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using Xunit;

namespace Swashbuckle.AspNetCore.IntegrationTests;

public class SwaggerGenIntegrationTests
{
    [Fact]
    public async Task SchemaFilter_WhenSchemaFilterSetsAdditionalPropertiesAllowedToTrue_ThenGeneratedOpenApiReflectsTheSame()
    {
        // arrange
        using var server = CreateTestApiServer();
        using var client = server.CreateClient();

        // act
        var openApiJson = await client.GetStringAsync("/swagger/v1/swagger.json");

        // assert
        Assert.NotNull(openApiJson);

        var openApiDoc = JsonDocument.Parse(openApiJson).RootElement;
        var modelSchema =
            openApiDoc
                .GetProperty("components")
                .GetProperty("schemas")
                .GetProperty(nameof(GetResponseModel));

        Assert.Contains("\"additionalProperties\": false", modelSchema.ToString()); // passes; top-level unaffected

        var modelPropertiesSchema =
            modelSchema
                .GetProperty("properties");

        Assert.Contains("\"additionalProperties\": true", modelPropertiesSchema.ToString()); // fails
    }

    internal TestServer CreateTestApiServer() =>
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder =>
            {
                builder.UseStartup<TestApiStartup>();
                builder.UseTestServer();
            })
            .Start()
            .GetTestServer();
}

public class AdditionalPropertiesSchemaFilter : ISchemaFilter
{
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        if (context?.Type == typeof(object))
        {
            schema.AdditionalPropertiesAllowed = true;
        }
    }
}

#region TestApiClasses

// public because SwaggerGen needs access
public class GetResponseModel
{
    public object Object { get; init; } = new { };

    public dynamic Dynamic { get; init; } = new { };
}

// public because SwaggerGen needs access
[ApiController]
public class ExampleController : ControllerBase
{
    [HttpGet]
    [Route("example")]
    public ActionResult<GetResponseModel> GetExample() =>
        Ok(new GetResponseModel
        {
            Object = new { Key = "value" },
            Dynamic = new { Nested = new { Inner = true } },
        });
}

internal class TestApiStartup
{
    public void ConfigureServices(IServiceCollection serviceCollection)
    {
        serviceCollection.AddControllers();
        serviceCollection.AddEndpointsApiExplorer();
        serviceCollection.AddSwaggerGen(genOptions =>
            genOptions.SchemaFilter<AdditionalPropertiesSchemaFilter>());
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();
        app.UseEndpoints(routing => routing.MapControllers());
        app.UseSwagger();
        app.UseSwaggerUI();
    }
}

#endregion

danielloganking avatar May 02 '23 20:05 danielloganking

I'm pretty sure the issue is a disconnect between the OpenAPI spec and Microsoft's OpenApiDocument implementation.

Swashbuckle delegates the actual document serialization to Microsoft.OpenApi.Models.OpenApiDocument:

 if (_options.SerializeAsV2) swagger.SerializeAsV2(jsonWriter); else swagger.SerializeAsV3(jsonWriter);

That SerializeAsV3 method will not output additionalProperties: true. You can review that in its repo.

A tip in the Swagger.io docs site has this (sorry, no direct link):

image

So, to get additionalProperties into the document, the OpenApiSchema object must have a non-null value for its AdditionalProperties property.

schema.AdditionalPropertiesAllowed = true;
schema.AdditionalProperties = new();

I used an attribute on a class to indicate to the filter that it should update the schema. Details in the GitHub gist: AdditionalPropertiesSchemaFilter

pschaeflein avatar Apr 18 '24 16:04 pschaeflein

I'm pretty sure the issue is a disconnect between the OpenAPI spec and Microsoft's OpenApiDocument implementation.

I agree. We in fact addressed this shortcoming exactly as you suggest in our filter.

So, to get additionalProperties into the document, the OpenApiSchema object must have a non-null value for its AdditionalProperties property.

schema.AdditionalPropertiesAllowed = true;
schema.AdditionalProperties = new();

That said, I think it is mighty confusing for users of this library who see schema.AdditionalPropertiesAllowed = false; work like they expect but who must then set a completely separate property when trying to make schema.AdditionalPropertiesAllowed = true; work. My recommendation would be that schema.AdditionalProperties have some logic so that users don't have to deal directly with this implementation detail, which I expect would be something along the lines of:

schema.AdditionalProperties ??= schema.AdditionalPropertiesAllowed ? new() : null;

EDIT: If I have time, I will try to figure out the best place for this logic and put up a PR but since we addressed this for ourselves a while ago, I unfortunately may not get to it very quickly.

danielloganking avatar Apr 26 '24 18:04 danielloganking