Cannot get the value of a token type ‘Number’ as a string con System.Text.Json
Introducción
Una de las tareas que debemos empezar a plantear dentro de nuestros desarrollos de .NET Core, sino lo hemos hecho ya (y dentro de poco con .NET 5 y posteriores), es la migración de Newtonsoft.Json a System.Text.Json.
Dentro de las tareas de migración, es posible que nos encontremos con situaciones especiales que nos den algún que otro quebradero de cabeza.
Situaciones que no esperábamos y que debemos sortear y resolver.
En esta entrada, voy a exponer una de esas situaciones con la que me he encontrado, y cómo la he resuelto.
Se trata de partir de la base de un JSON que deserializaba correctamente con Newtonsoft, pero que al deserializarlo con System.Text.Json, obtengo un error:
Cannot get the value of a token type ‘Number’ as a string
Poniendo en contexto el problema
Un JSON debe estar bien formado, algo que doy por hecho.
Un JSON debe tener un formato acorde con la entidad que lo va a consumir, algo que no siempre es así, ya que puede ocurrir que el JSON tenga unas propiedades diferentes a las que se espera en la entidad que lo va a deserializar, u otras circunstancias.
Así que imaginemos que partimos de una entidad Student que tiene dos propiedades.
Id => string
Name => string
public class Student { public string Id { get; set; } public string Name { get; set; } }
Lo natural es recibir un JSON cuyas propiedades cuadren perfectamente con las propiedades de la entidad, como por ejemplo:
{"Id": "123", "Name": "John"}
Ahora bien, como digo, no siempre es así.
De hecho, pensemos en que lo que recibimos es realmente algo como:
{"studentId": "123", "Name": "John"}
Nuestra clase en este caso, debería tener en consideración que al deserializar el JSON, el valor de studentId debería ser asignado a Id.
Por lo que en este caso, nuestra clase debería tener el siguiente aspecto para Newtonsoft:
public class Student { [Newtonsoft.Json.JsonProperty(PropertyName = "studentId")] public string Id { get; set; } public string Name { get; set; } }
De esta manera, le estaremos indicando a Newtonsoft, que la propiedad studentId del JSON debe ser mapeada a Id.
Si deserializamos de acuerdo a este ejemplo, obtendremos los datos como queríamos:
var studentJson = "{\"studentId\":\"123\", \"Name\":\"Jorge\"}"; var studentWithNewtonSoft = Newtonsoft.Json.JsonConvert.DeserializeObject<Student>(studentJson);
Y lo mismo sucede en el caso de usar System.Text.Json en lugar de Newtonsoft.
Nuestra clase deberá tener en este caso el siguiente aspecto:
public class Student { [System.Text.Json.Serialization.JsonPropertyName("studentId")] [Newtonsoft.Json.JsonProperty(PropertyName = "studentId")] public string Id { get; set; } public string Name { get; set; } }
Y la forma en la que deserializaremos la información será de la siguiente forma:
var studentJson = "{\"studentId\":\"123\", \"Name\":\"Jorge\"}"; var studentWithNewtonSoft = Newtonsoft.Json.JsonConvert.DeserializeObject<Student>(studentJson); var studentWithNetCore = System.Text.Json.JsonSerializer.Deserialize<Student>(studentJson);
Hasta aquí todo correcto.
Ahora bien, vamos a suponer que el JSON no tiene porqué asumir ni tener en cuenta que el valor de studentId sea realmente un valor string, y lo que nos viene es un int.
Sé que dirás que no es lo esperado, pero a veces, puede pasar.
¿Que sucedería en este caso?.
Lo que por defecto Newtonsoft se traga, no se lo traga System.Text.Json
Partiremos entonces del siguiente JSON:
{"studentId": 123, "Name": "John"}
¿Qué sucederá en el ejemplo anterior?.
Si ejecutamos este código:
var studentJson = "{\"studentId\": 123, \"Name\":\"Jorge\"}"; var studentWithNewtonSoft = Newtonsoft.Json.JsonConvert.DeserializeObject<Student>(studentJson); var studentWithNetCore = System.Text.Json.JsonSerializer.Deserialize<Student>(studentJson);
Se producirá un error con el siguiente mensaje:
System.Text.Json.JsonException: 'The JSON value could not be converted to System.String. Path: $.studentId | LineNumber: 0 | BytePositionInLine: 17.' InvalidOperationException: Cannot get the value of a token type 'Number' as a string.
Así que lo que inicialmente y por defecto, se traga Newtonsoft, System.Text.Json devuelve una excepción.
El motivo es que está esperando un dato de tipo string y por el contrario, en el JSON viene un int.
¿Cómo resolver este problema?.
Resolviendo el problema
System.Text.Json es muy sensible y restrictivo.
Muchos desarrolladores se quejan en Internet de él, ya que están habituados a la flexibilidad (a veces anarquía) que puede llegar a ofrece Newtonsoft, sin embargo, estoy de acuerdo con el «purismo» de System.Text.Json.
De hecho, si somos estrictos, el JSON que indico no debería ser un valor válido de entrada/formato para nuestro sistema.
Pero evitando entrar ahora en esa discusión, System.Text.Json nos ofrece alternativas para revertir el problema.
Una de las particularidades de System.Text.Json es la posibilidad de extender la propia librería de Microsoft.
En nuestro caso, resolveremos el problema a través de una conversión.
Podemos crear nuestro Converter heredando de JsonConverter y personalizando la conversión.
Un sencillo ejemplo de conversión para el problema planteado, podría ser la siguiente clase de conversión para System.Text.Json:
public class LongToStringJsonConverter : JsonConverter<string> { public LongToStringJsonConverter() { } public override string Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.Number && type == typeof(String)) return reader.GetString(); var span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; if (Utf8Parser.TryParse(span, out long number, out var bytesConsumed) && span.Length == bytesConsumed) return number.ToString(); var data = reader.GetString(); throw new InvalidOperationException($"'{data}' is not a correct expected value!") { Source = "LongToStringJsonConverter" }; } public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) { writer.WriteStringValue(value.ToString()); } }
En este caso, nuestro código utilizando el conversor y resolviendo el problema anterior quedaría de la siguiente forma:
var options = new System.Text.Json.JsonSerializerOptions(); options.Converters.Add(new LongToStringJsonConverter()); var studentJson = "{\"studentId\": 123, \"Name\":\"Jorge\"}"; var studentWithNewtonSoft = Newtonsoft.Json.JsonConvert.DeserializeObject<Student>(studentJson); var studentWithNetCore = System.Text.Json.JsonSerializer.Deserialize<Student>(studentJson, options);
Si ejecutamos nuestro código, veremos que ahora sí, System.Text.Json trata correctamente el dato convirtiéndolo en string y evitando el problema que estábamos obteniendo.
Mejorando el rendimiento
Ahora bien, dentro de System.Text.Json y del conversor que hemos preparado, tenemos la posibilidad de mejorar aún más el problema de deserialización.
El conversor se va a lanzar por todas y cada una de nuestras propiedades dentro del JSON.
Esto puede estar bien, pero podría no ser necesario.
De hecho, supongamos que sabemos que sólo el campo studentId es el «problemático».
¿Tiene sentido que el conversor se lance por todas las propiedades, o sólo por las propiedades que sabemos que pueden llegarnos en un formato diferente?.
Pues bien, eso es posible también.
De hecho, nuestra entidad tendría en este caso un aspecto similar al siguiente:
public class Student { [System.Text.Json.Serialization.JsonPropertyName("studentId")] [System.Text.Json.Serialization.JsonConverter(typeof(LongToStringJsonConverter))] [Newtonsoft.Json.JsonProperty(PropertyName = "studentId")] public string Id { get; set; } public string Name { get; set; } }
En este caso, el código de nuestro deserializador quedaría de la siguiente forma:
var studentJson = "{\"studentId\": 123, \"Name\":\"Jorge\"}"; var studentWithNewtonSoft = Newtonsoft.Json.JsonConvert.DeserializeObject<Student>(studentJson); var studentWithNetCore = System.Text.Json.JsonSerializer.Deserialize<Student>(studentJson);
Pero el conversor, sólo se lanzaría para la propiedad studentId que es la que sabemos que nos da el problema.
Otras circunstancias
En este ejemplo, parto de la premisa que studentId es string o int.
En el caso de que viniera en el JSON un valor como 1.23, tal y como está preparado el conversor, daría una excepción.
En estos casos, deberíamos «fortalecer» el conversor para asumir ese tipo de valores o bien, deberíamos entender que el valor que viene en el JSON no es válido, algo que en este caso y para mí sería lo correcto, ya que 1.23 debería venir como «1.23».
Pero aquí entraríamos más en una discusión funcional.
Lo que es claro es que el conversor en ese caso debería ser modificado para que cubriese esta situación.
Happy Coding!