perri.to: A mashup of things

Apis y JSON

  2019-10-03


Introducción

Nota: Los materiales de ejemplo de este artículo se pueden encontrar aqui

Siguiendo con la temática del post anterior vamos a utilizar un poco de actualidad financiera local (de Argentina) para ejemplificar uno de los usos comunes de go, en este caso vamos a utilizar una API http que devuelve resultados en JSON que des-serializaremos a diferentes representaciones en Go para pdoer trabajar con ellos.

El ejercicio que realizaremos será consultar la api pública de Mercado Libre para hacer una búsqueda del teléfono mas caro que hay en el mercado en este momento un iPhone 11 Max Pro que en su lugar de “origen” (Estados Unidos) Tiene un costo de U$D1499 mas impuestos en su versión mas cara (El modelo con 512G de almacenamiento). Me pareció simpatico hacer una aplicación que nos diga cuanto cuesta en la plaza local, para hacer esto hice un poco de trampa y hago una búsqueda por dicho teléfono y la ordeno de mas caro a mas barato y tomo el mas caro, seguramente en el mismo sitio se encuentren algunos mas baratos.

Para simplificar la explicación vamos a separar las funcionalidades en funciones individuales que explicaremos por partes.

La petición de consulta

Lo primero que necesitamos hacer es la petición HTTP a la API de Mercado Libre, para ello vamos a crear la función queryML que realizará la petición y nos devolverá un io.ReadCloser correspondiente al cuerpo de la respuesta (el JSON) o un error si no se puede realizar la petición correctamente.

Nuestra petición será una acción de GET que ademas de la URL deberá tener una cadena de consulta con los parámetros que requiere esta API para realizar la búsqueda en cuestión, tener en cuenta que, de tener un parámetro equivocado, la API devuelve un resultado equivalente a una búsqueda sin resultados.

Deberemo pasar un parametro para el ordenamiento, contenido en sortKey y uno para la búsqueda, contenido en queryKey. Para asegurarnos de construir una URL válida convertiremos el string que la contiene en un objeto net/url.URL parte de las librerias que vienen con el lenguaje, luego le pediremos un diccionario con las claves/valores de la cadena de consulta (que en este caso estará vacía dado que la URL de base no contiene ningún parametro). Modificaremos el mapa de valores obtenido y lo volveremos a asignar al objeto url.URL para luego re-generar un string con una URL válida.

const iPhone11Max = "iPhone 11 Pro Max"
const (
	// Si quieren buscar en otro lado reemplazaran MLA por su pais
	baseMeLiURL = "https://api.mercadolibre.com/sites/MLA/search"
	queryKey    = "q"
	sortKey     = "sort"
	sortID      = "price_desc"
)

/* ... */

func queryML() (io.ReadCloser, error) {
    // convertimos la URL de base a un objeto url.URL
	queryURL, err := url.Parse(baseMeLiURL)
	if err != nil {
		return nil, fmt.Errorf("parsing mercado libre url: %v", err)
	}
	// Obtenemos un diccionario de clave/valor de los parametros de GET
	queryValues := queryURL.Query()
	// Agregamos los parametros que nos interesan
	// Ordenar por mas caro primero
	queryValues[sortKey] = []string{sortID}
	// Criterio de búsquda: un teléfono carísimo
	queryValues[queryKey] = []string{iPhone11Max}
	// Re-asignamos el diccionario de valores a la query original.
	queryURL.RawQuery = queryValues.Encode()
	// Realizamos la consulta con el string de URL válido generado por queryURL.
	response, err := http.Get(queryURL.String())
	if err != nil {
		return nil, fmt.Errorf("querying mercado libre url: %v", err)
	}
    if response.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("requesting to mercado libre: %s", response.Status)
	}
	return response.Body, nil
}

La respuesta, parseando JSON a un diccionario de Go

Hay mas de una forma de interpretar un resultado en JSON para convertirlo a datos de Go, veremos dos a continuación, de mas genérica a mas puntual.

Disponiendo del io.ReadCloser que nos da acceso al cuerpo de la respuesta, lo primero que debemos hacer es recordar encolar una llamada a Close() para que se ejecute al salir de la función actual y así ya es un asunto resuelto, esto es opcional pero recomendado.

Interface

Antes de seguir, deberiamos hablar brevemente de las interfaces, en Go, una interface nos permite definir, a grandes rasgos, una variable especificando su comportamiento en lugar de su tipo. Un buen ejemplo de esto pudimos verlo en io.ReadCloser que es el tipo de Body, que en vez de aludir especificamente al tipo que tendra lo que se asignó a Body nos dice que espera que implemente el método Read([]byte) (int, error) y el método Close() error justamente especificando una interface que es la mezcla de io.Reader e io.Closer. Las interfaces nos permiten ser explícitos en la funcionalidad mínima que esperamos de un tipo y así permitir en una variable o función el uso de varios tipos concretos que cumplan con esos requisitos. A falta de soporte para programacion genérica en el lenguaje, a veces necesitamos aplicar transformaciones a un objeto contenido en una variable del tipo de una interface para devolverlo a su tipo concreto si es que necesitamos algo, esto se puede lograr de dos maneras simples que veremos mas adelante, estas transformaciones se llama “aserciones de tipo” que no deben confundirse con “casteo” de variables dado que lo que se hace es realizar aserciones de la interface contra un tipo concreto para ver si el objeto en memoria es en efecto perteneciente al tipo, no hay conversión. Una variante, no muy recomendada, es la interface vacia, que nos permite definir una interface con la que cualquier tipo coincide por el simple hecho de que no tiene ningun requerimiento mínimo, se define como interface {} indicando con las llaves vacías que no se espera ningun atributo. En el caso que veremos a continuación, se utilizará para de-serializar un JSON dado que en el caso de cargar JSON a primitivas como mapas no podemos definir en Go uno que soporte tipos mixtos como valor (en los mapas de Go definimos el tipo de la clave y del valor pero estos aplican a todos los elementos del mapa)

De-serialización a mapas

La naturaleza de JSON hace que tenga algo de fricción a la hora de intentar cargarlo en estructuras rígidas. A un nivel muy bajo, un objeto JSON es un mapa o diccionario de claves y valores que a su vez pueden ser otros mapas, listas o primitivas. Una forma simple de intentar cargar uno es la que veremos en el ejemplo a continuación: definiremos un mapa map[string]interface{} que esperará que todas sus claves sean strings y aceptará cualquier cosa como valor, luego deberemos recorrerlo tomando los valores en las claves que nos interesan e intentar hacer “aserciones de tipo” con cada una de ellas. Vemos como primero obtenemos los results que es una lista de objetos así que hacemos una aserción a un []interface{} que es como estan almacenados y luego recorremos cada uno de sus elementos y hacemos aserción a map[string]interface{} dado que sabemos que cada uno de ellos es un mapa. Notemos que en cada aserción se utiliza la notación específica <interface>.(tipoEsperado) y que se esperan dos resultados, el objeto de tipo concreto y una variable booleana que nos indica si la aserción fue exitosa. Podemos omitir la variable booleana pero en caso de no haber éxito se dispararía un panic

func iPhoneMasCaroML() (decimal.Decimal, error) {
	// obtendremos el cuerpo de la respuesta de la función queryML, que es un io.ReadCloser
	body, err := queryML()
	if err != nil {
		return decimal.Zero, err
	}
	// recordaremos cerrar el cuerpo al finalizar
	defer body.Close()

	// leemos todo el cuerpo en un arreglo de bytes, no es recomendable hacerlo de esta manera
	// en código de producción ya que no estamos chequeando el largo del contenido antes de
	// guardarlo en memoria, si nuestro código hiciese muchas de estas llamadas posiblemente
	// ocuparia mucha memoria.
	bodyData, err := ioutil.ReadAll(body)
	if err != nil {
		return decimal.Zero, fmt.Errorf("reading mercado libre response body: %v", err)
	}

	// de-serializamos el contenido del cuerpo a un map[string]interface{}
	resultML := map[string]interface{}{}
	err = json.Unmarshal(bodyData, &resultML)
	if err != nil {
		return decimal.Zero, fmt.Errorf("unmarshaling mercado libre response body: %v", err)
	}

	// buscamos en el map, la clave de la lista de resultados
	resultsRaw, ok := resultML[resultsKey]
	if !ok {
		return decimal.Zero, fmt.Errorf("key %s not found in response JSON", resultsKey)
	}
	
	// convertimos de un objeto interface{} a un []interface para poder utilizar las
	// características de una lista
	results, ok := resultsRaw.([]interface{})
	if !ok {
		return decimal.Zero, fmt.Errorf("unexpected results type %T", resultsRaw)
	}
	
	// chequeamos que, ademas de ser una lita, tenga en efecto resultados.
	if len(results) == 0 {
		return decimal.Zero, fmt.Errorf("nobody is selling an %s", iPhone11Max)
	}

	// obtenemos el primer resultado que, dado el ordenamiento de mas caro a mas barato
	// debería ser el mas caro.
	resultRaw := results[0]

	// convertimos este resultado nuevamente a un tipo que podamos manipular.
	result, ok := resultRaw.(map[string]interface{})
	if !ok {
		return decimal.Zero, fmt.Errorf("unexpected single type %T", resultRaw)
	}

	// buscamos la clave del precio
	priceRaw, ok := result[priceKey]
	if !ok {
		return decimal.Zero, fmt.Errorf("price is not available")
	}

Luego de varias aserciones exitosas tenemos priceRaw que de momento es interface{} que sabemos contiene el precio, pero no sabemos si es un int, un string o un float para esto utilizaremos una aserción de tipo combinada con un switch.

Este es un caso especial en que la condición presentada al switch es variable := variableInterface.(type) donde type es la palabra clave type lo que provocara que el intento de coincidencia con cada casese haga en el tipo, cuando coincida el tipo, dentro del ámbito de ese case la variable tendra el tipo concreto que coincidió y entonces podremos operar con funciones que tomen ese tipo concreto o asignar a variables, en el ejemplo a continuación vemos como convertimos el valor de priceRaw a decimal.Decimal dependiendo de que tipo sea.

En la realidad sabemos que es float64 pero puede darse el caso de que sea un resultado mas variado dada la naturaleza no tipada de JSON.

Si ningun caso coincide devolveremos un error por que no podremos convertir el valor a decimal y por ende operar con el mismo.

	// utilizamos type switch para convertir el precio a decimal desde varios tipos
	// posibles.
	var moneyPrice decimal.Decimal
	switch price := priceRaw.(type) {
	case float64:
		moneyPrice = decimal.NewFromFloat(price)
	case float32:
		moneyPrice = decimal.NewFromFloat32(price)
	case string:
		moneyPrice, err = decimal.NewFromString(price)
		if err != nil {
			return decimal.Zero, fmt.Errorf("cannot translate price to a decimal value: %v", err)
		}
	default:
		return decimal.Zero, fmt.Errorf("price is not a type we can convert to decimal, is %T", priceRaw)
	}

	return moneyPrice, nil
}

De-serialización a tipos

No solo podemos deserializar a mapas y de esta forma tan procedural, Go nos permite también de-serializar a tipos concretos. Podemos definir uno o varios tipos que representen el total o una parte del contenido del JSON y a traves de anotaciones en el código utilizar variables de esos tipos para de-serializar el JSON. En este caso si el tipo tiene mas miembros que el JSON se dejaran en su valor Cero los que no tengan correspondencia y si el JSON fuese el que tiene mas, se descartarán.

Un ejemplo de un tipo simple, que representa solo los valores que nos interesan: `

// ResultadosML contiene un listado de resultados, representa una página de resultados.
type ResultadosML struct {
	Results []ResultadoML `json:"results"`
}

// ResultadoML contiene el precio de un resultado, representa un item de una página de resultados
// pero no es para nada exaustivo.
type ResultadoML struct {
	Price float64 `json:"price"`
}

// GetPrice devuelve el precio de un resultado convertido a decimal.Decimal.
func (r ResultadoML) GetPrice() decimal.Decimal {
	return decimal.NewFromFloat(r.Price)
}

Que intenta representar

{ 
    "results": [
        {"price":1900 },
        {"price":1800 }
    ]
}

Este JSON es un subconjunto de un resultado real, en el codigo de ejemplo hay un archivo JSON que contiene en realidad todo lo que hay en un resultado de una búsqueda.

Re-pensado para ser serializado en estos tipos, podemos ver que entonces el código es mas legible y un poco mas declarativo, no hay necesidad de realizar aserciones para todo lo que hay entre la raiz del arbol y la hoja que nos interesa.

    // en lugar de map[string]interface{} definimos el tipo concreto
	resultML := &ResultadosML{}
    // se de-serializa a un puntero de ResultadosML (siempre puntero en este caso)
	err = json.Unmarshal(bodyData, resultML)
	if err != nil {
		return decimal.Zero, fmt.Errorf("unmarshaling mercado libre response body: %v", err)
	}
    // podemos chequear directamente el largo del slice Results porque sabemos que 
    // el chequeo de tipo se hizo durante la de-serialización
	if len(resultML.Results) == 0 {
		return decimal.Zero, fmt.Errorf("results not found in response")
	}
	result := resultML.Results[0]

    // finalmente la conversión a decimal.Decimal la realiza el mismo struct que sabe
    // que su miembro es float, esto solo es posible por la estructura fija de los
    // resultados
	return result.GetPrice(), nil

De este modo podemos armar un main.go muy simple, en el ejemplo a continuación, para el que se provee un repositorio con todo el código para experimentación, contamos con la funcion dolarizame que esta en el archivo dolarizame.go que es una conversión del ejercicio anterior a función y que dejaremos como ejercicio para quien su analisis.

func main() {

	// moneyPrice, err := iPhoneMasCaroML(wg)
	moneyPrice, err := iPhoneMasCaroMLStruct()
	if err != nil {
		log.Fatalf("no se puede obtener el costo del iphone de mercado libre: %v", err)
	}
	usd, err := dolarizame(moneyPrice)
	if err != nil {
		fmt.Printf("el iphone mas caro cuesta: AR$ %s\n", moneyPrice.StringFixedBank(2))
		log.Fatalf("no se puede obtener la taza de cambio en dolares: %v", err)
	}
	fmt.Printf("el iphone mas caro cuesta: AR$ %s (U$D%s al promedio compra/venta)\n",
		moneyPrice.StringFixedBank(2), usd.StringFixedBank(2))
}

Si desean, pueden bajarse la carpeta de ejemplo y ejecutar:

go run main.go dolarizame.go

Obtendran un resultado como (lleva un rato dependiendo de su internet, paciencia):

el iphone mas caro cuesta: AR$ 197176.00 (U$D3414.30 al promedio compra/venta)

Este resultado no es para nada científico, es mas que nada didáctico para que experimenten consumir una API JSON y obtener datos de dos fuentes distintas si ojean dolarizame.go tambien pueden experimentar cambiando la velocidad o imprimiendo algunos otros resultados que no sean el mas caro o incluso cambiando el criterio de búsqueda. Siéntanse libres de comentar con los resultados y preguntas.

comments powered by Disqus