Esta pequeña serie sobre scripts en Service Layer consta de tres partes:
La parte realmente difícil de todo el tema de los scripts ha sido combinarlo con el cliente OData. Confieso que, entre unas cosas y otras, se me fueron 50 horas de trabajo hasta conseguir algo funcional. La documentación de Microsoft de un tiempo a esta parte no es ninguna maravilla y con OData es especialmente parca. Intentar salirme de lo básico me ha dado muchos dolores de cabeza.
Resumiendo, que tampoco quiero aburrir, mi idea original era utilizar el context y las entidades ya creadas y hacer algo como:
Dim cosa As New BodyOperationParameter("Recibo", oRecibo) Dim cosa2 As New BodyOperationParameter("Emision", oEmision) Dim responses = context.Execute(url, "POST", {cosa, cosa2})
Pero eso no me funcionó. El serializado de los objetos dejaba demasiados valores basura (colecciones y campos vacíos), que provocaban errores en la creación del documento en SAP. Hice entonces clases personalizadas para enviar, en lugar del Document, pero entonces el error llegaba en la respuesta y no conseguí encontrar el punto donde reemplazar su tratamiento por uno personalizado.
El siguiente intento, que ya fue el bueno, supuso usar la clase HttpClient, y configurar manualmente la serialización y deserialización de la información. Es un tratamiento más genérico que lo puedo extender en un futuro, no sólo para otros scripts, sino como reemplazo del cliente OData (que no es precisamente pequeño: el mapeo para la base de datos de la empresa supone medio millón de líneas de código, casi 6MB ya compilada, que tengo que mantener en una solución aparte, porque trabajar con ella me mata Visual Studio).
Creé una clase sencilla con las dos propiedades que espera el script, Recibo y Emisión, ambas del tipo Document definidos en el cliente OData:
public class ReciboProduccion { public Document Recibo { get; set; } public Document Emision { get; set; } }
Con idea de añadir a la clase ServiceLayer (el context) un método como éste:
public async Task<OperationResponse<ReciboProduccion>> CrearReciboAsync(ReciboProduccion recibo) { Uri url = new Uri(this.BaseUri, URL_RECIBO); Operador<ReciboProduccion, ReciboProduccion> operador = new Operador<ReciboProduccion, ReciboProduccion>(HttpClient, url, this.SessionCookie); return await operador.PostAsync(recibo); }
Para gestionar el HttpClient, añadí estas propiedades al context:
public static HttpClientHandler CreateHandler() { return new HttpClientHandler() { UseCookies = false, ClientCertificateOptions = ClientCertificateOption.Manual, ServerCertificateCustomValidationCallback = ServiceLayer.TLSCertificateValidate }; } public HttpMessageHandler Handler { get { if(httpMessageHandler is null) { httpMessageHandler = CreateHandler(); } return httpMessageHandler; } set => httpMessageHandler = value; } public HttpClient HttpClient { get { if(httpClient is null) { httpClient = new HttpClient(Handler); } return httpClient; } set => httpClient = value; }
La chicha está en la clase Operador, cuyo planteamiento es éste:
using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using System.Threading.Tasks; internal class Operador<T, TResult> { private readonly HttpClient client; private readonly Uri url; private readonly string cookie; private JsonSerializerOptions _JsonSerializerOptions; public Operador(HttpClient client, Uri url, string cookie) { this.client = client; this.url = url; this.cookie = cookie; } public async Task<OperationResponse<TResult>> PostAsync(T documento) { StringContent content = GetContent(documento); var response = await client.PostAsync(this.url, content); return await ProcesarResponse(response); } //(...)
Que se reduce a preparar el contenido, enviarlo y leer la respuesta.
La generación del contenido es así:
private StringContent GetContent(T documento) { string jsonDocumento = JsonSerializer.Serialize(documento, JsonSerializerOptions); StringContent content = new StringContent(jsonDocumento, Encoding.UTF8, "application/json"); content.Headers.Add("Cookie", cookie); return content; }
Son tres pasos:
- Serializamos nuestro documento en una cadena con la estructura json.
- Creamos un StringContent con el documento json.
- Añadimos la cookie de sesión a la cabecera.
Las instrucciones para serializar el documento están definidas por unas JsonSerializerOptions que defino en la propiedad homónima:
private JsonSerializerOptions JsonSerializerOptions { get { if(_JsonSerializerOptions is null) { _JsonSerializerOptions = new JsonSerializerOptions(); _JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull; _JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); _JsonSerializerOptions.Converters.Add(new EdmTimeOfDayJsonConverter()); DefaultJsonTypeInfoResolver resolver = new DefaultJsonTypeInfoResolver(); resolver.Modifiers.Add(ServiceLayer.JsonDefaultValueModifier); _JsonSerializerOptions.TypeInfoResolver = resolver; } return _JsonSerializerOptions; } }
- JsonIgnoreCondition.WhenWritingNull para que no envíe las propiedades nulas.
- Con JsonStringEnumConverter le indicamos que las enumeraciones las envíe con su nombre y no con el valor numérico. Ojo, para que esto funcione, debemos desmarcar la opción Use C# casing style cuando generemos el cliente, porque los nombres de las enumeraciones de SAP empiezan por minúscula y, si esa opción está marcada, se nos crearán con la primera letra en mayúsculas. El motor de OData se guarda el nombre correcto para la conversión, pero por este camino no tenemos acceso a esos conversores.
- EdmTimeOfDayJsonConverter es un convertidor personalizado que he tenido que añadir para resolver el tipo Microsoft.OData.Edm.TimeOfDay:
using Microsoft.OData.Edm; using System; using System.Text.Json; using System.Text.Json.Serialization; public class EdmTimeOfDayJsonConverter : JsonConverter<Microsoft.OData.Edm.TimeOfDay> { public override TimeOfDay Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return TimeOfDay.Parse(reader.GetString()); } public override void Write(Utf8JsonWriter writer, TimeOfDay value, JsonSerializerOptions options) { writer.WriteStringValue($"{value.Hours}:{value.Minutes}:{value.Seconds}"); } }
- Aún con todo esto, seguiremos teniendo problemas con las colecciones vacías y con la colección de propiedades dinámicas. Para quitarlas, debemos indicarle cómo en el TypeInfoResolver. Hago esto en un método estático creado en el context, ServiceLayer.JsonDefaultValueModifier
/// <summary> /// Elimina las colecciones vacías y los diccionario de DynamicProperties, tengan o no valores. /// </summary> public static void JsonDefaultValueModifier(JsonTypeInfo type_info) { foreach(var property in type_info.Properties) { if(typeof(ICollection).IsAssignableFrom(property.PropertyType)) { property.ShouldSerialize = (_, val) => val is ICollection collection && collection.Count > 0; } else if(property.Name == "DynamicProperties") { property.ShouldSerialize = (_, val) => false; } } }
Con esto, ya tenemos nuestro contenido, que será o estará compuesto por entidades y tipos mapeados en nuestro cliente OData, preparado para ser enviado a Service Layer. Lo único que nos falta es leer la respuesta:
private async Task<OperationResponse<TResult>> ProcesarResponse(HttpResponseMessage response) { TResult result = default; ErrorMessage error = new ErrorMessage(); string contentResponse; contentResponse = await response.Content.ReadAsStringAsync(); if(response.IsSuccessStatusCode) { result = JsonSerializer.Deserialize<TResult>(contentResponse, JsonSerializerOptions); } else { try { var jsonDocument = JsonDocument.Parse(contentResponse); var jsonError = jsonDocument.RootElement.GetProperty("error"); if(jsonError.TryGetProperty("code", out var code)) { error.Code = code.GetString(); error.Message = jsonError.GetProperty("message").GetString(); } else { error.Code = response.StatusCode.ToString(); error.Message = jsonError.GetRawText(); } jsonDocument.Dispose(); } catch { error = new ErrorMessage() { Code = response.StatusCode.ToString(), Message = contentResponse }; } } return new OperationResponse<TResult>(response, result, error); }
- Si la respuesta es Ok (response.IsSuccessStatusCode), lo único que tenemos que hacer es deserializar el contenido usando las mismas JsonSerializerOptions que empleamos en el envío.
- Si tenemos un error, es algo más complicado de procesar, porque podemos tener un error devuelto por SAP, un error devuelto por Service Layer (un error del script) o una excepción. Para esto he creado una clase sencilla, ErrorMessage con dos propiedades de tipo string, Code y Message.
El método nos devuelve una OperationResponse, que es lo que devuelve nuestro método PostAsync delOperador. Que es, a su vez, lo que devuelve el método CrearReciboAsync que añadimos a nuestro contexto, la clase ServiceLayer. La definición de la clase es:
public class OperationResponse<T> { private HttpResponseMessage response; public OperationResponse(HttpResponseMessage response, T resultado, ErrorMessage errorMessage) { Resultado = resultado; this.response = response; this.ErrorMessage = errorMessage; } public T Resultado { get; } public bool IsSuccessStatusCode { get => response.IsSuccessStatusCode; } public HttpStatusCode StatusCode { get => response.StatusCode; } public ErrorMessage ErrorMessage { get; } }
Con esto ya está todo hecho. Sólo queda compilar nuestro cliente OData, crear el paquete NuGet, subirlo a nuestro repositorio privado y usarlo. ¡Otra operación de Di Server reemplazada!