POST dto becomes empty with JsonHttpClient, GET works

Trying to make some integration tests I have landed in a situation where my post dto becomes empty. But changing the dto to be a GET request it will serialize the request just fine. Any idea why it differs and I get an empty dto in the POST variant?

The test:

  • CreateServicePrice2 is a copy of CreateServicePrice but have an appended “2” on the route and verb set to GET instead of POST

      [Fact(DisplayName = "POST new article should return ok")]
      public async Task PostOwnedShouldReturnOwnedAsync()
      {
          // Given
          var request = new CreateServicePrice
          {
              Category = "testcategory",
              From = DateTime.Now,
              To = DateTime.Now.AddDays(1),
              Price = 1,
              CompanyId = this.DefaultOwnedBookingSupplier.Id,
              CurrencyId = this.SharedDbContext.Constants.DefaultCurrency.Code,
              ServiceId = this.Data.OwnedActiveService.LegacyId,
              FromTime = Time.StartOfDay,
              ToTime = Time.EndOfDay,
              VAT = 0.25M,
              DaysOfWeek = DaysOfWeek.Monday.ToIntArray()
          };
    
          var request2 = new CreateServicePrice2
          {
              Category = "testcategory",
              From = DateTime.Now,
              To = DateTime.Now.AddDays(1),
              Price = 1,
              CompanyId = this.DefaultOwnedBookingSupplier.Id,
              CurrencyId = this.SharedDbContext.Constants.DefaultCurrency.Code,
              ServiceId = this.Data.OwnedActiveService.LegacyId,
              FromTime = Time.StartOfDay,
              ToTime = Time.EndOfDay,
              VAT = 0.25M,
              DaysOfWeek = DaysOfWeek.Monday.ToIntArray()
          };
    
          // When
          
          // This works, the dto is delivered to the service
          var result = await this.Client.GetAsync(request2).ConfigureAwait(false);
          
          // This does not work, the dto triggers the service but is empty
          var response = await this.Client.PostAsync(request).ConfigureAwait(false);
    

And so on…the service methods looks like this:

    public async Task<object> Post(CreateServicePrice request)
    {
        // Failure here, the request have no values!
        ...
    }

    public async Task<object> Get(CreateServicePrice2 request)
    {
        // Success here! the request have values!
        ...
    }

The dtos looks like this

[Route("/services/prices/",
    Verbs = "POST",
    Summary = "Create a price",
    Notes =
        "Create a new price on the service"
    )]
    public class CreateServicePrice  : IReturn<ServicePriceResponse> //ICompany
    {
        [ApiMember(
          Description =
              "The company id, if empty will use the company id for the user you are logged in with.",
          IsRequired = false)]
        public Guid? CompanyId { get; set; }

        [ApiMember(
            Description = "The service id")]
        public int ServiceId { get; set; }

        [ApiMember(
           Description = "The price")]
        public double Price { get; set; }

        [ApiMember(
            Description = "The price currency")]
        public string CurrencyId { get; set; }

        [ApiMember(
            Description = "The price VAT in percent")]
        public decimal VAT { get; set; }

        [ApiMember(
            Description = "The price category if price has a category")]
        public string Category { get; set; }

        [ApiMember(
          Description = "The valid from date for the price."
          )]
        public DateTime From { get; set; }

        [ApiMember(
          Description = "The valid to date for the price."
          )]
        public DateTime To { get; set; }

        [ApiMember(
        Description = "If the price is only valid for specific days in week add a comma separated list of which days this day price belongs to, 1 = Monday .. 7 = Sunday. All old days connected will be removed on update."
        )]
        public int[] DaysOfWeek { get; set; }

        [ApiMember(
        Description = "If the price is only valid for specific days in week add a comma separated list of which days this day price belongs to, 1 = Monday .. 7 = Sunday. All old days connected will be removed on update."
        )]
        public TimeSpan? FromTime { get; set; } = new TimeSpan(0, 0, 0);

        [ApiMember(
      Description = "If the price is only valid for a specific time span during a time of day enter the FromTime and ToTime parameters."
      )]
        public TimeSpan? ToTime { get; set; } = new TimeSpan(23, 59, 59);
    }

     [Route("/services/prices2/",
    Verbs = "GET",
    Summary = "Create a price",
    Notes =
        "Create a new price on the service"
    )]
    public class CreateServicePrice2  : IReturn<ServicePriceResponse> //ICompany
    {
        [ApiMember(
          Description =
              "The company id, if empty will use the company id for the user you are logged in with.",
          IsRequired = false)]
        public Guid? CompanyId { get; set; }

        [ApiMember(
            Description = "The service id")]
        public int ServiceId { get; set; }

        [ApiMember(
           Description = "The price")]
        public double Price { get; set; }

        [ApiMember(
            Description = "The price currency")]
        public string CurrencyId { get; set; }

        [ApiMember(
            Description = "The price VAT in percent")]
        public decimal VAT { get; set; }

        [ApiMember(
            Description = "The price category if price has a category")]
        public string Category { get; set; }

        [ApiMember(
          Description = "The valid from date for the price."
          )]
        public DateTime From { get; set; }

        [ApiMember(
          Description = "The valid to date for the price."
          )]
        public DateTime To { get; set; }

        [ApiMember(
        Description = "If the price is only valid for specific days in week add a comma separated list of which days this day price belongs to, 1 = Monday .. 7 = Sunday. All old days connected will be removed on update."
        )]
        public int[] DaysOfWeek { get; set; }

        [ApiMember(
        Description = "If the price is only valid for specific days in week add a comma separated list of which days this day price belongs to, 1 = Monday .. 7 = Sunday. All old days connected will be removed on update."
        )]
        public TimeSpan? FromTime { get; set; } = new TimeSpan(0, 0, 0);

        [ApiMember(
      Description = "If the price is only valid for a specific time span during a time of day enter the FromTime and ToTime parameters."
      )]
        public TimeSpan? ToTime { get; set; } = new TimeSpan(23, 59, 59);
    }

POST’s sends the request in a serialized body whilst GET’s send it via queryString.

So it’s likely a serialization error, enable strict mode to throw on deserialization errors:

Env.StrictMode = true;

Also your Routes should not have trailing slashes

Enabling strict mode did not make any difference and no error was thrown. The apphost is super simple:

  public override void Configure(Container container)
    {
        Env.StrictMode = true;
        this.Plugins.Add(new OpenApiFeature());
        this.SetConfig(new HostConfig
        {
            DefaultRedirectPath = "/metadata",
            DebugMode = this.AppSettings.Get(nameof(HostConfig.DebugMode), false),
            MapExceptionToStatusCode = {
                { typeof(BokaMeraEntityNotFoundException), (int)HttpStatusCode.NotFound }
            },
        });
    }

It does however run in an asp.net core WebApplicationFactory for the tests if that has any effect.
If I take the request dto and run request.ToJson() in the debugger and then copies the content and posts it to the service when running live using PostMan/Insomnia then the request dto is received and deserialized correctly.

The client is initialized like this in the webapplicationfactory:

        var client = new JsonHttpClient(this.ClientOptions.BaseAddress.ToString());
        client.HttpClient = this.CreateClient();

The createclient method is from WebApplicationFactory and is needed for the tests to be able to work at all. I suspect thee is something special going on in the client from WebApplicationFactory that might cause the issue.

To follow up even more, I added a request filter on the client and checked the content there. No problem serializing it on that side (I removed the lines afterwards to not mess with the async stream)

        var client = new JsonHttpClient(this.ClientOptions.BaseAddress.ToString());
        client.HttpClient = this.CreateClient();

        client.RequestFilter = req =>
        {
            testOutputHelper.WriteLine($"REQ: {req.Method} {req.RequestUri}");
            
            // Added for debug only...this gets the correct content
            var content = req.Content.ReadAsStringAsync().Result;
            
            /// No problems deserializing here, all values exist
            var dto = content.FromJson<CreateServicePrice>();
        };

also, if I add a global request filter on the server side to check whats happening like this:

        this.GlobalRequestFilters.Add((request, response, arg3) =>
        {
            Console.WriteLine(request.Dto.Dump());
        });

I get an empty dto here. Inspecting the request reveals to me that all meta information is there and is correct (headers, path etc). But the content stream is of zero length for some reason (and hence the dto gets blank).

I am using the asynchronous io in the WebApplicaitonFactory but disabling it had no effect either:

        this.Server.AllowSynchronousIO = true; // False did not work either

Its almost as if something is reading the stream in an earlier stage and disposing it.

Can’t tell what the issue is from here, you can try enabling debug logging to see if any issues are being logged. Other than a serialization exception, maybe you have something in your environment that’s re-reading the forward-only request body in which case you can enable Request Buffering.

Otherwise if you can put together a stand-alone repro I can take a look.

Setting buffered request streams helped, thank you. Something in test is apparently touching the streams. I have disabled everything in our apphost but it did not help, so I guess there is some part of our startup.cs file that uses a configuration that reads the streams.

The workaround with buffered streams active in test mode is ok for us for now.

Thank you for your assistance.