24

I am using .Net Core 3.0 and have the following string which I need to deserialize with Newtonsoft.Json:

{
    "userId": null,
    "accessToken": null,
    "refreshToken": null,
    "sessionId": null,
    "cookieExpireTimeSpan": {
        "ticks": 0,
        "days": 0,
        "hours": 0,
        "milliseconds": 0,
        "minutes": 0,
        "seconds": 0,
        "totalDays": 0,
        "totalHours": 0,
        "totalMilliseconds": 0,
        "totalMinutes": 0,
        "totalSeconds": 0
    },
    "claims": null,
    "success": false,
    "errors": [
        {
            "code": "Forbidden",
            "description": "Invalid username unknown!"
        }
    ]
}

and bump into the following error:

   Newtonsoft.Json.JsonSerializationException : Cannot deserialize the current JSON object (e.g. {"name":"value"}) into type 'System.TimeSpan' because the type requires a JSON primitive value (e.g. string, number, boolean, null) to deserialize correctly.
To fix this error either change the JSON to a JSON primitive value (e.g. string, number, boolean, null) or change the deserialized type so that it is a normal .NET type (e.g. not a primitive type like integer, not a collection type like an array or List<T>) that can be deserialized from a JSON object. JsonObjectAttribute can also be added to the type to force it to deserialize from a JSON object.
Path 'cookieExpireTimeSpan.ticks', line 1, position 103.

The error string actually happens when reading the content of HttpResponseMessage:

var httpResponse = await _client.PostAsync("/api/auth/login", new StringContent(JsonConvert.SerializeObject(new API.Models.Request.LoginRequest()), Encoding.UTF8, "application/json"));
var stringResponse = await httpResponse.Content.ReadAsStringAsync();

The server controller method returns:

return new JsonResult(result) { StatusCode = whatever; };
Askolein
  • 3,250
  • 3
  • 28
  • 40
Kok How Teh
  • 3,298
  • 6
  • 47
  • 85
  • There's no Timespan value in that JSON. A `TimeSpan` is a time value, eg `12:00` – Panagiotis Kanavos Oct 08 '19 at 09:37
  • That's the response I get from a WebAPI. The only "valid" value in that object is "success: false". The other values are just "default" values. – Kok How Teh Oct 08 '19 at 09:38
  • 1
    In that case ask the Web API's author to fix it the bug. JSON.NET won't generate such a string. – Panagiotis Kanavos Oct 08 '19 at 09:39
  • It's my web api. I didn't have this error when i was using .Net Core 2.2. Only see this after I have upgraded to .Net Core 3.0 recently and all my integration tests fail due to that. What do I miss? – Kok How Teh Oct 08 '19 at 09:40
  • I suspect that the WebApi has changed in the time when you upgraded to 3.0 – tymtam Oct 08 '19 at 09:59
  • Could you post an example response without the authentication error? – tymtam Oct 08 '19 at 10:00
  • In the success code execution path, the login controller actually sets the cookieExpireTimeSpan. So the serialized string is "cookieExpireTimeSpan": "00:00:00". I am lost now as to where goes wrong. Serialization or Deserialization? I can't fix what I don't know where goes wrong. – Kok How Teh Oct 08 '19 at 10:04

3 Answers3

74

The REST API service shouldn't produce such a JSON string. I'd bet that previous versions returned 00:0:00 instead of all the properties of a TimeSpan object.

The reason for this is that .NET Core 3.0 replaced JSON.NET with a new, bult-in JSON serializer, System.Text.Json. This serializer doesn't support TimeSpan. The new serializer is faster, doesn't allocate in most cases, but doesn't cover all the cases JSON.NET did.

In any case, there's no standard way to represent dates or periods in JSON. Even the ISO8601 format is a convention, not part of the standard itself. JSON.NET uses a readable format (23:00:00), but ISO8601's duration format would look like P23DT23H (23 days, 23 hours) or P4Y (4 years).

One solution is to go back to JSON.NET. The steps are described in the docs:

services.AddMvc()
    .AddNewtonsoftJson();

Another option is to use a custom converter for that type, eg :

public class TimeSpanToStringConverter : JsonConverter<TimeSpan>
{
    public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var value=reader.GetString();
        return TimeSpan.Parse(value);
    }

    public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString());
    }
}

And register it in Startup.ConfigureServices with AddJsonOptions, eg :

services.AddControllers()                    
        .AddJsonOptions(options=>
            options.JsonSerializerOptions.Converters.Add(new TimeSpanToStringConverter()));

Panagiotis Kanavos
  • 120,703
  • 13
  • 188
  • 236
  • How not to "go back" but fix the issues? Where / what to fix? – Kok How Teh Oct 08 '19 at 10:05
  • 3
    @KokHowTeh added how to create and register a custom converter – Panagiotis Kanavos Oct 08 '19 at 10:23
  • the error actually appens at the client side when reading the HttpReponseMessage. I have updated my original post. – Kok How Teh Oct 08 '19 at 14:02
  • 2
    @KokHowTeh the problem is caused by the *server* serializing a TimeSpan as if it were an object instead of a duration. You need to fix the server, not the client. – Panagiotis Kanavos Oct 08 '19 at 14:07
  • Is this a bug that I should report to the .net core git as an issue for them to fix? – Kok How Teh Oct 08 '19 at 14:12
  • I already posted a link to the Github issue that explains why this happened and that there are no plans to change this right now. You need to add the custom converter to your application or go back to Json.NET – Panagiotis Kanavos Oct 08 '19 at 14:20
  • 4
    @KokHowTeh in fact, I even trolled Immo Landwerth on Twitter about it – Panagiotis Kanavos Oct 08 '19 at 14:20
  • That works. Hope they fix them in the near future. Thanks! – Kok How Teh Oct 08 '19 at 14:27
  • 7
    In `TimeSpanToStringConverter` I recommend parsing and formatting in the invariant culture, i.e. `TimeSpan.Parse(value, CultureInfo.InvariantCulture)` and `((TimeSpan)value).ToString(null, CultureInfo.InvariantCulture)` – dbc Oct 08 '19 at 15:20
  • How did this pass review? :O This makes apps silently fail/return wrong values. – AyCe Jan 30 '20 at 11:55
  • 2
    `TimeSpanJsonConverter` seems like a better name than `TimeSpanToStringConverter`. – Delphi.Boy Jan 21 '21 at 06:55
  • +1 for great answer and background detail, thanks! I'm just adding this here for those like me hitting both this and the same issue for System.Version. It seems this exact issue exists where no VersionConverter exists and according to [this](https://github.com/dotnet/runtime/issues/41368) it has already been added/fixed for .NET 6.0. For 5.0 you'll have to do basically the same as in this answer, but for System.Version. – DeVil Feb 28 '22 at 02:05
6

My solution is to use custom converter, but with explicitly specified standard non culture-sensitive TimeSpan format specifier .

public class JsonTimeSpanConverter : JsonConverter<TimeSpan>
{
    public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        return TimeSpan.ParseExact(reader.GetString(), "c", CultureInfo.InvariantCulture);
    }

    public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value.ToString("c", CultureInfo.InvariantCulture));
    }
}

Then register it in the Startup for the HostBuilder:

public class Startup
{
    public IServiceProvider ConfigureServices(IServiceCollection services)
    {
        ...
        services
            ...
            .AddJsonOptions(opts =>
            {
                opts.JsonSerializerOptions.Converters.Add(new JsonTimeSpanConverter());                    
            });
        ...
    }
}
kalitsov
  • 1,319
  • 3
  • 20
  • 33
  • 2
    When using the `"c"` format specifier, you don't need to specify `CultureInfo.InvariantCulture` (the format is already culture-invariant see [docs](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-timespan-format-strings#the-constant-c-format-specifier)). In fact `"c"` is the default value used by `ToString()` so you can simply write `writer.WriteStringValue(value.ToString());` – Eric Mutta Apr 29 '21 at 13:45
0

TimeSpanConverter is available in .NET 6.0. So TimeSpan serialization/deserialization will work without custom converters out of the box.

Issue: https://github.com/dotnet/runtime/issues/29932

Implementation: https://github.com/dotnet/runtime/pull/54186

Igor
  • 349
  • 1
  • 6
  • How about an example of it working? – ΩmegaMan Mar 24 '22 at 13:50
  • I don't think this is necessary for obvious code. But I agree with you that we can't believe everything that is written on the Internet and need to check everything)) https://dotnetfiddle.net/13kIUo – Igor Mar 24 '22 at 14:20