3

In our API we would like to return a object from a external Nuget package when a users makes a call to the endpoint.

This object (Can be viewed here) has a couple of properties. One of them is called Action. This property has as type IPaymentResponseAction but can be a set of different action types (You can see them all over here).

The generated swagger does not know about these actions and doesn't generate the required code. Even with the polymorphism setting set.

    services.AddSwaggerGen(c =>
            {
                c.EnableAnnotations();
                c.UseOneOfForPolymorphism();
            });

Is there a way that i can make these objects show up in my swagger? Maybe with some custom SwaggerGenOptions?


Update after first answer with the c.SelectSubTypesUsing code

    Adyen.Model.Checkout.PaymentResponse": {
        "type": "object",
        "properties": {
          "resultCode": {
            "$ref": "#/components/schemas/Adyen.Model.Checkout.PaymentResponse.ResultCodeEnum"
          },
          "action": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Adyen.Model.Checkout.Action.IPaymentResponseAction"
              },
              {
                "$ref": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutAwaitAction"
              },
              {
                "$ref": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutDonationAction"
              },
              {
                "$ref": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutOneTimePasscodeAction"
              },
              {
                "$ref": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutQrCodeAction"
              },
              {
                "$ref": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutRedirectAction"
              },
              {
                "$ref": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutSDKAction"
              },
              {
                "$ref": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutThreeDS2Action"
              },
              {
                "$ref": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutVoucherAction"
              }
            ],
            "nullable": true
          }......

And the IPaymentResponseAction is:

    "Adyen.Model.Checkout.Action.IPaymentResponseAction": {
        "required": [
          "type"
        ],
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "discriminator": {
          "propertyName": "type",
          "mapping": {
            "await": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutAwaitAction",
            "donation": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutDonationAction",
            "oneTimePasscode": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutOneTimePasscodeAction",
            "qrCode": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutQrCodeAction",
            "redirect": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutRedirectAction",
            "sdk": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutSDKAction",
            "threeDS2Action": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutThreeDS2Action",
            "voucher": "#/components/schemas/Adyen.Model.Checkout.Action.CheckoutVoucherAction"
          }
        }
      },

UPDATE: All my actions look like this now, so i think its not there yet. But its close!

    "CheckoutAwaitAction": {
        "type": "object",
        "allOf": [
          {
            "$ref": "#/components/schemas/Rig.Commercial.Reservation.Core.Settings.Swagger.Swagger_Models.PaymentResponseAction"
          }
        ],
        "additionalProperties": false
      }
Joris Mathijssen
  • 629
  • 7
  • 20

1 Answers1

2

Updated answer

This is the updated answer to the question that addresses the question :) And sorry for the long post.

The issue you described is caused by a lack of Swashbuckle's ability to handle C# interfaces to reflect polymorphic hierarchy (seems like a missing feature to me).

Here is a workaround (see an MVP project here).

Step 1. Swashbuckle options

    c.EnableAnnotations(enableAnnotationsForInheritance: true, enableAnnotationsForPolymorphism: true);
    c.UseAllOfToExtendReferenceSchemas();
    c.UseAllOfForInheritance();
    c.UseOneOfForPolymorphism();

Step 2. Discriminator options

Swashbuckle does not consider interfaces as "parent" types. What if we make it "think" it's still dealing with a class and not an interface? Let's introduce PaymentResponseAction class:

    [DataContract]
    [SwaggerDiscriminator("type")]
    public class PaymentResponseAction : IPaymentResponseAction
    {
        [JsonProperty(PropertyName = "type")]
        public string Type { get; set; }
    }

In the AddSwaggerGen call, we should also provide correct discriminator options:

    c.SelectDiscriminatorNameUsing(type =>
    {
        return type.Name switch
        {
            nameof(PaymentResponseAction) => "type",
            _ => null
        };
    });

    c.SelectDiscriminatorValueUsing(subType =>
    {
        return subType.Name switch
        {
            nameof(CheckoutAwaitAction) => "await",
            nameof(CheckoutBankTransferAction) => "bank",
            nameof(CheckoutDonationAction) => "donation",
            nameof(CheckoutOneTimePasscodeAction) => "oneTimePasscode",
            // rest of the action types ...
            _ => null
        };
    });

Step 3. allOf keyword in implementation classes

Up to this point, everything almost works. The only thing that is missing is the allOf keyword for the implementation classes. Currently, it's impossible to make it work with only Swashbuckle's options because it uses BaseType to resolve sub-types while constructing allOf.

And as before, we can make Swashbuckle think that it deals with inherited types. We can generate "fake" types that inherit our new PaymentResponseAction class and copy over properties from the implementation types we are interested in. These "fake" types don't have to be functional; they should contain enough type information to make Swashbuckle happy.

Here is an example of a method that does it. It accepts a source type to copy properties from a base type and returns a new type. It also copies custom attributes to play well with dependent settings like AddSwaggerGenNewtonsoftSupport.

Please note that this code should be improved to be production-ready; for example, it shouldn't "copy" public properties with JsonIgnore or similar attributes.

    private static Type GenerateReparentedType(Type originalType, Type parent)
    {
        var assemblyBuilder =
            AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("hack"), AssemblyBuilderAccess.Run);
        var moduleBuilder = assemblyBuilder.DefineDynamicModule("hack");
        var typeBuilder = moduleBuilder.DefineType(originalType.Name, TypeAttributes.Public, parent);

        foreach (var property in originalType.GetProperties(BindingFlags.Instance | BindingFlags.Public))
        {
            var newProperty = typeBuilder
                .DefineProperty(property.Name, property.Attributes, property.PropertyType, null);

            var getMethod = property.GetMethod;
            if (getMethod is not null)
            {
                var getMethodBuilder = typeBuilder
                    .DefineMethod(getMethod.Name, getMethod.Attributes, getMethod.ReturnType, Type.EmptyTypes);
                getMethodBuilder.GetILGenerator().Emit(OpCodes.Ret);
                newProperty.SetGetMethod(getMethodBuilder);
            }

            var setMethod = property.SetMethod;
            if (setMethod is not null)
            {
                var setMethodBuilder = typeBuilder
                    .DefineMethod(setMethod.Name, setMethod.Attributes, setMethod.ReturnType, Type.EmptyTypes);
                setMethodBuilder.GetILGenerator().Emit(OpCodes.Ret);
                newProperty.SetSetMethod(setMethodBuilder);
            }

            var customAttributes = CustomAttributeData.GetCustomAttributes(property).ToArray();
            foreach (var customAttributeData in customAttributes)
            {
                newProperty.SetCustomAttribute(DefineCustomAttribute(customAttributeData));
            }
        }

        var type = typeBuilder.CreateType();
        return type ?? throw new InvalidOperationException($"Unable to generate a re-parented type for {originalType}.");
    }

    private static CustomAttributeBuilder DefineCustomAttribute(CustomAttributeData attributeData)
    {
        // based on https://stackoverflow.com/a/3916313/8607180

        var constructorArguments = attributeData.ConstructorArguments
            .Select(argument => argument.Value)
            .ToArray();

        var propertyArguments = new List<PropertyInfo>();
        var propertyArgumentValues = new List<object?>();
        var fieldArguments = new List<FieldInfo>();
        var fieldArgumentValues = new List<object?>();

        foreach (var argument in attributeData.NamedArguments ?? Array.Empty<CustomAttributeNamedArgument>())
        {
            var fieldInfo = argument.MemberInfo as FieldInfo;
            var propertyInfo = argument.MemberInfo as PropertyInfo;

            if (fieldInfo != null)
            {
                fieldArguments.Add(fieldInfo);
                fieldArgumentValues.Add(argument.TypedValue.Value);
            }
            else if (propertyInfo != null)
            {
                propertyArguments.Add(propertyInfo);
                propertyArgumentValues.Add(argument.TypedValue.Value);
            }
        }

        return new CustomAttributeBuilder(
            attributeData.Constructor, constructorArguments,
            propertyArguments.ToArray(), propertyArgumentValues.ToArray(),
            fieldArguments.ToArray(), fieldArgumentValues.ToArray()
        );
    }

Now we can use it in the AddSwaggerGen call to make Swashbuckle resolve those types the way we want:

    var actionTypes = new[]
    {
        GenerateReparentedType(typeof(CheckoutAwaitAction), typeof(PaymentResponseAction)),
        GenerateReparentedType(typeof(CheckoutBankTransferAction), typeof(PaymentResponseAction)),
        GenerateReparentedType(typeof(CheckoutDonationAction), typeof(PaymentResponseAction)),
        GenerateReparentedType(typeof(CheckoutOneTimePasscodeAction), typeof(PaymentResponseAction)),
        // rest of the action types ...
    };

    c.SelectSubTypesUsing(type =>
    {
        var allTypes = typeof(Startup).Assembly.GetTypes().ToArray();
        return type.Name switch
        {
            nameof(PaymentResponseAction) => new[] { typeof(PaymentResponseAction) }.Union(actionTypes),
            nameof(IPaymentResponseAction) => new[] { typeof(PaymentResponseAction) }.Union(actionTypes),
            _ => allTypes.Where(t => t.IsSubclassOf(type))
        };
    });

Results

Now Swashbuckle should generate everything correctly:

paths:
  /api/someEndpoint:
    get:
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentResponse'
# ...

components:
  schemas:
    PaymentResponse:
      type: object
      properties:
        resultCode:
          allOf:
            - $ref: '#/components/schemas/ResultCodeEnum'
          nullable: true
        action:
          oneOf:
            - $ref: '#/components/schemas/PaymentResponseAction'
            - $ref: '#/components/schemas/CheckoutAwaitAction'
            - $ref: '#/components/schemas/CheckoutBankTransferAction'
            - $ref: '#/components/schemas/CheckoutDonationAction'
            - $ref: '#/components/schemas/CheckoutOneTimePasscodeAction'
            # ... rest of the actions
          nullable: true
        # ... rest of the properties
    PaymentResponseAction:
      required:
        - type
      type: object
      properties:
        type:
          type: string
          nullable: true
      additionalProperties: false
      discriminator:
        propertyName: type
        mapping:
          await: '#/components/schemas/CheckoutAwaitAction'
          bank: '#/components/schemas/CheckoutBankTransferAction'
          donation: '#/components/schemas/CheckoutDonationAction'
          oneTimePasscode: '#/components/schemas/CheckoutOneTimePasscodeAction'
          # ... rest of the action mapping
    CheckoutAwaitAction:
      type: object
      allOf:
        - $ref: '#/components/schemas/PaymentResponseAction'
      properties:
        # CheckoutAwaitAction's own properties
      additionalProperties: false
    CheckoutBankTransferAction:
      type: object
      allOf:
        - $ref: '#/components/schemas/PaymentResponseAction'
      properties:
        # CheckoutBankTransferAction's own properties
      additionalProperties: false
    CheckoutDonationAction:
      type: object
      allOf:
        - $ref: '#/components/schemas/PaymentResponseAction'
      properties:
        # CheckoutDonationAction's own properties
      additionalProperties: false
    CheckoutOneTimePasscodeAction:
      type: object
      allOf:
        - $ref: '#/components/schemas/PaymentResponseAction'
      properties:
        # CheckoutOneTimePasscodeAction's own properties
      additionalProperties: false
    # ... rest of the action classes

Previous (incomplete) answer

This can be done using the Swashbuckle.AspNetCore.Annotations package. Depending on the API design, you can use one of the following approaches.

Response schema doesn't depend on a response code

This approach takes advantage of using oneOf in the response schema. The idea is to make Swashbuckle generate a response schema that would have oneOf:

responses:
  '200':
    description: Success
    content:
      application/json:
        schema:
          oneOf:
            - $ref: '#/components/schemas/CheckoutAwaitAction'
            - $ref: '#/components/schemas/CheckoutBankTransferAction'
            - $ref: '#/components/schemas/CheckoutDonationAction'
            - $ref: '#/components/schemas/CheckoutOneTimePasscodeAction'
            # ...

Here is what you need to do:

  1. Add UseOneOfForPolymorphism and SelectSubTypesUsing options to your AddSwaggerGen call; make sure your SelectSubTypesUsing resolves IPaymentResponseAction interface to all the desired implementations your API is returning from a controller method:

    services.AddSwaggerGen(c =>
        {
        // ...
    
        c.UseOneOfForPolymorphism();
        c.SelectSubTypesUsing(baseType =>
        {
            if (baseType == typeof(IPaymentResponseAction))
            {
                return new[]
                {
                    typeof(CheckoutAwaitAction),
                    typeof(CheckoutBankTransferAction),
                    typeof(CheckoutDonationAction),
                    typeof(CheckoutOneTimePasscodeAction),
                    // ...
                };
            }
    
            return Enumerable.Empty<Type>();
        });
    
    
  2. Add SwaggerResponse annotation to your controller methods. Specify only the IPaymentResponseAction interface.

    [HttpGet]
    [SwaggerResponse((int)HttpStatusCode.OK, "response description", typeof(IPaymentResponseAction))]
    public IPaymentResponseAction GetPaymentAction()
    {
        // ...
    
    

This will give you the desired schema in Swagger-UI:

Response in Swagger-UI

Please note that Swagger-UI doesn't support the "Example Value" section if the schema has a oneOf definition: it will just show a response sample for the first resolved type in the SelectSubTypesUsing call.

Response schema depends on a response code

It doesn't seem like your case, but I still wanted to mention it as an option.

If the response schema is different for different response codes, you can specify corresponding types directly in the controller:

[HttpPost]
[SwaggerResponse((int)HttpStatusCode.Created, "response description", typeof(CheckoutAwaitAction))]
[SwaggerResponse((int)HttpStatusCode.OK, "response description", typeof(CheckoutBankTransferAction))]
// ...
public IPaymentResponseAction PostPaymentAction()
{
    // ...

Sasha
  • 827
  • 1
  • 9
  • 16
  • This is getting close to what we want. First of all the response of the controller is a PaymentResponse. In this object is a property with type action. So having annotations above our controller will not work. The addition of SelectSubTypesUsing is working and it generates the models. But in this proces it loses the IPaymentResponseAction base. If i generate code based on this swagger. The Action property will be of type CheckoutAwaitAction. The first in the list of subTypes. This should be IPaymentResponseAction right? – Joris Mathijssen Nov 17 '21 at 10:07
  • 1
    @JorisMathijssen Making sure I understand correctly: by "generated code" you mean client code generated for this API, right? If so, what toolset are you using for generating the client code? Into what programming language? – Sasha Nov 17 '21 at 13:52
  • Yes, im using NSwag to generate it into C#. – Joris Mathijssen Nov 17 '21 at 13:53
  • 1
    Also, sorry for the misunderstanding regarding `PaymentResponse`. I did not get the original question correctly. Let me think about it and I will get back to you. – Sasha Nov 17 '21 at 13:56
  • No problem! You helped me a lot already. The subclass definition is helpful! I will edit the original post with the swagger that is generated now. – Joris Mathijssen Nov 17 '21 at 14:02
  • Looks like i'm missing an `AllOf` on each of the action childs. – Joris Mathijssen Nov 17 '21 at 14:24
  • Maybe related: https://github.com/domaindrivendev/Swashbuckle.WebApi/issues/1409 – Joris Mathijssen Nov 17 '21 at 15:25
  • 1
    @JorisMathijssen Yes, you are right. The issue seems to be that Swashbuckle does not handle interfaces correctly for reflecting polymorphic hierarchy. There might be a workaround for that (looking into it) – Sasha Nov 19 '21 at 17:00
  • 1
    @JorisMathijssen Please take a look at the updated answer (at the beginning of the post) – Sasha Nov 20 '21 at 06:35
  • Sorry for getting back to you so late. It did work great. But all the actions are missing their properties. Is it a problem in the GenerateReparentedType method? See my update. – Joris Mathijssen Dec 02 '21 at 16:39
  • 1
    Interesting. Could you verify that `GenerateReparentedType` is called for the actions in step 3? `Union`-ing with `PaymentResponseAction` should take care of combining them. – Sasha Dec 02 '21 at 17:16
  • Yeh, its called for each of them, its also looping through all the property setters and getters. And in the union all the actions are all still ok. Its weird. – Joris Mathijssen Dec 03 '21 at 09:32
  • 1
    Very interesting. I was testing the code above with the following versions. `Swashbuckle.AspNetCore: 6.2.3`, `Swashbuckle.AspNetCore.Annotations: 6.2.3`, `Swashbuckle.AspNetCore.Filters: 7.0.2`, `System.Reflection.Emit: 4.7.0`. For the third party I used `Adyen: 8.0.1` – Sasha Dec 03 '21 at 15:27
  • @JorisMathijssen Sorry, some of the references in my previous comment are irrelevant. For a demo please see an MVP project I put together at https://github.com/ferrata/swashbuckle-interface-polymorphism-demo – Sasha Dec 06 '21 at 05:36
  • Thanks i will take a look at it! :) – Joris Mathijssen Dec 06 '21 at 10:56
  • 1
    Apparently its the AddSwaggerGenNewtonsoftSupport() method i also have added... Not yet sure why but i'm going to figure it out. – Joris Mathijssen Dec 06 '21 at 16:45
  • 1
    @JorisMathijssen The reason it happened is that my code did not copy custom attributes from the original type's methods, `AddSwaggerGenNewtonsoftSupport` depends on it. Now it's fixed. Updated both the answer and the project code on GitHub (see a corresponding [PR](https://github.com/ferrata/swashbuckle-interface-polymorphism-demo/pull/1)) – Sasha Dec 10 '21 at 05:20
  • You are so great! I tried removing newtonsoft support and work with custom converters but it created more problems. This works perfect! :) – Joris Mathijssen Dec 10 '21 at 10:57
  • 1
    @JorisMathijssen, Glad it works for you! Although there is a bug in the `DefineCustomAttribute` method, I doubt it will cause issues in your case, but I will fix it anyway and update the answer later. – Sasha Dec 10 '21 at 16:36
  • 1
    @JorisMathijssen, Fixed the remaining copy-attributes issue. Answer updated, for details, see [PR](https://github.com/ferrata/swashbuckle-interface-polymorphism-demo/pull/2) – Sasha Dec 11 '21 at 08:31
  • I fixed it on my side too. Thanks – Joris Mathijssen Dec 13 '21 at 16:33