microsoft / kiota

OpenAPI based HTTP Client code generator
https://aka.ms/kiota/docs
MIT License
2.93k stars 205 forks source link

Null results for GitHub users GET request #4844

Closed kfcampbell closed 2 weeks ago

kfcampbell commented 4 months ago

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 am trying to get users from the GitHub API, like var users = await gitHubClient.Users["kfcampbell"].GetAsync();

I'm using the dotnet-sdk generated from Kiota to do this, but it will reproduce with a fresh SDK generated from the Kiota CLI. The offending code is this generated code:

            public static WithUsernameGetResponse CreateFromDiscriminatorValue(IParseNode parseNode)
            {
                _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode));
                var mappingValue = parseNode.GetChildNode("")?.GetStringValue();
                var result = new WithUsernameGetResponse();
                if("private-user".Equals(mappingValue, StringComparison.OrdinalIgnoreCase))
                {
                    result.PrivateUser = new GitHub.Models.PrivateUser();
                }
                else if("public-user".Equals(mappingValue, StringComparison.OrdinalIgnoreCase))
                {
                    result.PublicUser = new GitHub.Models.PublicUser();
                }
                return result;
            }

When generating, the above code lives in a file called Users/Item/WithUsernameItemRequestBuilder.cs. What happens is that mappingValue is always null, which means the if statements never trigger, which means the returned users are always null in the result.

Expected behavior

I expect the returned users to be populated correctly.

How to reproduce

You may use the dotnet-sdk and modify the CLI to include the following C# code:

using GitHub;
using GitHub.Octokit.Client;
using GitHub.Octokit.Client.Authentication;

var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? "";
var request = RequestAdapter.Create(new TokenAuthProvider(new TokenProvider(token)));
var gitHubClient = new GitHubClient(request);

var users = await gitHubClient.Users["kfcampbell"].GetAsync();

Then run dotnet build, export your GITHUB_TOKEN, and run dotnet run. Alternately, you may generate a fresh C# SDK from the Kiota CLI, create your own CLI, input that code, and follow the same steps.

Open API description file

https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json

Kiota Version

v1.14.0

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

No response

Known Workarounds

Manually removing the if statements so the generated code reads like:

            public static WithUsernameGetResponse CreateFromDiscriminatorValue(IParseNode parseNode)
            {
                _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode));
                var mappingValue = parseNode.GetChildNode("")?.GetStringValue();
                var result = new WithUsernameGetResponse();
                result.PrivateUser = new GitHub.Models.PrivateUser();
                result.PublicUser = new GitHub.Models.PublicUser();

                return result;
            }

will fix the issue.

Configuration

This occurs both on my machine (an Ubuntu 22.04 derivative, 64-bit Linux box) and GitHub Actions (Ubuntu 22.04, 64-bit Linux). I do not believe it is specific to this configuration.

Debug output

N/A

Other information

No response

kfcampbell commented 4 months ago

When originally creating this issue, I somehow neglected to note it was originally spotted by the eagle eyes of @martincostello. Thanks Martin!

baywet commented 4 months ago

Hi @kfcampbell Thanks for reaching out on this topic! Aren't our MVPs precious? ;-)

This behaviour is expected, this is a union type, but it doesn't have discriminator information. Adding it to your description should fix the deserialization issue. (assuming you have a property that gives you the user kind). Additionally you might add mappings if the user kinds are not strictly matching the component name.

        "responses": {
          "200": {
            "description": "Response",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/private-user"
                    },
                    {
                      "$ref": "#/components/schemas/public-user"
                    }
                  ]
                },
+             "discriminator": {
+               "propertyName": "foo"
+              },
                "examples": {
                  "default-response": {
                    "$ref": "#/components/examples/public-user-default-response"
                  },
                  "response-with-git-hub-plan-information": {
                    "$ref": "#/components/examples/public-user-response-with-git-hub-plan-information"
                  }
                }
              }
            }
          },
kfcampbell commented 4 months ago

Thanks for the information! I know historically we've had teams resistant to adding discriminators to our specs because they would break other internal tooling for some reason. I've asked to see if that is still the case.

Additionally you might add mappings if the user kinds are not strictly matching the component name.

Do you mind telling me a little bit more about this? I'm not quite sure I understand what you're getting at here, I'm sorry.

baywet commented 4 months ago

Do you mind telling me a little bit more about this?

I meant a mappings element under the discriminator one. But if you're already hesitant to add a discriminator to the description, this is not going to help (not an alternative)

kfcampbell commented 2 months ago

I'd like to come back to this issue to report some weird findings. In the hopes of fixing this behavior, I've added a discriminator locally to my API, and I've confirmed payloads are coming back correctly:

abridged public user payload:

{
  "login": "monalisa",
  "id": 2,
  "followers": 0,
  "created_at": "2024-08-23T15:33:30Z",
  "updated_at": "2024-08-23T17:21:54Z",
  "user_type": "public"
}

abridged private user payload:

{
    "login": "monalisa",
    "id": 2,
    "following": 0,
    "created_at": "2024-08-23T15:33:30Z",
    "updated_at": "2024-08-23T17:21:54Z",
    "user_type": "private",
    "private_gists": 0,
    "two_factor_authentication": true,
}

The users endpoint OpenAPI spec I used to generate the SDK looks like this:

    "/user": {
      "get": {
        "summary": "Get the authenticated user",
        "description": "OAuth app tokens and personal access tokens (classic) need the `user` scope in order for the response to include private profile information.",
        "tags": [
          "users"
        ],
        "operationId": "users/get-authenticated",
        "externalDocs": {
          "description": "API method documentation",
          "url": "https://docs.github.com/rest/users/users#get-the-authenticated-user"
        },
        "parameters": [

        ],
        "responses": {
          "200": {
            "description": "Response",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/private-user"
                    },
                    {
                      "$ref": "#/components/schemas/public-user"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "user_type",
                    "mapping": {
                      "public": "../../components/schemas/public-user.yaml",
                      "private": "../../components/schemas/private-user.yaml"
                    }
                  }
                },
                "examples": {
                  "response-with-public-and-private-profile-information": {
                    "$ref": "#/components/examples/private-user-response-with-public-and-private-profile-information"
                  },
                  "response-with-public-profile-information": {
                    "$ref": "#/components/examples/private-user-response-with-public-profile-information"
                  }
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/not_modified"
          },
          "403": {
            "$ref": "#/components/responses/forbidden"
          },
          "401": {
            "$ref": "#/components/responses/requires_authentication"
          }
        }
      },

The full spec is available upon request.

What's interesting here is even with the discriminator, the code still does not serialize the HTTP response body into the type correctly. The following Go Kiota code prints an empty object (where gh is a variable holding an initialized Kiota client):

    user, err := gh.User().Get(ctx, nil)
    if err != nil {
        log.Fatalf("error: ", err)
    }

    fmt.Println("user: ", user) // prints user:  &{<nil> <nil>}

The Kiota serialization code:

// CreateUserGetResponseFromDiscriminatorValue creates a new instance of the appropriate class based on discriminator value
// returns a Parsable when successful
func CreateUserGetResponseFromDiscriminatorValue(parseNode i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode) (i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.Parsable, error) {
    result := NewUserGetResponse()
    if parseNode != nil {
        mappingValueNode, err := parseNode.GetChildNode("user_type")
        if err != nil {
            return nil, err
        }
        if mappingValueNode != nil {
            mappingValue, err := mappingValueNode.GetStringValue()
            if err != nil {
                return nil, err
            }
            if mappingValue != nil {
            }
        }
    }
    return result, nil
}

is creating a type, UserGetResponse, that contains both a publicUser and a privateUser internal field in it. However, neither of these are initialized. What I would expect is that based on the mappingValue, there would be either a PublicUserResponse or a PrivateUserResponse type generated, initialized, and populated.

Does that make sense? Is there an error in my API code/responses or OpenAPI spec somewhere?

baywet commented 2 months ago

Hey @kfcampbell Thank you for the additional information. Re-opening so it's easier to track.

have you tried the following instead

"discriminator": {
  "propertyName": "user_type",
    "mapping": {
-      "public": "../../components/schemas/public-user.yaml",
-      "private": "../../components/schemas/private-user.yaml"
+      "public": "#/components/schemas/public-user",
+      "private": "#/components/schemas/private-user"
  }
}
kfcampbell commented 2 months ago

Interestingly, that appears to be an artifact of GitHub's internal schema construction: we have separate files for each operation and schema, and they're all munged together. Something in that engine doesn't appear to be following up the reference correctly to replace the prepended relative path with the #/components/schemas directory.

That's gotten us a little further! Given a finalized schema of:

    "/user": {
      "get": {
        "summary": "Get the authenticated user",
        "description": "OAuth app tokens and personal access tokens (classic) need the `user` scope in order for the response to include private profile information.",
        "tags": [
          "users"
        ],
        "operationId": "users/get-authenticated",
        "parameters": [

        ],
        "responses": {
          "200": {
            "description": "Response",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/private-user"
                    },
                    {
                      "$ref": "#/components/schemas/public-user"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "user_type",
                    "mapping": {
                      "public": "#/components/schemas/public-user",
                      "private": "#/components/schemas/private-user"
                    }
                  }
                },
                "examples": {
                  "response-with-public-and-private-profile-information": {
                    "$ref": "#/components/examples/private-user-response-with-public-and-private-profile-information"
                  },
                  "response-with-public-profile-information": {
                    "$ref": "#/components/examples/private-user-response-with-public-profile-information"
                  }
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/not_modified"
          },
          "403": {
            "$ref": "#/components/responses/forbidden"
          },
          "401": {
            "$ref": "#/components/responses/requires_authentication"
          }
        },
      },

new Kiota code is produced:

func CreateUserGetResponseFromDiscriminatorValue(parseNode i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode)(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.Parsable, error) {
    result := NewUserGetResponse()
    if parseNode != nil {
        mappingValueNode, err := parseNode.GetChildNode("user_type")
        if err != nil {
            return nil, err
        }
        if mappingValueNode != nil {
            mappingValue, err := mappingValueNode.GetStringValue()
            if err != nil {
                return nil, err
            }
            if mappingValue != nil {
                if ie967d16dae74a49b5e0e051225c5dac0d76e5e38f13dd1628028cbce108c25b6.EqualFold(*mappingValue, "private") {
                    result.SetPrivateUser(i59ea7d99994c6a4bb9ef742ed717844297d055c7fd3742131406eea67a6404b6.NewPrivateUser())
                } else if ie967d16dae74a49b5e0e051225c5dac0d76e5e38f13dd1628028cbce108c25b6.EqualFold(*mappingValue, "public") {
                    result.SetPublicUser(i59ea7d99994c6a4bb9ef742ed717844297d055c7fd3742131406eea67a6404b6.NewPublicUser())
                }
            }
        }
    }
    return result, nil
}

Note the extra code visible after if mappingValue != nil. When running against a local API that returns the user_type discriminator, the privateUser sub-object is correctly initialized.

Later on, in github.com/microsoft/kiota-serialization-json-go's json_parse_node.go's GetObjectValue method, it looks like all the properties are correctly identified:

Image

but the fields are not:

Image

which means the result stays empty as initialized:

Image

Do you have any idea what might be going on there?

kfcampbell commented 2 months ago

Sidenote: it's weird to me that given a oneOf scenario, Kiota generates a top-level object that contains both possibilities, and only populates one of them. Why is that? Is that due to something I'm doing wrong on the API side?

baywet commented 2 months ago

On the side note: that's because at the time we implemented composed types support for go, go did not support union types. I think it's changed in recent versions but changing that now would be a source breaking change. The model looks correct, at least for its factory

baywet commented 2 months ago

On the main problem: can you share the generated get field deserializer body for the user get response type please? (The Union model)

kfcampbell commented 2 months ago

There's this function:

// GetFieldDeserializers the deserialization information for the current model
// returns a map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode)(error) when successful
func (m *UserGetResponse) GetFieldDeserializers()(map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode)(error)) {
    return make(map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode)(error))
}

inside of user_request_builder.go that's suspiciously empty. Is that what you're referring to?

baywet commented 2 months ago

Thank you for the additional information. Yes that's what I was referring to. And yes that's emptier than I expected. I expected to see a "triage" implementation "if memberType1 is not null, then return the field deserializers from that type", etc...

This is the method which is supposed to write that. It'd be interesting to trace the generation to understand whether it gets there at all. https://github.com/microsoft/kiota/blob/61d036ed06c2c9942a6ba3e9e8f4abe8b4abd1e0/src/Kiota.Builder/Writers/Go/CodeMethodWriter.cs#L629 Let us know if you have any additional comments or questions.

kfcampbell commented 2 months ago

What's the best way to do that? I can provide debug logs and a full OpenAPI specification, of course. I could also maybe manually run Kiota from source in debug mode and step through to get to that method?

It's worth noting this occurs both on Kiota v1.14.0 (which is what we were running prior to this week) and Kiota v1.17.0 (which is what we upgraded to this week).

baywet commented 2 months ago

clone the repo, change the target description for the go launch settings, set a breakpoint on that method.

kfcampbell commented 1 month ago

I'm still working through this and haven't had as much time as I'd like yet to debug with Kiota. We've had issues with our OpenAPI tooling and we're in the process of getting discriminators to a point where they'll even be included in our specification. Stay tuned.

kfcampbell commented 1 month ago

Okay, I've done some more investigation here. It turns out this is (at least) a Go thing: the serialization works in .NET. When given a correct spec (one that includes the discriminator as described here, Kiota generates correct .NET code to deserialize the object.

Kiota v1.17.0 Microsoft.Kiota.Abstractions 1.11.0 Microsoft.Kiota.Http.HttpClientLibrary 1.11.0 Microsoft.Kiota.Serialization.Form 1.11.0 Microsoft.Kiota.Serialization.Json 1.11.0 Microsoft.Kiota.Serialization.Multipart 1.11.0 Microsoft.Kiota.Serialization.Text 1.11.0 Microsoft.Kiota.Authentication.Azure 1.11.0

GetFieldDeserializers is properly generating in .NET:

        /// <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>>
            {
                { "avatar_url", n => { AvatarUrl = n.GetStringValue(); } },
                { "bio", n => { Bio = n.GetStringValue(); } },
                { "blog", n => { Blog = n.GetStringValue(); } },
                { "business_plus", n => { BusinessPlus = n.GetBoolValue(); } },
                { "collaborators", n => { Collaborators = n.GetIntValue(); } },
                { "company", n => { Company = n.GetStringValue(); } },
                { "created_at", n => { CreatedAt = n.GetDateTimeOffsetValue(); } },
                { "disk_usage", n => { DiskUsage = n.GetIntValue(); } },
                { "email", n => { Email = n.GetStringValue(); } },
                { "events_url", n => { EventsUrl = n.GetStringValue(); } },
                { "followers", n => { Followers = n.GetIntValue(); } },
                { "followers_url", n => { FollowersUrl = n.GetStringValue(); } },
                { "following", n => { Following = n.GetIntValue(); } },
                { "following_url", n => { FollowingUrl = n.GetStringValue(); } },
                { "gists_url", n => { GistsUrl = n.GetStringValue(); } },
                { "gravatar_id", n => { GravatarId = n.GetStringValue(); } },
                { "hireable", n => { Hireable = n.GetBoolValue(); } },
                { "html_url", n => { HtmlUrl = n.GetStringValue(); } },
                { "id", n => { Id = n.GetLongValue(); } },
                { "ldap_dn", n => { LdapDn = n.GetStringValue(); } },
                { "location", n => { Location = n.GetStringValue(); } },
                { "login", n => { Login = n.GetStringValue(); } },
                { "name", n => { Name = n.GetStringValue(); } },
                { "node_id", n => { NodeId = n.GetStringValue(); } },
                { "notification_email", n => { NotificationEmail = n.GetStringValue(); } },
                { "organizations_url", n => { OrganizationsUrl = n.GetStringValue(); } },
                { "owned_private_repos", n => { OwnedPrivateRepos = n.GetIntValue(); } },
                { "plan", n => { Plan = n.GetObjectValue<global::GitHub.Models.PrivateUser_plan>(global::GitHub.Models.PrivateUser_plan.CreateFromDiscriminatorValue); } },
                { "private_gists", n => { PrivateGists = n.GetIntValue(); } },
                { "public_gists", n => { PublicGists = n.GetIntValue(); } },
                { "public_repos", n => { PublicRepos = n.GetIntValue(); } },
                { "received_events_url", n => { ReceivedEventsUrl = n.GetStringValue(); } },
                { "repos_url", n => { ReposUrl = n.GetStringValue(); } },
                { "site_admin", n => { SiteAdmin = n.GetBoolValue(); } },
                { "starred_url", n => { StarredUrl = n.GetStringValue(); } },
                { "subscriptions_url", n => { SubscriptionsUrl = n.GetStringValue(); } },
                { "suspended_at", n => { SuspendedAt = n.GetDateTimeOffsetValue(); } },
                { "total_private_repos", n => { TotalPrivateRepos = n.GetIntValue(); } },
                { "twitter_username", n => { TwitterUsername = n.GetStringValue(); } },
                { "two_factor_authentication", n => { TwoFactorAuthentication = n.GetBoolValue(); } },
                { "type", n => { Type = n.GetStringValue(); } },
                { "updated_at", n => { UpdatedAt = n.GetDateTimeOffsetValue(); } },
                { "url", n => { Url = n.GetStringValue(); } },
                { "user_type", n => { UserType = n.GetStringValue(); } },
            };
        }

but not for Go:

// GetFieldDeserializers the deserialization information for the current model
// returns a map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode)(error) when successful
func (m *UserGetResponse) GetFieldDeserializers()(map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode)(error)) {
    return make(map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode)(error))
}

leading to the empty response body in Go only.

Go versions: Kiota v1.17.0 github.com/microsoft/kiota-abstractions-go v1.6.1 github.com/microsoft/kiota-http-go v1.4.3 github.com/microsoft/kiota-serialization-form-go v1.0.0 github.com/microsoft/kiota-serialization-json-go v1.0.7 github.com/microsoft/kiota-serialization-multipart-go v1.0.0 github.com/microsoft/kiota-serialization-text-go v1.0.0

@baywet should this be transfered perhaps to github.com/microsoft/kiota-abstractions-go or github.com/microsoft/kiota-http-go?

baywet commented 1 month ago

Thank you for the additional information. If we're comparing the serialization methods for the same types across different languages, this is definitively a generation issue and should remain here. Assuming those methods are both for the UserGetResponse type (obvious in go, but not in dotnet from what you've shared), I think the next step would be to approach that in a dichotomic way: in debug, are the writers receiving similar information? are they implemented in a similar way? (they should beyond the fact that go has intermediate interfaces which is what might be messing up the logic here). Also doing a sanity check with Java would be a good thing, since Java is close to both languages. Let us know if you have any additional comments or questions.

kfcampbell commented 3 weeks ago

@baywet apologies for the delay! Things have been a little crazy. @nickfloyd and I did some pairing on this earlier, and poked it a little farther. So the problem actually isn't with the discriminated types' deserializers: those are being generated correctly. In both C# and Go, public and private user models exist that have GetFieldDeserializers defined correctly.

Example OpenAPI JSON definition:

    "/users/{username}": {
      "get": {
        "summary": "Get a user",
        "description": "Provides publicly available information about someone with a GitHub account.\n\nThe `email` key in the following response is the publicly visible email address from your GitHub [profile page](https://github.com/settings/profile). When setting up your profile, you can select a primary email address to be “public” which provides an email entry for this endpoint. If you do not set a public email address for `email`, then it will have a value of `null`. You only see publicly visible email addresses when authenticated with GitHub. For more information, see [Authentication](https://docs.github.com/rest/guides/getting-started-with-the-rest-api#authentication).\n\nThe Emails API enables you to list all of your email addresses, and toggle a primary email to be visible publicly. For more information, see \"[Emails API](https://docs.github.com/rest/users/emails)\".",
        "operationId": "users/get-by-username",
        "parameters": [
          {
            "$ref": "#/components/parameters/username"
          }
        ],
        "responses": {
          "200": {
            "description": "Response",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/private-user"
                    },
                    {
                      "$ref": "#/components/schemas/public-user"
                    }
                  ],
                  "discriminator": {
                    "propertyName": "user_view_type",
                    "mapping": {
                      "public": "#/components/schemas/public-user",
                      "private": "#/components/schemas/private-user"
                    }
                  }
                },
                "examples": {
                  "default-response": {
                    "$ref": "#/components/examples/public-user-default-response"
                  },
                  "response-with-git-hub-plan-information": {
                    "$ref": "#/components/examples/public-user-response-with-git-hub-plan-information"
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/not_found"
          }
        },
      }
    },

Go's public_user.go (snippet):

func (m *PublicUser) GetFieldDeserializers()(map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode)(error)) {
    res := make(map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode)(error))
    res["avatar_url"] = func (n i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode) error {
        val, err := n.GetStringValue()
        if err != nil {
            return err
        }
        if val != nil {
            m.SetAvatarUrl(val)
        }
        return nil
    }
    res["bio"] = func (n i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode) error {
        val, err := n.GetStringValue()
        if err != nil {
            return err
        }
        if val != nil {
            m.SetBio(val)
        }
        return nil
    }

C#'s PrivateUser.cs (snippet):

        public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
        {
            return new Dictionary<string, Action<IParseNode>>
            {
                { "avatar_url", n => { AvatarUrl = n.GetStringValue(); } },
                { "bio", n => { Bio = n.GetStringValue(); } },
                { "blog", n => { Blog = n.GetStringValue(); } },
                { "business_plus", n => { BusinessPlus = n.GetBoolValue(); } },

The difference occurs in the user request builder, when that calls GetFieldDeserializers.

In C#, it's populated with logic that checks whether the public or private user is null (based upon what is previously set in CreateUserGetResponseFromDiscriminatorValue):

            public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
            {
                if(PrivateUser != null)
                {
                    return PrivateUser.GetFieldDeserializers();
                }
                else if(PublicUser != null)
                {
                    return PublicUser.GetFieldDeserializers();
                }
                return new Dictionary<string, Action<IParseNode>>();
            }

In Go, however, it simply returns a blank map:

func (m *UserGetResponse) GetFieldDeserializers() map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode) error {
    return make(map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode) error)
}

Nick and I have confirmed that manually adding the missing lines into the Go build will cause the request to correctly deserialize:

func (m *UserGetResponse) GetFieldDeserializers() map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode) error {
    if m.privateUser != nil {
        return m.privateUser.GetFieldDeserializers()
    } else if m.publicUser != nil {
        return m.publicUser.GetFieldDeserializers()
    }
    return make(map[string]func(i878a80d2330e89d26896388a3f487eef27b0a0e6c010c493bf80be1452208f91.ParseNode) error)
}

However, we're at a bit of a loss on where to troubleshoot the logic for how GetFieldDeserializers gets populated. Does that happen in the Go language refiner? In the Kiota builder? Would you mind pointing us in the right direction?

baywet commented 3 weeks ago

Thank you for the additional information. I think this is what you're looking for.

https://github.com/microsoft/kiota/blob/27ddb0bebcb731d342b8829a8320e95b3e56ff36/src/Kiota.Builder/Writers/Go/CodeMethodWriter.cs#L367

kfcampbell commented 3 weeks ago

Hmm...as best we can tell, it looks like on the Go side, the original ".NET" implementation seems correct. None of the information in the WriteSerializerBodyForUnionModel is obviously missing, and the code steps through there as we'd expect.

Is there a way to dump the contents of the LanguageWriter's writer byte array in that method? It might be that the error is happening later in the refiner or the "translation" to Go.

baywet commented 3 weeks ago

The LanguageWriter writes directly to the the file for performance reasons (memory pressure and whatnot). So no, unless me makes changes.

However, you could tweak/duplicate the unit test, and that'll give you access to the resulting string. Plus it's going to be much faster to run than the full engine. https://github.com/microsoft/kiota/blob/5ec4690a734c6db48e0b28740e6cacd165665fb8/tests/Kiota.Builder.Tests/Writers/Go/CodeMethodWriterTests.cs#L1495

nickfloyd commented 3 weeks ago

After a bit of foot work (thx for all of the tips @baywet ❤ ). I tracked it down to this:

The issue is in WriteDeserializerBodyForUnionModel or at least why the method body is not getting generated. So the LINQ statement for Go is:

var otherPropGetters = parentClass
 .GetPropertiesOfKind(CodePropertyKind.Custom)
 .Where(static x => !x.ExistsInBaseType && x.Getter != null)
 .Where(static x => x.Type is CodeType propertyType && !propertyType.IsCollection && propertyType.TypeDefinition is CodeClass)
 .OrderBy(static x => x, CodePropertyTypeForwardComparer)
 .ThenBy(static x => x.Name)
 .Select(static x => x.Getter!.Name.ToFirstCharacterUpperCase())
 .ToArray();

The problem is that for these discriminators the TypeDefinition is not of type CodeClass but of type CodeInterface When the LINQ is rewritten like this, it works:

var otherPropGetters = parentClass
 .GetPropertiesOfKind(CodePropertyKind.Custom)
 .Where(static x => !x.ExistsInBaseType && x.Getter != null)
 .Where(static x => x.Type is CodeType propertyType && !propertyType.IsCollection && propertyType.TypeDefinition is CodeInterface)
 .OrderBy(static x => x, CodePropertyTypeForwardComparer)
 .ThenBy(static x => x.Name)
 .Select(static x => x.Getter!.Name.ToFirstCharacterUpperCase())
 .ToArray();

Given my limited understanding of the architecture here, when the models are generated in go they are done so as interfaces. i.e. type ContentSymlinkable interface whereas .NET generates them as classes.

If this seems like a plausible fix, we can create a quick PR with some tests and get this resolved; just double checking here given my limited knowledge of the kiota architecture.

baywet commented 2 weeks ago

Great work getting to the root cause here! Yeah this has probably been caused by some bad copy pasta. Please go ahead and submit a pull request for that. If you could please double check other instances of the same pattern in the same source file as well, that'd help too!