Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Swashbuckle: Polymorphism not working with external nuget package

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
      }
like image 789
Joris Mathijssen Avatar asked Oct 31 '25 23:10

Joris Mathijssen


1 Answers

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()
{
    // ...

like image 100
Sasha Avatar answered Nov 03 '25 14:11

Sasha



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!