Guía-de-Desarrollo
Rev 23/09/2019
Ubicación de los archivos en el código
En general uno desarrolla una x cantidad de APIs para un módulo o feature, que es lo que nos agrupa estas APIs en algo que tiene sentido para el usuario final.
En consecuencia a las APIs las pondremos en un archivo que se llame <nombre_del_módulo>.py que irá en la carpeta "tasks" del código (no pregunten por el nombre, quedó así y no nos atrevemos a cambiarle el nombre).
También en estos archivos irán las funciones accesorias que sean necesarias, no solo por el mismo módulo/feature sino también a veces por otros módulos/features.
Ese archivo tiene que de mínima importar lo siguiente y declarar el logger para poder funcionar:
from flask_app import *
from commons.commons import *
from tasks.auths import get_token
from commons.resourceaura import ResourceAura, ApiAura, ApiErr
import logging
logger = logging.getLogger()
con esto ya estamos para empezar a programar una API
Paradigma REST
TBC
Esquema de una API en código
Estructura general de una API
La estructura general de una API es la que sigue debajo:
class API_MiAPI(ResourceAura):
decorators = \[limiter.limit("10 per second, 100 per minute")\]
@ApiAura\
def get(self):\
...
@ApiAura\
def post(self):\
...
@ApiAura\
def patch(self):\
...
@ApiAura\
def delete(self):\
...
api.add_resource(API_MiAPI, '/api/demo/MiAPI')
Como se puede ver para generar una API no hay más que crear una clase (usamos la convención de iniciarlas con API_). Esta clase tiene que heredar otra clase que se llama ResourceAura (después ampliamos sobre esta clase).
Después no hay más que definir una función para el método que queramos programar. Si queremos implementar el método GET declaramos la función get... y así sucesivamente.
Y finalmente tenemos que agregar esa clase que creamos a la aplicación de flask y setearle el endpoint (url en donde responderá la API).
Manejo de errores
Como se puede ver, estas funciones tienen también un decorador que se llama @ApiAura. Este decorador existe porque para desarrollar las APIs preferimos usar una metodología EAFP (Easier Ask Forgiveness than Permission). Esto es: intentamos hacer las cosas y atajamos las excepciones que eso pudiera generar en vez de ver antes si lo que vamos a hacer es factible. De esta forma ganamos en velocidad y reducimos el tiempo de procesamiento en las APIs, lo cual nos importa por temas de performance.
Volviendo al decorador, lo que hace básicamente es atajar todos todos los errores (excepciones) que se generan dentro de la función que implementa la API. Si se genera una excepción, genera una respuesta standard de error (status code 400 con un mensaje sobre el error que saltó) y se termina la ejecución de la función.
El decorador ya tiene algunos códigos de error pre-establecidos, por ejemplo:
| Código de error | Descripción |
|---|---|
| -4 | Error en la base de datos |
| -3 | Error en token JWT |
| -2 | Token vencido |
| -1 | Error general (error por default si no está atajado adhoc) |
De esta forma, por ejemplo, si queremos ver si un usuario está correctamente logueado, con solo consultar a la función que devuelve el id del usuario, se validará el token, y si el token es inválido se generará una excepción que ataja @ApiAura y devuelve automáticamente un status code 400 indicando que el token no es válido.
Errores propios de la API (A.K.A. quiero devolver error rápido y terminar con el tema)
Como se puede ver en la tabla anterior, todos los códigos de error genéricos son negativos. Esto es porque dejamos los errores positivos a los que corresponde a la API propiamente dicha.
Por ejemplo nuestra API tiene que verificar que un dato exista y si no existe devuelva un error. Bueno esto es tan simple como levantar una excepción que existe para tal fin pasandole el código de error que queramos (siempre que sea positivo) y el mensaje de error. Por ejemplo:
if not param: raise ApiErr(1, "Not enough parameters")
Si la variable param es nula, la API va a devolver el request con un status code 400 y un JSON indicando que ocurri ó el error con código 1 y el hint de ese error va a ser “Faltan Parámetros”.
El json de respuesta es así:
{'res': 'err', 'code': 1, 'info': u'Not enough parameters'}
La clase ResourceAura
ResourceAura no es más que un wrapper que armamos sobre la clase Resource de Flask.
La clase Resource de flask nos da a acceso a una serie de variables y funciones para poder ver el request que llega (datos, headers, parámetros, etc) y armar la respuesta (código de respuesta, datos, headers, cookies, etc).
Lo que agrega Resource Aura es principalmente dos cosas:
- Implementa el método options con una respuesta standard para que no haya problemas con CORS
- Implementa funciones que ayudan a rápidamente devolver una respuesta válida con código 200.
Sobre este último punto actualmente existen las siguientes funciones de respuesta:
resp_ok_json_from_json(diccionario): devuelve un json a partir de un diccionario que se le pasa como parámetro
resp_ok_json_from_string(string): devuelve un json desde un json expresado como string. Es importante que esté correcto el formato del string en función al standard de json.
resp_ok_text_from_text(string): devuelve una respuesta en texto plano desde un string que se le pasa como parámetro.
resp_ok_imagesvg_from_text(text,filename="image.svg"): devuelve una imagen SVG a partir de código SVG que se pasa como string.
Acceso a la base de datos
Para acceder a la base de datos tenemos disponible una función que ya tiene las conexiones abiertas y nosotros simplemente le mandamos una query.
Para hacer una consulta no hay que hacer más que llamar a la función db_query, que devuelve un cursor y después obtener la respuesta, por ejemplo:
cursor = db_query(**"select \* from usuarios;"**)\
resultado = cursor.fetchall()
En general vamos a querer pasar parámetros a una query. La forma de hacer esto no es concatenando strings porque eso es una falla grave de seguridad!!!
La forma de hacerlo es usando prepared statements. En un prepared statement le decimos a la base de de datos que en algún lugar le vamos a pasar un parámetro o valor en vez de código SQL. Por ejemplo:
cursor = db_query(**"select \* from usuarios where email = %(mail)s;",**
{**mail:”****nicolas@auravant.com****”**})
resultado = cursor.fetchall()
Acá como se puede ver le estamos diciendo que le vamos a pasar un parámetro que se llama "mail" y después le pasamos un diccionario con el valor de ese parámetro "mail".
Por último la librería que se usa acá es psycopg2, por lo cual tenemos distintas opciones de recuperar el resultado:
fetchall():
- devuelve todas los registros de las queries
- devuelve siempre un arreglo de tuplas
- cuando no hay resultados devuelve un arreglo vacío []
fetchmany(x):
- devuelve los próximos x registros desde la última vez que se llamó a fetchmany para este cursor (si es la primera llamada devuelve los primeros x registros)
- devuelve siempre un arreglo de tuplas
- cuando no hay resultados devuelve un arreglo vacío []
- se la puede llamar subsiguientemente para ir leyendo de a x registros
fetchone():
- devuelve el próximos registro desde la última vez que se llamó a fetchmany para este cursor (si es la primera llamada devuelve el primer registro)
- devuelve siempre una tupla
- cuando no hay resultados devuelve null
- se la puede llamar subsiguientemente para ir leyendo los registros siguientes
Ojo con los casos anteriores que fetchmany y fetchall devuelven listas de tuplas y listas vacías cuando no hay registros y fetchone devuelve una tupla y null cuando no hay registros!!!
La función db_query permite pasar un parámetro "dict" que al pasarlo con valor True devuelve los resultados en vez de en un arreglo o tupla, en un arreglo de objetos o diccionario con los nombres de sus columnas.
Un ejemplo de lo anterior:
res = db_query(**"""**
**select id_lote from ciclos_productivos**
**join monitoreos on monitoreos.id_ciclos_productivos = ciclos_productivos.id**
**where monitoreos.uuid = %(uuid)s;**
**"""**,
{**'uuid'**:uuid},dict=True).fetchall()
id_lote = res\[0\]\[**'id_lote'**\]
Hacer link a apartado de SQL injection
Logging
TBC
Pasos que tiene que cumplir una API
Los siguientes pasos son en general los que tiene que tener una API standard. Obviamente habrá variaciones, algunas veces vas a querer no validar al usuario o demás, pero en general este es el flujo:
- Validación del usuario
- Validar datos de entrada
- que estén los mínimos necesarios
- que tengan el formato adecuado
- Hacer el proceso que corresponda a la API
- Enviar la respuesta
1) Validación del usuario
Para validar el usuario no hay más que decodificar el token llamando a la siguiente función:
id_usuario = get_token()
Si el token llega a ser inválido se genera una excepción y se aborta el request, interrumpiendo la ejecución del código.
2) Validar datos de entrada
En este caso amerita hablar sobre dos casos:
- Validación de parámetros de la query del request
- Validación de los datos parados al request
Parámetros de la query del request:
Siguiendo con el concepto restful, como parámetro en la query se deben incluir solamente aquellas variables que permitan identificar o filtrar el resultado.
Las funciones de flask ya permiten hacer una validación de entrada básica y solo nos dejan a nosotros validar la obligatoriedad de esa variable y otras validaciones que correspondan a cada caso puntual.
Un ejemplo de esto es:
uuid = request.args.get(**'uuid'**,type=str,default=None)\
**if not** uuid: **raise** ERR_FALTAN_PARAMETROS
Datos pasados al request:
Cuando se trate de métodos que ingresan información al sistema (POST, PATCH, PUT) es deseable que dicha información esté en formato JSON. Usar este método de entrada nos permite poder manejar de una forma más estándar las validaciones, así como también nos va a reducir los tiempos de documentación.
En las apis está implementada la librería jsonschema, que permite validar un esquema y levanta una excepción si no es válida, interrumpiendo la ejecución y devolviendo un mensaje de error standard.
Un esquema de uso de un esquema JSON es el siguiente:
schema = {\
**"type"**: **"object"**,
**"properties"**: {
**"fecha_obligatoria"**: {
**"type"**:**"string"**,
**"format"**: **"date-time"**,
**"description"**:**"esta fecha es re obligatoria"**},
**"dato_no_obligatorio"**: {
**"type"**: **"string"**,
**"description"**:**"esta descripción no importa tanto"**},
**"numero_obligatorio"**: {
**"type"**: **"integer"**,
**"minimum"**: 0,
**"description"**:**"este numero es obligatorio y tiene que ser mayor o igual a 0"**},
},
**"required"**:\[ **"fecha_obligatoria", “numero_obligatorio” \]**
}
data = request.get_json()
jsonschema.validate(data, schema,format_checker=jsonschema.FormatChecker())
En el ejemplo anterior es muy importante pasar en la última función el format_checker, sino no va a validar todas las entradas de las que dependa la key “format”.
Con esta validación ya se hace una gran, parte de checkeo, solo resta que nuestro código valide algunas condiciones raras que a veces se complican desde el json (requerimientos en llaves a distinto nivel por ejemplo) y más importante aún, los valores por defecto cuando no nos pasan un dato.
Como los JSON schemas tienen su forma de sintaxis, una parada obligatoria es este link en donde se explica en detalle:
https://json-schema.org/understanding-json-schema/index.html
Otra parada obligatoria es de la librería jsonschema en sí:
https://python-jsonschema.readthedocs.io/en/latest/
APIs internas
Las apis internas son las que están disponibles solo para la red interna de los servidores de auravant. Las mismas no están disponibles al público y son sólo para uso interno. Así, se puede hacer consultas entre los microservicios de manera rápida y segura.
Los endpoints de estas APIs deben estar bajo el path /services/.
Las URL quedan:
PRODUCCIÓN: http://api.internal.auravant.com/services\\ TESTING: http://api.testing.auravant.com/services (aun no hay internal)
Los archivos de variables de entorno (aurapi_config) deben tener definido API_INSIDE con la url correspondiente.
Otros temas que falta desarrollar
Programar pensando en la compatibilidad hacia atrás
- Nuestro front end (web IOS y Android) nunca nunca nunca va a estar sincronizado. vas a tener usuarios con versiones de app viejas, etc etc.
- Tenemos que pensar que cuando modificamos algo viejo, tiene que seguir funcionando como estaba antes (no se pueden agregar parámetros sin defaults, la respuesta debe ser idéntica o si es un JSON puede agregar nuevas llaves, no eliminarlas ni cambiarlas de lugar).
Programar pensando en el bosque
- A veces un cambio en una función, implica hacer cambios en otros lados
- Pensar que todo tiene una instancia de creación, consulta, modificación y borrado. Si hacemos un cambio en uno de esos pasos seguramente tengamos que hacer cambios en los otros.
- Pensar si no hay otros elementos/módulos que dependen de lo nuestro (no confiar en tests que fallen, que igual hoy no tenemos :S, para enterarnos que nos faltó programar algo). Asumir que los tests van a quedarse siempre cortos.
- Si modifiqué una función buscar muy rápidamente en todo el código donde se usa para tener una idea de estas relaciones y si no se me está escapando algo. Después caer en los tests, son la última instancia.
- Si modifiqué una API buscar en donde se está usando para ver que no se esté escapando nada. Después caer en los tests.
Acceso a la base de datos:
usar db_query(query,parametros) en vez de get_db
llamando directamente a db_query se ejecuta la query
Si se quieren obtener resultados se puede acceder al cursor
Ojo:
si se usa fetchone() devuelve una tupla y cuando no tiene registros devuelve None
pero si se usa fetchall() o o fetchmany(n) devuelve un arreglo de arreglos y si no tiene registros devuelve un array vacío
Como se usan las APIs en flask
Seguridad en la base de datos
Todas las queries tienen que ser con parámetros.
No tienen que existir queries que estén hechas concatenando strings. De ser absolutamente necesaria, hay que pasarla por un proceso de validación de seguridad.
Los TOCs en general son malos
ej: reordenar columnas en base de datos, cambiar nombres de variables o funciones, etc.
Checklist para pasar a producción
- ejecutar requirements.txt, no instalar "a mano"
- hacer backup para rollback
- bajar la página y las APIs (a2dissite) cuando se modifican cosas y necesitamos matar input del usuario
- Si tenemos que matar input de todo sistema existente, se pueden bajar los accesos a postgres
Deuda técnica
- Migrar todos los det_db a db_query
- Terminar de llevar todo aurapi.py a archivos separados
Estilo de programación
- pushear al menos una vez por semana
- git flow
- en ingles
- términos comunes inglés
- testear fuera del flujo normal del programa
comentarios en apis
triple comentario
pocos comentarios
código fácil de entender
actualizar requirements txt
seguridad: cuidado con los errores en los logs y respuestas de error de las APIs
Formato de una api
- Throttling
- Validación de usuario
- Validación de permisos (lectura se pueden hacer en paralelo)
- Validación y limpieza de input
- Procesamiento
- Respuesta
Performance
Threading
- quieries largas si hay otras cosas en paralelo que hacer
- validación de permisos en lectura
- descarga de imágenes
Seguridad
- Validación de datos
- SQL Injection
- Code Injection (eval y similares)
- Parámetros no públicos van en headers o payload (no en parámetros)
- Throtling
- (hay mucho mas, meterle cerebro a ver que falta)