kiota icon indicating copy to clipboard operation
kiota copied to clipboard

Incorrect C# property mapping with oneOf componet schema & null

Open awesomenath opened this issue 5 months ago • 6 comments

What are you generating using Kiota, clients or plugins?

API Client/SDK

In what context or format are you using Kiota?

Nuget tool

Client library/SDK language

Csharp

Describe the bug

I'm trying to use Kiota with a request with mapping oneOf that has one component schema and null, but Kiota can't seem to map this to a nullable string which is what I was expecting to resolve to the underlying type.

Endpoint:

    customer-create:
      title: CustomerCreate
      x-stoplight:
        id: t35cm8w8ydouf
      description: Represents a customer entity when creating customers.
      type: object
      additionalProperties: false
      properties:
        id:
          $ref: "#/components/schemas/customer_id"
          readOnly: true
        name:
          oneOf:
            - $ref: "#/components/schemas/name"
            - type: "null"
          description: Full name of this customer. Required when creating transactions where `collection_mode` is `manual` (invoices).
        email:
          $ref: "#/components/schemas/email"
          description: Email address for this customer.
        marketing_consent:
          type: boolean
          default: false
          description: |-
            Whether this customer opted into marketing from you. `false` unless customers check the marketing consent box
            when using Paddle Checkout. Set automatically by Paddle.
          readOnly: true
        custom_data:
          description: Your own structured key-value data.
          oneOf:
            - $ref: "#/components/schemas/custom_data"
            - type: "null"
        locale:
          description: Valid IETF BCP 47 short form locale tag. If omitted, defaults to `en`.
          default: en
          type: string
        import_meta:
          description: Import information for this entity. `null` if this entity is not imported.
          oneOf:
            - $ref: "#/components/schemas/import_meta"
            - type: "null"
          readOnly: true
      required:
        - email
      x-tags:
        - "v1: Entities"

Name Schema:

  name:
      x-stoplight:
        id: 4f2fk6rigfqro
      title: Name
      description: Full name.
      type: string
      maxLength: 1024

Customer Id schema:

customer_id:
      title: Customer ID
      x-stoplight:
        id: 6bbb1nk365bfp
      description: Unique Paddle ID for this customer entity, prefixed with `ctm_`.
      type: string
      examples:
        - ctm_01grnn4zta5a1mf02jjze7y2ys
      pattern: ^ctm_[a-z\d]{26}$

Email schema:

    email:
      x-stoplight:
        id: 7zm82w155qlwi
      title: Email address
      description: Email address for this entity.
      type: string
      format: email
      maxLength: 320
      minLength: 1
      examples:
        - [email protected]

Ends up with the following C# code, with some removed lines for brevity:

/// <summary>
/// Represents a customer entity when creating customers.
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class CustomerCreate : IParsable
{
        /// <summary>Email address for this customer.</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
        public string? Email { get; set; }
#nullable restore
#else
        public string Email { get; set; }
#endif
        /// <summary>Unique Paddle ID for this customer entity, prefixed with `ctm_`.</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
        public string? Id { get; private set; }
#nullable restore
#else
        public string Id { get; private set; }
#endif
        /// <summary>Full name of this customer. Required when creating transactions where `collection_mode` is `manual` (invoices).</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
        public global::Paddle.Client.Models.CustomerCreate.CustomerCreate_name? Name { get; set; }
#nullable restore
#else
        public global::Paddle.Client.Models.CustomerCreate.CustomerCreate_name Name { get; set; }
#endif

        // <summary>
        /// Instantiates a new <see cref="global::Paddle.Client.Models.CustomerCreate"/> and sets the default values.
        /// </summary>
        public CustomerCreate()
        {
            Locale = "en";
        }
        /// <summary>
        /// Creates a new instance of the appropriate class based on discriminator value
        /// </summary>
        /// <returns>A <see cref="global::Paddle.Client.Models.CustomerCreate"/></returns>
        /// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
        public static global::Paddle.Client.Models.CustomerCreate CreateFromDiscriminatorValue(IParseNode parseNode)
        {
            _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode));
            return new global::Paddle.Client.Models.CustomerCreate();
        }
        /// <summary>
        /// The deserialization information for the current model
        /// </summary>
        /// <returns>A IDictionary&lt;string, Action&lt;IParseNode&gt;&gt;</returns>
        public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
        {
            return new Dictionary<string, Action<IParseNode>>
            {
                { "custom_data", n => { CustomData = n.GetObjectValue<global::Paddle.Client.Models.CustomerCreate.CustomerCreate_custom_data>(global::Paddle.Client.Models.CustomerCreate.CustomerCreate_custom_data.CreateFromDiscriminatorValue); } },
                { "email", n => { Email = n.GetStringValue(); } },
                { "id", n => { Id = n.GetStringValue(); } },
                { "import_meta", n => { ImportMeta = n.GetObjectValue<global::Paddle.Client.Models.CustomerCreate.CustomerCreate_import_meta>(global::Paddle.Client.Models.CustomerCreate.CustomerCreate_import_meta.CreateFromDiscriminatorValue); } },
                { "locale", n => { Locale = n.GetStringValue(); } },
                { "marketing_consent", n => { MarketingConsent = n.GetBoolValue(); } },
                { "name", n => { Name = n.GetObjectValue<global::Paddle.Client.Models.CustomerCreate.CustomerCreate_name>(global::Paddle.Client.Models.CustomerCreate.CustomerCreate_name.CreateFromDiscriminatorValue); } },
            };
        }
        /// <summary>
        /// Serializes information the current object
        /// </summary>
        /// <param name="writer">Serialization writer to use to serialize this model</param>
        public virtual void Serialize(ISerializationWriter writer)
        {
            _ = writer ?? throw new ArgumentNullException(nameof(writer));
            writer.WriteObjectValue<global::Paddle.Client.Models.CustomerCreate.CustomerCreate_custom_data>("custom_data", CustomData);
            writer.WriteStringValue("email", Email);
            writer.WriteStringValue("locale", Locale);
            writer.WriteObjectValue<global::Paddle.Client.Models.CustomerCreate.CustomerCreate_name>("name", Name);
        }

        /// <summary>
        /// Composed type wrapper for classes <see cref="global::Paddle.Client.Models.CustomerCreate_nameMember1"/>, <see cref="global::Paddle.Client.Models.Name"/>
        /// </summary>
        [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
        public partial class CustomerCreate_name : IComposedTypeWrapper, IParsable
        {
            /// <summary>Composed type representation for type <see cref="global::Paddle.Client.Models.CustomerCreate_nameMember1"/></summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
            public global::Paddle.Client.Models.CustomerCreate_nameMember1? CustomerCreateNameMember1 { get; set; }
#nullable restore
#else
            public global::Paddle.Client.Models.CustomerCreate_nameMember1 CustomerCreateNameMember1 { get; set; }
#endif
            /// <summary>Composed type representation for type <see cref="global::Paddle.Client.Models.Name"/></summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
            public global::Paddle.Client.Models.Name? Name { get; set; }
#nullable restore
#else
            public global::Paddle.Client.Models.Name Name { get; set; }
#endif
            /// <summary>
            /// Creates a new instance of the appropriate class based on discriminator value
            /// </summary>
            /// <returns>A <see cref="global::Paddle.Client.Models.CustomerCreate.CustomerCreate_name"/></returns>
            /// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
            public static global::Paddle.Client.Models.CustomerCreate.CustomerCreate_name CreateFromDiscriminatorValue(IParseNode parseNode)
            {
                _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode));
                var mappingValue = parseNode.GetChildNode("")?.GetStringValue();
                var result = new global::Paddle.Client.Models.CustomerCreate.CustomerCreate_name();
                if("".Equals(mappingValue, StringComparison.OrdinalIgnoreCase))
                {
                    result.CustomerCreateNameMember1 = new global::Paddle.Client.Models.CustomerCreate_nameMember1();
                }
                else if("".Equals(mappingValue, StringComparison.OrdinalIgnoreCase))
                {
                    result.Name = new global::Paddle.Client.Models.Name();
                }
                return result;
            }
            /// <summary>
            /// The deserialization information for the current model
            /// </summary>
            /// <returns>A IDictionary&lt;string, Action&lt;IParseNode&gt;&gt;</returns>
            public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
            {
                if(CustomerCreateNameMember1 != null)
                {
                    return CustomerCreateNameMember1.GetFieldDeserializers();
                }
                else if(Name != null)
                {
                    return Name.GetFieldDeserializers();
                }
                return new Dictionary<string, Action<IParseNode>>();
            }
            /// <summary>
            /// Serializes information the current object
            /// </summary>
            /// <param name="writer">Serialization writer to use to serialize this model</param>
            public virtual void Serialize(ISerializationWriter writer)
            {
                _ = writer ?? throw new ArgumentNullException(nameof(writer));
                if(CustomerCreateNameMember1 != null)
                {
                    writer.WriteObjectValue<global::Paddle.Client.Models.CustomerCreate_nameMember1>(null, CustomerCreateNameMember1);
                }
                else if(Name != null)
                {
                    writer.WriteObjectValue<global::Paddle.Client.Models.Name>(null, Name);
                }
            }
        }
}

Customer_nameMember1 class:

[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
#pragma warning disable CS1591
public partial class Customer_nameMember1 : IParsable
#pragma warning restore CS1591
{
    /// <summary>
    /// Creates a new instance of the appropriate class based on discriminator value
    /// </summary>
    /// <returns>A <see cref="global::Paddle.Client.Models.Customer_nameMember1"/></returns>
    /// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
    public static global::Paddle.Client.Models.Customer_nameMember1 CreateFromDiscriminatorValue(IParseNode parseNode)
    {
        _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode));
        return new global::Paddle.Client.Models.Customer_nameMember1();
    }
    /// <summary>
    /// The deserialization information for the current model
    /// </summary>
    /// <returns>A IDictionary&lt;string, Action&lt;IParseNode&gt;&gt;</returns>
    public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
    {
        return new Dictionary<string, Action<IParseNode>>
        {
        };
    }
    /// <summary>
    /// Serializes information the current object
    /// </summary>
    /// <param name="writer">Serialization writer to use to serialize this model</param>
    public virtual void Serialize(ISerializationWriter writer)
    {
        _ = writer ?? throw new ArgumentNullException(nameof(writer));
    }
}

Name class:

/// <summary>
/// Full name.
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class Name : IParsable
{
    /// <summary>
    /// Creates a new instance of the appropriate class based on discriminator value
    /// </summary>
    /// <returns>A <see cref="global::Paddle.Client.Models.Name"/></returns>
    /// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
    public static global::Paddle.Client.Models.Name CreateFromDiscriminatorValue(IParseNode parseNode)
    {
        _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode));
        return new global::Paddle.Client.Models.Name();
    }
    /// <summary>
    /// The deserialization information for the current model
    /// </summary>
    /// <returns>A IDictionary&lt;string, Action&lt;IParseNode&gt;&gt;</returns>
    public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
    {
        return new Dictionary<string, Action<IParseNode>>
        {
        };
    }
    /// <summary>
    /// Serializes information the current object
    /// </summary>
    /// <param name="writer">Serialization writer to use to serialize this model</param>
    public virtual void Serialize(ISerializationWriter writer)
    {
        _ = writer ?? throw new ArgumentNullException(nameof(writer));
    }
}

Email and Id map to nullable strings but Name ends up with these classes that I can't access any values from and can't use correctly.

Expected behavior

I expect to either be able to use the class type with a value that will deserialise / serialise correctly or have it resolve to a nullable string.

How to reproduce

  1. Generate Kiota client e.g. kiota generate -l csharp --namespace-name Paddle.Client --openapi https://github.com/PaddleHQ/paddle-openapi/raw/refs/heads/main/v1/openapi.yaml -o ./Paddle.Client -c PaddleApiClient --additional-data false --exclude-backward-compatible --ll debug
  2. Attempt to use new CustomerCreate type e.g.
var newCustomer = new Paddle.Client.Models.CustomerCreate()
{
    Email = "[email protected]",
    Name = new CustomerCreate.CustomerCreate_name()
    {
        Name = "Test Name"
    }
};

Open API description file

https://github.com/PaddleHQ/paddle-openapi/blob/main/v1/openapi.yaml

Kiota Version

1.28.0+57130b1b1db3bc5c060498682f41e20c8ae089f2

Latest Kiota version known to work for scenario above?(Not required)

No response

Known Workarounds

No response

Configuration

  • OS: Windows 11
  • Architecture: x64

Debug output

Click to expand log
Information: KiotaBuilder Cleaning output directory .\Paddle.Client
Debug: KiotaBuilder kiota version 1.28.0
Debug: KiotaBuilder cache file C:\AppData\Local\Temp\kiota\cache\generation\CD7AA69F99657234A7E1A9689AE0D0318B0FA399E67960409DE8DA459839510E\openapi.yaml is up to date and clearCache is False, using it
Information: KiotaBuilder loaded description from remote source
Debug: KiotaBuilder step 1 - reading the stream - took 00:00:00.0060274
Warning: KiotaBuilder OpenAPI warning: #/ - Multiple servers entries were found in the OpenAPI description. Only the first one will be used. The root URL can be set manually with the request adapter.
Warning: KiotaBuilder OpenAPI warning: #/ - The schema simulation_scenario_config is a polymorphic type but does not define a discriminator. This will result in serialization errors.
Warning: KiotaBuilder OpenAPI warning: #/ - The schema simulation-update is a polymorphic type but does not define a discriminator. This will result in serialization errors.
Warning: KiotaBuilder OpenAPI warning: #/ - The schema simulation is a polymorphic type but does not define a discriminator. This will result in serialization errors.
Warning: KiotaBuilder OpenAPI warning: #/ - The schema simulation-create is a polymorphic type but does not define a discriminator. This will result in serialization errors.
Warning: KiotaBuilder OpenAPI warning: #/ - The schema simulation-run is a polymorphic type but does not define a discriminator. This will result in serialization errors.
Warning: KiotaBuilder OpenAPI warning: #/ - The schema simulation-run-includes is a polymorphic type but does not define a discriminator. This will result in serialization errors.
Warning: KiotaBuilder OpenAPI warning: #/ - The schema report is a polymorphic type but does not define a discriminator. This will result in serialization errors.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/email - The format email is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/error/properties/error/properties/documentation_url - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/image_url - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/pagination/properties/next - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/product-create/properties/image_url/oneOf/0 - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/product-update/properties/image_url/oneOf/2 - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/product.created/allOf/1/properties/data/properties/image_url/oneOf/2 - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/product.imported/allOf/1/properties/data/properties/image_url/oneOf/2 - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/product.updated/allOf/1/properties/data/properties/image_url/oneOf/2 - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/subscription_management_urls/properties/update_payment_method - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/subscription_management_urls/properties/cancel - The format uri is not supported by Kiota and the string type will be used.
Warning: KiotaBuilder OpenAPI warning: #/components/schemas/transaction-subscription-product-create/properties/image_url/oneOf/2 - The format uri is not supported by Kiota and the string type will be used.
Debug: KiotaBuilder step 2 - parsing the document - took 00:00:00.5502161
Debug: KiotaBuilder step 3 - updating generation configuration from kiota extension - took 00:00:00.0000617
Debug: KiotaBuilder step 4 - filtering API paths with patterns - took 00:00:00.0029282
Information: KiotaBuilder Client root URL set to https://api.paddle.com
Debug: KiotaBuilder step 5 - checking whether the output should be updated - took 00:00:00.0312706
Debug: KiotaBuilder step 6 - create uri space - took 00:00:00.0020808
Debug: KiotaBuilder InitializeInheritanceIndex 00:00:00.0025049
Warning: KiotaBuilder Discriminator name is not inherited from name.
Warning: KiotaBuilder Discriminator subscription_id is not inherited from subscription_id.
Warning: KiotaBuilder Discriminator business_id is not inherited from business_id.
Warning: KiotaBuilder Discriminator discount_code is not inherited from discount_code.
Warning: KiotaBuilder Discriminator timestamp is not inherited from timestamp.
Warning: KiotaBuilder Discriminator customer_id is not inherited from customer_id.
Warning: KiotaBuilder Discriminator address_id is not inherited from address_id.
Warning: KiotaBuilder Discriminator external_id is not inherited from external_id.
Warning: KiotaBuilder Discriminator image_url is not inherited from image_url.
Warning: KiotaBuilder Discriminator empty_string is not inherited from empty_string.
Warning: KiotaBuilder Discriminator discount_id is not inherited from discount_id.
Warning: KiotaBuilder Discriminator document_number is not inherited from document_number.
Warning: KiotaBuilder Discriminator apikey-description is not inherited from apikeyDescription.
Warning: KiotaBuilder Discriminator payment_method_id is not inherited from payment_method_id.
Warning: KiotaBuilder Discriminator transaction_id is not inherited from transaction_id.
Warning: KiotaBuilder Discriminator price_id is not inherited from price_id.
Warning: KiotaBuilder Discriminator product_id is not inherited from product_id.
Warning: KiotaBuilder Discriminator updated_at is not inherited from updated_at.
Warning: KiotaBuilder Discriminator adjustment-tax-rates-used is not inherited from updated_data_tax_rates_usedMember1.
Warning: KiotaBuilder Discriminator created_at is not inherited from created_at.
Warning: KiotaBuilder Discriminator adjustment-tax-rates-used is not inherited from created_data_tax_rates_usedMember1.
Debug: KiotaBuilder CreateRequestBuilderClass 00:00:00
Debug: KiotaBuilder MapTypeDefinitions 00:00:00.0226759
Debug: KiotaBuilder TrimInheritedModels 00:00:00
Debug: KiotaBuilder CleanUpInternalState 00:00:00
Debug: KiotaBuilder step 7 - create source model - took 00:00:00.3758099
Debug: KiotaBuilder 244ms: Language refinement applied
Debug: KiotaBuilder step 8 - refine by language - took 00:00:00.2453384
Debug: KiotaBuilder step 9 - writing files - took 00:00:00.6515641
Debug: KiotaBuilder cache file C:\AppData\Local\Temp\kiota\cache\generation\CD7AA69F99657234A7E1A9689AE0D0318B0FA399E67960409DE8DA459839510E\openapi.yaml is up to date and clearCache is False, using it
Information: KiotaBuilder loaded description from remote source
Debug: KiotaBuilder step 10 - writing lock file - took 00:00:00.0333733
Debug: KiotaBuilder Api manifest path: apimanifest.json

Other information

It correctly resolves to a string however when not using the oneOf scenario. The Open API file has many usages of this scenario and it seems that most if not all them with oneOf usages result in the same non-functional mapping code.

awesomenath avatar Jul 20 '25 06:07 awesomenath

This issue is especially important because ASP.NET moved to OpenApi 3.1 with .NET 10 and Kiota fails to generate correct code for any nullable schema. It should fold any [type, null] pattern into a nullable reference.

lvde0 avatar Oct 06 '25 15:10 lvde0

Thanks for raising this issue @lvde0 Are you aware of anything in the OpenAPI spec that relates to this specific behavior of type folding?

gavinbarron avatar Oct 27 '25 21:10 gavinbarron

@gavinbarron Yes, they removed nullable and we should use oneof or [type, null] now. See here.

lvde0 avatar Oct 28 '25 08:10 lvde0

@gavinbarron Is there any update on this? .NET 10 has officially released and Kiota fails to generate correct code for the OpenApi documents. Even when setting the generator to OpenApi 3.0 it fails to understand constructs like these:

 "oneOf": [
              {
                "nullable": true
              },
              {
                "$ref": "#/components/schemas/XYZ"
              }
            ]

lvde0 avatar Nov 12 '25 09:11 lvde0

I'm surprised this issue hasn't gotten more traction. It currently prevents us from adopting Microsoft.AspNetCore.OpenApi and upgrading to Open API 3.1.

rm-code avatar Nov 25 '25 09:11 rm-code

Huge issue for us too. Tried kiota 1.29.0 and OpenApi versions 3.0, 3.1, kiota cannot make sense of nullable properties.

[edit] OpenApi 2.0 seems to have issues generating nullable types, no kiota issue there AFAIK.

snerte avatar Dec 11 '25 09:12 snerte