Search code examples
c#asp.netopenapinswaggenerated-code

Reading a DTO from the HttpContext returns null in JSON de-serialization (nswag generated client)


I'm diving into NET8, minimal API and clean architecture.

First of all: This works (Swagger UI and postman)

   app.MapPost("/user/login", async (HttpContext httpContext, [FromBody] LoginDto loginDto, [FromServices] IConfiguration conf, [FromServices] UserDataBackupDbContext db, [FromServices] UserManager<IdentityUser> userManager) =>
   {
       try
       {
           {... shortened ...}

           var response = new AuthResponseDto
           {
               Id = user.Id,
               UserName = user.UserName ?? Constants.UnknownValue,
               Token = accessToken
           };

           return TypedResults.Ok<AuthResponseDto>(response);
       }
       catch (Exception e)
       {
           return Results.BadRequest<string>(e.Message);
       }
   }).WithName("UserLogin")
     .Produces<AuthResponseDto>(StatusCodes.Status200OK)
     .Produces<string>(StatusCodes.Status400BadRequest)
     .AllowAnonymous();

Via Swagger UI or Postman I get the object AuthResponseDto back.

Now I fiddled with nswag client generation. The created method is this:

   /// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
   /// <exception cref="UserDataBackupApiException">A server side error occurred.</exception>
   public virtual async System.Threading.Tasks.Task<AuthResponseDto> UserLoginAsync(LoginDto loginDto, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken))
   {
       if (loginDto == null)
           throw new System.ArgumentNullException("loginDto");

       var client_ = _httpClient;
       var disposeClient_ = false;
       try
       {
           using (var request_ = new System.Net.Http.HttpRequestMessage())
           {
               var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(loginDto, _settings.Value);
               var content_ = new System.Net.Http.ByteArrayContent(json_);
               content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json");
               request_.Content = content_;
               request_.Method = new System.Net.Http.HttpMethod("POST");
               request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json"));

               var urlBuilder_ = new System.Text.StringBuilder();
               if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
               // Operation Path: "user/login"
               urlBuilder_.Append("user/login");

               await PrepareRequestAsync(client_, request_, urlBuilder_, cancellationToken).ConfigureAwait(false);

               var url_ = urlBuilder_.ToString();
               request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);

               await PrepareRequestAsync(client_, request_, url_, cancellationToken).ConfigureAwait(false);

               var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
               var disposeResponse_ = true;
               try
               {
                   var headers_ = new System.Collections.Generic.Dictionary<string, System.Collections.Generic.IEnumerable<string>>();
                   foreach (var item_ in response_.Headers)
                       headers_[item_.Key] = item_.Value;
                   if (response_.Content != null && response_.Content.Headers != null)
                   {
                       foreach (var item_ in response_.Content.Headers)
                           headers_[item_.Key] = item_.Value;
                   }

                   await ProcessResponseAsync(client_, response_, cancellationToken).ConfigureAwait(false);

                   var status_ = (int)response_.StatusCode;
                   if (status_ == 200)
                   {
                       var objectResponse_ = await ReadObjectResponseAsync<AuthResponseDto>(response_, headers_, cancellationToken).ConfigureAwait(false);
                       if (objectResponse_.Object == null)
                       {
                           throw new UserDataBackupApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
                       }
                       return objectResponse_.Object;
                   }
                   else
                   if (status_ == 400)
                   {
                       var objectResponse_ = await ReadObjectResponseAsync<string>(response_, headers_, cancellationToken).ConfigureAwait(false);
                       if (objectResponse_.Object == null)
                       {
                           throw new UserDataBackupApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
                       }
                       throw new UserDataBackupApiException<string>("A server side error occurred.", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
                   }
                   else
                   {
                       var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
                       throw new UserDataBackupApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
                   }
               }
               finally
               {
                   if (disposeResponse_)
                       response_.Dispose();
               }
           }
       }
       finally
       {
           if (disposeClient_)
               client_.Dispose();
       }
   }

On run-time this will get a response with content length = 0. Later, within the status_ == 200 block, it will read the object response and return a new instance (all default values, not what was sent from the API).

Questions:

  • Why is the de-serialized object not filled with the values within the response?
  • Why is the HttpCompletionOption ResponseHeadersRead? If I manually set this to ResponseContentRead the content length is >0 and I can retrieve the content as string. But the rest of the function will return the "new born" instance anyway.
  • Is there any OpenApi or nswag option I can set?

For completness: The generated function reading the object. In my case the else part will try to read from the stream:

   protected virtual async System.Threading.Tasks.Task<ObjectResponseResult<T>> ReadObjectResponseAsync<T>(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>> headers, System.Threading.CancellationToken cancellationToken)
   {
       if (response == null || response.Content == null)
       {
           return new ObjectResponseResult<T>(default(T), string.Empty);
       }

       if (ReadResponseAsString)
       {
           var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
           try
           {
               var typedBody = System.Text.Json.JsonSerializer.Deserialize<T>(responseText, JsonSerializerSettings);
               return new ObjectResponseResult<T>(typedBody, responseText);
           }
           catch (System.Text.Json.JsonException exception)
           {
               var message = "Could not deserialize the response body string as " + typeof(T).FullName + ".";
               throw new UserDataBackupApiException(message, (int)response.StatusCode, responseText, headers, exception);
           }
       }
       else
       {
           try
           {
               using (var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
               {
                   var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync<T>(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false);
                   return new ObjectResponseResult<T>(typedBody, string.Empty);
               }
           }
           catch (System.Text.Json.JsonException exception)
           {
               var message = "Could not deserialize the response body stream as " + typeof(T).FullName + ".";
               throw new UserDataBackupApiException(message, (int)response.StatusCode, string.Empty, headers, exception);
           }
       }
   }

If you need more context, just ask. TIA


Solution

  • This is what I found myself:

    The solution for me was to change all property names in my DTOs in camelCase instead of PascalCase.

    Reading the object came back empty because the embedded JSON had lower-case initial letters, while my DTO's properties had upper-case there.

    Probably it's possible to find a way to configure this (JSON settings or Attributes to the properties), but it turned out to be nasty, especially when it comes to nested JSON objects.

    However, I decided to walk the camelCase route.

    In this link there is a discussion about casing in JSON and special situations with minimal API.

    I'm still open for an easy going with PascalCase properties.

    Hint: After waiting quite a while with no alternative answer, I'll accept my own answer. If there'll be any better solution I'd be happy to accept this.