refit icon indicating copy to clipboard operation
refit copied to clipboard

Responses with a 204 status code throw when deserializing content.

Open jawa-gh opened this issue 3 years ago • 29 comments

When using Refit with System.Text.Json and the SystemTextJsonContentSerializer the following error comes up: Refit.ApiException: An error occured deserializing the response. ---> System.Text.Json.JsonException: The input does not contain any JSON tokens. Expected the input to start with a valid JSON token, when isFinalBlock is true. Path: $ | LineNumber: 0 | BytePositionInLine: 0. ---> System.Text.Json.JsonReaderException: The input does not contain any JSON tokens. Expected the input to start with a valid JSON token, when isFinalBlock is true. LineNumber: 0 | BytePositionInLine: 0. at System.Text.Json.ThrowHelper.ThrowJsonReaderException(Utf8JsonReader& json, ExceptionResource resource, Byte nextByte, ReadOnlySpan1 bytes) at System.Text.Json.Utf8JsonReader.Read() at System.Text.Json.Serialization.JsonConverter1.ReadCore(Utf8JsonReader& reader, JsonSerializerOptions options, ReadStack& state)

This happens when the API returns StatusCodeResult (eg. return NoContent(), return StatusCode(204). When returning StatusCodeResult the HttpContentHeaders in HttpContent are empty and the MediaTypeHeaderValue is null and the content itself has no content to deserialize and an exception in System.Text.Json is thrown. image

My Suggestion is to change the FromHttpContentAsync method to: public async Task<T?> FromHttpContentAsync<T>(HttpContent content, CancellationToken cancellationToken = default) { if(content.Headers.ContentType != null && content.Headers.ContentType.MediaType == "application/json") { var item = await content.ReadFromJsonAsync<T>(jsonSerializerOptions, cancellationToken).ConfigureAwait(false); return item; } return default; }

jawa-gh avatar Mar 19 '21 09:03 jawa-gh

We're encountering the same issue on 204 requests. Is there a (temporary) workaround possible?

LaurensAdema avatar Mar 25 '21 08:03 LaurensAdema

We're encountering the same issue on 204 requests. Is there a (temporary) workaround possible?

What I did as workaround so far: I created a 'CustomContentSerializer', implement the IHttpContentSerializer and in the FromHttpContentAsync method I added the check. Here is a sample of how you could implement a Serializer on your own (the implementation is from Refit, I just added the additional check):

`

 public class CustomContentSerializer : IHttpContentSerializer 

    private readonly JsonSerializerOptions jsonSerializerOptions;

    public CustomContentSerializer() : this(new JsonSerializerOptions(JsonSerializerDefaults.Web))
    {
        jsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
        jsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve;
        jsonSerializerOptions.WriteIndented = true;
        //jsonSerializerOptions.IgnoreNullValues = true;
        jsonSerializerOptions.PropertyNameCaseInsensitive = true;
        jsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
        jsonSerializerOptions.Converters.Add(new ObjectToInferredTypesConverter());
        jsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
    }

    public CustomContentSerializer(JsonSerializerOptions jsonSerializerOptions)
    {
        this.jsonSerializerOptions = jsonSerializerOptions;
    }

    public async Task<T?> FromHttpContentAsync<T>(HttpContent content, CancellationToken cancellationToken = default)
    {
      // this needs to be added
        if(content.Headers.ContentType != null && content.Headers.ContentType.MediaType == "application/json")
        {
            var item = await content.ReadFromJsonAsync<T>(jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
            return item;
        }

        return default;
    }

    public string? GetFieldNameForProperty(PropertyInfo propertyInfo)
    {
        if(propertyInfo is null)
        {
            throw new ArgumentNullException(nameof(propertyInfo));
        }

        return propertyInfo.GetCustomAttributes<JsonPropertyNameAttribute>(true)
                   .Select(a => a.Name)
                   .FirstOrDefault();
    }

    public HttpContent ToHttpContent<T>(T item)
    {
        var content = JsonContent.Create(item, options: jsonSerializerOptions);
        return content;
    }

    // From https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to?pivots=dotnet-5-0#deserialize-inferred-types-to-object-properties
    public class ObjectToInferredTypesConverter
       : JsonConverter<object>
    {
        public override object? Read(
          ref Utf8JsonReader reader,
          Type typeToConvert,
          JsonSerializerOptions options) => reader.TokenType switch
          {
              JsonTokenType.True => true,
              JsonTokenType.False => false,
              JsonTokenType.Number when reader.TryGetInt64(out var l) => l,
              JsonTokenType.Number => reader.GetDouble(),
              JsonTokenType.String when reader.TryGetDateTime(out var datetime) => datetime,
              JsonTokenType.String => reader.GetString(),
              _ => JsonDocument.ParseValue(ref reader).RootElement.Clone()
          };

        public override void Write(
            Utf8JsonWriter writer,
            object objectToWrite,
            JsonSerializerOptions options) =>
            JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options);
    }
}

`

And in Startup.cs: services.AddHttpClient("CoilDNATenantClient", c => c.BaseAddress = new Uri(Configuration["Endpoints:Tenant"])) .AddTypedClient(c => RestService.For<ITenantClient>(c, new RefitSettings { //https://github.com/reactiveui/refit/blob/main/Refit/SystemTextJsonContentSerializer.cs //ContentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web) //{ // IgnoreNullValues = true, // ReferenceHandler = ReferenceHandler.Preserve, // WriteIndented = true //}) ContentSerializer = new CustomContentSerializer() }));

jawa-gh avatar Mar 25 '21 08:03 jawa-gh

Thanks for the help and quick response!

LaurensAdema avatar Mar 25 '21 08:03 LaurensAdema

Unfortunately this fix is more complicated than here. We can't return null or default for non-nullable return types. A 204 returns null, effectively. That means we have to detect if the return type is non-nullable or nullable so we can either return null or throw.

Another workaround is to use ApiResponse<> or HttpResponseMessage return types where you can check the status before deserializing the object.

clairernovotny avatar Jun 17 '21 11:06 clairernovotny

I have find a way to correct this problem by detecting on my custom httpClientHandler if the content received was null or a string.Empty, on those cases i transformed it to a JSON null object, that would be a string "null", this came as a correction because of .NET 5 upgrade, that it wont give you a null content never, just an empty string.

if (string.IsNullOrEmpty(response.Content?.ReadAsStringAsync(ct).Result)) { response.Content = new StringContent( "null", Encoding.UTF8, MediaTypeNames.Application.Json); }

victorsimas avatar Jul 11 '21 13:07 victorsimas

Thank you @jagnarock your workaround was helpful

tjmcnaboe avatar Jul 19 '21 22:07 tjmcnaboe

Unfortunately this fix is more complicated than here. We can't return null or default for non-nullable return types. A 204 returns null, effectively. That means we have to detect if the return type is non-nullable or nullable so we can either return null or throw.

Another workaround is to use ApiResponse<> or HttpResponseMessage return types where you can check the status before deserializing the object.

Using ApiResponse<> doesn't help much because it still has Error set in the case of 204 and this becomes cumbersome to distinct real errors from 204. Also regarding the suggested solution, it would work fine for ApiResponse<> as it has its Content as nullable, could it be used at least for this case?

DsAekb avatar Sep 28 '21 13:09 DsAekb

I agree that Refit should work as previous version when the Newtonsoft was used.

The only problem is, that Refit tries to deserialize content when there is no content and that is wrong. Returning null or default seems the best result.

hubocan avatar Sep 28 '21 14:09 hubocan

We are seeing this issue only on our Azure environment. It works as expected when we try to debug the issue on our development pc's.

Refit.SystemTextJsonContentSerializer is thowing a JsonReaderException when getting a 204 no content from the service. image

But on my local development machine it works just fine and i get a null value as expected.

rndfm avatar Oct 05 '21 09:10 rndfm

Do we have any update related to this?

We recently migrated our project to .NET Core 6 and Refit 6.1.15 and are facing the same issue.

mbikodusic avatar Jan 11 '22 13:01 mbikodusic

We also hit the same issue when we updated our library.

Any ETA when this will be fixed? It's a showstopper for us.

R4cOOn avatar Apr 21 '22 08:04 R4cOOn

Not sure why this is not fixed yet - a possible solution was already proposed in https://github.com/reactiveui/refit/pull/1190 but was closed for reasons I don't understand. The solution in there seems viable to me (also OPs), there should be no situation in which you can't return default on a 204 because the content serializers' return type is T?. Or I'm missing something?

Either way whether it's a good fix or not, it is currently a clear breaking change that you'll hit if you potentially return null from your API methods.

LarsWesselius avatar May 20 '22 16:05 LarsWesselius

Also interested in a fix for this. Would expect Task<T?> nullable to work for 204. 204 would return null and 200 return T.

wrkntwrkn avatar Jun 17 '22 11:06 wrkntwrkn

Are there any updates regarding this issue?

MaksymShuldiner avatar Jun 30 '22 14:06 MaksymShuldiner

This is causing a lot of problems in our systems. We are integrating to a service that uses 204 no content for a lot of endpoints.

rndfm avatar Sep 05 '22 07:09 rndfm

Workaround when an API send 204 no content.

Add a try catch around the refit call. try { // Refit call goes here. } catch (Refit.ApiException ex) when (ex.StatusCode == HttpStatusCode.NoContent) { // Do something. Ex return empty/null/empty array }

rndfm avatar Sep 08 '22 08:09 rndfm

It seems like that the issue still exists in Version 6.3.2. To solve, I think the method From HttpContentAsync should lool like the following:

`

 public async Task<T?> FromHttpContentAsync<T>(HttpContent content, CancellationToken cancellationToken = default)
{
       return content.Headers.ContentType?.MediaType == "application/json"
             ? await content.ReadFromJsonAsync<T>(jsonSerializerOptions, cancellationToken).ConfigureAwait(false)
              : default;
}

`

When deserializing NoContent or HTTPStatusCodeResult 204 the HttpContentHeaders in HttpContent are empty and the MediaTypeHeaderValue is null. The content itself has no content to deserialize and an exception in System.Text.Json is thrown.

jawa-gh avatar Mar 20 '23 13:03 jawa-gh

any progress on this issue? this is quite a blocker for 204 responses

yohny avatar Apr 06 '23 13:04 yohny

Yeah I keep having to initialize clients with serializers working around this issue; it's pretty annoying.

Thorarin avatar Apr 13 '23 08:04 Thorarin

fix the auto 204 conversion, there's an easy workaround: You can remove the HttpNoContentOutputFormatter in Startup.cs and your ConfigureServices()method:

services.AddControllers(opt =>  // or AddMvc()
{
    // remove formatter that turns nulls into 204 - No Content responses
    // this formatter breaks Angular's Http response JSON parsing
    opt.OutputFormatters.RemoveType<HttpNoContentOutputFormatter>();
})

or

builder.Services.AddControllers(opt =>  // or AddMvc()
{
    // remove formatter that turns nulls into 204 - No Content responses
    // this formatter breaks Angular's Http response JSON parsing
    opt.OutputFormatters.RemoveType<HttpNoContentOutputFormatter>();
});

itsvse avatar Aug 19 '23 07:08 itsvse

That "fixes" it on the wrong side. The HTTP spec says 200 is supposed to have content. If there is no content, the response code should be 204.

Thorarin avatar Aug 19 '23 07:08 Thorarin

Came across this issue today. Does anyone know if this will be addressed?

dan5602 avatar Oct 03 '23 16:10 dan5602

Refit version 7.0.0 (at the date, last release 4 months ago) still the same issue :( Notice that .net 8 has been released yesterday.

MarcoMedrano avatar Nov 16 '23 03:11 MarcoMedrano

use ApiResponse<> instead of only class in refit

tohidhaghighy avatar Nov 20 '23 05:11 tohidhaghighy

Hi, is there any update on this issue ?

DominikZublasing avatar Feb 01 '24 16:02 DominikZublasing

Hi, is there any update on this issue ?

Good question. We really would appreciate hearing some news from the Refit Team according this issue

jawa-gh avatar Feb 02 '24 08:02 jawa-gh

@clairernovotny could you elaborate on why this is more complex? I totally understand the complexities about staying backwards compatible, so maybe the only solution is to give people hooks into the client to fix the problem in their specific situation? Or is there another problem here I am missing?

eriksteinebach avatar Apr 09 '24 19:04 eriksteinebach

With @jagnarock 's solution I was a little worried about the maintainability long term, so I am trying out the following solution which looks like it works for us:

public class CustomSystemTextJsonContentSerializer : IHttpContentSerializer
{
    private SystemTextJsonContentSerializer serializer;

    public CustomSystemTextJsonContentSerializer()
    {
        serializer = new SystemTextJsonContentSerializer();
    }

    public CustomSystemTextJsonContentSerializer(JsonSerializerOptions jsonSerializerOptions)
    {
        serializer = new SystemTextJsonContentSerializer(jsonSerializerOptions);
    }

    public Task<T?> FromHttpContentAsync<T>(HttpContent content, CancellationToken cancellationToken = default)
    {
        if (content.Headers.ContentType != null && content.Headers.ContentType.MediaType == "application/json")
        {
            return serializer.FromHttpContentAsync<T>(content, cancellationToken); 
        }
        return Task.FromResult<T?>(default);
    }

    public string? GetFieldNameForProperty(PropertyInfo propertyInfo)
    {
        return serializer.GetFieldNameForProperty(propertyInfo);
    }

    public HttpContent ToHttpContent<T>(T item)
    {
        return serializer.ToHttpContent<T>(item);
    }
}

eriksteinebach avatar Apr 09 '24 19:04 eriksteinebach