Hoy publicamos el segundo material de la serie dedicado al uso de Python en Instagram.
La última vez estaba verificando los tipos de código de servidor de Instagram. El servidor es un monolito escrito en Python. Consiste en varios millones de líneas de código y tiene varios miles de puntos finales Django.

Este artículo trata sobre cómo Instagram usa los tipos para documentar las API HTTP y hacer cumplir los contratos cuando trabaja con ellos.
Resumen de la situación
Cuando abre el cliente móvil de Instagram, a través de HTTP, accede a la API JSON de nuestro servidor Python (Django).
Aquí hay información sobre nuestro sistema que le permitirá tener una idea de la complejidad de la API que utilizamos para organizar el trabajo del cliente móvil. Entonces, esto es lo que tenemos:
- Más de 2000 puntos finales en el servidor.
- Más de 200 campos de nivel superior en un objeto de datos del cliente que representa una imagen, video o historia en una aplicación.
- Cientos de programadores que escriben código de servidor (y aún más que tratan con el cliente).
- Cientos de confirmaciones al código del servidor realizadas diariamente y modificando la API. Esto es necesario para proporcionar soporte para las nuevas características del sistema.
Usamos tipos para documentar nuestras API HTTP complejas y en constante evolución y para hacer cumplir los contratos cuando trabajamos con ellas.
Tipos
Comencemos desde el principio. La descripción de la sintaxis para las anotaciones de tipo en código Python apareció en
PEP 484 . ¿Por qué agregar anotaciones de tipo al código?
Considere la función que descarga información sobre el héroe de Star Wars:
def get_character(id, calendar): if id == 1000: return Character( id=1000, name="Luke Skywalker", birth_year="19BBY" if calendar == Calendar.BBY else ... ) ...
Para comprender esta función, debe leer su código. Una vez hecho esto, puede descubrir lo siguiente:
- Toma el identificador entero (
id
) del personaje. - Toma el valor de la enumeración correspondiente (
calendar
). Por ejemplo, Calendar.BBY
significa "Antes de la batalla de Yavin", es decir, "Antes de la batalla de Yavin". - Devuelve información sobre el personaje en forma de una entidad que contiene campos que representan el identificador de este personaje, su nombre y año de nacimiento.
La función tiene un contrato implícito, cuyo significado tiene que restaurar el programador cada vez que lee el código de la función. Pero el código de función se escribe solo una vez, y debe leerlo muchas veces, por lo que este enfoque para trabajar con este código no es particularmente bueno.
Además, es difícil verificar que el mecanismo que llama a la función se adhiera al contrato implícito descrito anteriormente. Del mismo modo, es difícil verificar que este contrato se respete en el cuerpo de la función. En una base de código grande, tales situaciones pueden conducir a errores.
Ahora considere la misma función que declara anotaciones de tipo:
def get_character(id: int, calendar: Calendar) -> Character: ...
Las anotaciones de tipo le permiten expresar explícitamente el contrato de esta función. Para entender lo que se necesita ingresar a una función y lo que devuelve esta función, solo lea su firma. Un sistema de verificación de tipos puede analizar estáticamente la función y verificar el cumplimiento del contrato en el código. ¡Esto le permite deshacerse de toda una clase de errores!
Tipos para varias API HTTP
Desarrollaremos una API HTTP que le permitirá recibir información sobre los héroes de Star Wars. Para describir el contrato explícito utilizado al trabajar con esta API, utilizaremos anotaciones de tipo.
Nuestra API debe aceptar el identificador de caracteres (
id
) como un parámetro de URL y el valor de la enumeración del
calendar
como un parámetro de solicitud. La API debe devolver una respuesta JSON con información de caracteres.
Así es como se ve la solicitud de API y la respuesta que devuelve:
curl -X GET https://api.starwars.com/characters/1000?calendar=BBY { "id": 1000, "name": "Luke Skywalker", "birth_year": "19BBY" }
Para implementar esta API en Django, primero debe registrar la ruta URL y la función de vista responsable de recibir la solicitud HTTP realizada a lo largo de esta ruta y de devolver la respuesta.
urlpatterns = [ url("characters/<id>/", get_character) ]
La función, como entrada, acepta los parámetros de solicitud y URL (en nuestro caso,
id
). Analiza y convierte el parámetro de solicitud de
calendar
, que es el valor de la enumeración correspondiente, al tipo requerido. Carga datos de caracteres de la tienda y devuelve un diccionario serializado en JSON y envuelto en una respuesta HTTP.
def get_character(request: IGWSGIRequest, id: str) -> JsonResponse: calendar = Calendar(request.GET.get("calendar", "BBY")) character = Store.get_character(id, calendar) return JsonResponse(asdict(character))
Aunque la función se proporciona con anotaciones de tipo, no describe explícitamente el contrato duro para la API HTTP. A partir de la firma de esta función no podemos encontrar los nombres o tipos de parámetros de solicitud, o campos de respuesta y sus tipos.
¿Es posible hacer que la firma de la representación de función sea exactamente la misma informativa que la firma de la función considerada anteriormente con anotaciones de tipo?
def get_character(id: int, calendar: Calendar) -> Character: ...
Los parámetros de función pueden ser parámetros de consulta (URL, consulta o parámetros del cuerpo de consulta). El tipo de valor devuelto por la función puede representar el contenido de la respuesta. Con este enfoque, tendríamos a nuestra disposición un contrato explícito y comprensible para la API HTTP, cuya observancia podría garantizarse mediante un sistema de verificación de tipo.
Implementación
¿Cómo implementar esta idea?
Usamos un
decorador para convertir una función de representación fuertemente tipada en una función de representación de Django. Este paso no requiere cambios en términos de trabajo con el marco Django. Podemos usar el mismo middleware, las mismas rutas y otros componentes a los que estamos acostumbrados.
@api_view def get_character(id: int, calendar: Calendar) -> Character: ...
Considere los detalles de la
api_view
decorador
api_view
:
def api_view(view): @functools.wraps(view) def django_view(request, *args, **kwargs): params = { param_name: param.annotation(extract(request, param)) for param_name, param in inspect.signature(view).parameters.items() } data = view(**params) return JsonResponse(asdict(data)) return django_view
Este es un código difícil de entender. Analicemos sus características.
Nosotros, como valor de entrada, tomamos una función de representación fuertemente tipada y la envolvemos en una función de representación Django normal, que devolvemos:
def api_view(view): @functools.wraps(view) def django_view(request, *args, **kwargs): ... return django_view
Ahora eche un vistazo a la implementación de la función de vista Django. Primero necesitamos construir argumentos para una función de presentación fuertemente tipada. Utilizamos la introspección y el módulo de
inspección para obtener la firma de esta función e iterar sobre sus parámetros:
for param_name, param in inspect.signature(view).parameters.items()
Para cada parámetro, llamamos a la función de
extract
, que extrae el valor del parámetro de la solicitud.
Luego, convertimos el parámetro al tipo esperado especificado en la firma (por ejemplo, convierte el
calendar
cadena a un valor que sea un elemento de la enumeración del
Calendar
).
param.annotation(extract(request, param))
Llamamos a una función de vista fuertemente tipada con los argumentos que construimos:
data = view(**params)
La función devuelve un valor fuertemente tipado de la clase
Character
. Tomamos este valor, lo transformamos en un diccionario y lo envolvemos en una respuesta HTTP en formato JSON:
return JsonResponse(asdict(data))
Genial Ahora tenemos una función de vista de Django que envuelve una función de vista fuertemente tipada. Finalmente, eche un vistazo a la función de
extract
:
def extract(request: HttpRequest, param: Parameter) -> Any: if request.resolver_match.route.contains(f"<{param}>"): return request.resolver_match.kwargs.get(param.name) else: return request.GET.get(param.name)
Cada parámetro puede ser un parámetro de URL o un parámetro de solicitud. La ruta de la URL de solicitud (la ruta que registramos al principio) está disponible en el objeto de ruta del sistema de localización de URL de Django. Verificamos el nombre del parámetro en la ruta. Si hay un nombre, entonces tenemos un parámetro de URL. Esto significa que de alguna manera podemos extraerlo de la solicitud. De lo contrario, este es un parámetro de consulta y también podemos extraerlo, pero de alguna otra manera.
Eso es todo Esta es una implementación simplificada, pero ilustra la idea básica de escribir una API.
Tipos de datos
El tipo utilizado para representar el contenido de la respuesta HTTP (es decir,
Character
) puede representarse mediante una clase de datos o un diccionario escrito.
Una clase de datos es un formato de descripción de clase compacta que representa datos.
from dataclasses import dataclass @dataclass(frozen=True) class Character: id: int name: str birth_year: str luke = Character( id=1000, name="Luke Skywalker", birth_year="19BBY" )
Instagram generalmente usa clases de datos para modelar objetos de respuesta HTTP. Estas son sus principales características:
- Generan automáticamente construcciones de plantillas y varios métodos auxiliares.
- Son comprensibles para los sistemas de verificación de tipo, lo que significa que los valores pueden estar sujetos a verificación de tipo.
- Mantienen la inmunidad gracias a la construcción
frozen=True
. - Están disponibles en la biblioteca estándar de Python 3.7, o como backport en el Índice de paquetes de Python.
Desafortunadamente, Instagram tiene una base de código obsoleta que utiliza diccionarios grandes y sin tipo, pasados entre funciones y módulos. No sería fácil traducir todo este código de diccionarios a clases de datos. Como resultado, nosotros, usando clases de datos para el nuevo código, y en código obsoleto usamos
diccionarios escritos .
El uso de diccionarios mecanografiados nos permite agregar anotaciones de tipo a los objetos del diccionario del cliente y, sin cambiar el comportamiento de un sistema de trabajo, usar las capacidades de verificación de tipos.
from mypy_extensions import TypedDict class Character(TypedDict): id: int name: str birth_year: str luke: Character = {"id": 1000} luke["name"] = "Luke Skywalker" luke["birth_year"] = 19
Manejo de errores
Se espera que la función de vista devuelva información de caracteres en forma de una entidad de
Character
. ¿Qué debemos hacer si necesitamos devolver un error al cliente?
Puede lanzar una excepción que será capturada por el marco y convertida en una respuesta HTTP con información de error.
@api_view("GET") def get_character(id: str, calendar: Calendar) -> Character: try: return Store.get_character(id) except CharacterNotFound: raise Http404Exception()
Este ejemplo también muestra el método HTTP en el decorador, que establece los métodos HTTP permitidos para esta API.
Las herramientas
La API HTTP está fuertemente tipada usando el método HTTP, los tipos de solicitud y los tipos de respuesta. Podemos introspectar esta API y determinar que debe aceptar una solicitud GET con la cadena de
id
en la ruta URL y con el valor del
calendar
relacionado con la enumeración correspondiente en la cadena de consulta. También podemos aprender que, en respuesta a dicha solicitud, se debe proporcionar una respuesta JSON con información sobre la naturaleza de
Character
.
¿Qué se puede hacer con toda esta información?
OpenAPI es un formato de descripción de API en base al cual se crea un rico conjunto de herramientas auxiliares. Este es todo un ecosistema. Si escribimos algún código para realizar una introspección de punto final y generar especificaciones OpenAPI basadas en los datos recibidos, esto significará que tendremos las capacidades de estas herramientas.
paths: /characters/{id}: get: parameters: - in: path name: id schema: type: integer required: true - in: query name: calendar schema: type: string enum: ["BBY"] responses: '200': content: application/json: schema: type: object ...
Podemos generar documentación de la API HTTP para la API
get_character
, que incluye nombres, tipos, información de solicitud y respuesta. Este es un nivel apropiado de abstracción para los desarrolladores de clientes que necesitan cumplir solicitudes al punto final apropiado. No necesitan leer el código de implementación de Python para este punto final.
Documentación APISobre esta base, puede crear herramientas adicionales. Por ejemplo, un medio para ejecutar una solicitud desde un navegador. Esto permite a los desarrolladores acceder a las API HTTP que les interesan sin tener que escribir código. Incluso podemos generar un código de cliente de tipo seguro para garantizar que los tipos funcionen correctamente tanto en el cliente como en el servidor. Debido a esto, podemos tener a nuestra disposición una API estrictamente tipada en el servidor, cuyas llamadas se realizan utilizando un código de cliente estrictamente tipado.
Además, podemos crear un sistema de verificación de compatibilidad con versiones anteriores. ¿Qué sucede si lanzamos una nueva versión del código del servidor para acceder a la API en cuestión? Necesitamos usar
id
,
name
y
birth_year
, y luego entendemos que no sabemos los cumpleaños de todos los personajes. En este caso, el parámetro
birth_year
hacerse opcional, pero las versiones antiguas de clientes que esperan un parámetro similar pueden simplemente dejar de funcionar. Aunque nuestras API difieren en la tipificación explícita, los tipos correspondientes pueden cambiar (por ejemplo, la API cambiará si el uso del año de nacimiento del personaje fue obligatorio y luego se convirtió en opcional). Podemos rastrear los cambios de API y advertir a los desarrolladores de API dándoles indicaciones en el momento adecuado de que, al hacer algunos cambios, pueden interrumpir el rendimiento de los clientes.
Resumen
Existe una amplia gama de protocolos de aplicación que las computadoras pueden usar para comunicarse entre sí.
Un lado de este espectro está representado por marcos RPC como Thrift y gRPC. Se diferencian en que generalmente establecen tipos estrictos para solicitudes y respuestas y generan código de cliente y servidor para organizar el funcionamiento de las solicitudes. Pueden prescindir de HTTP e incluso sin JSON.
Por otro lado, hay marcos web no estructurados escritos en Python que no tienen contratos explícitos para solicitudes y respuestas. Nuestro enfoque brinda oportunidades típicas para marcos estructurados más claramente, pero al mismo tiempo le permite continuar usando el paquete HTTP + JSON y contribuye al hecho de que tiene que hacer un mínimo de cambios en el código de la aplicación.
Es importante tener en cuenta que esta idea no es nueva. Hay muchos marcos escritos en lenguajes fuertemente tipados que brindan a los desarrolladores las características que describimos. Si hablamos de Python, este es, por ejemplo, el marco
APIStar .
Hemos encargado con éxito el uso de tipos para la API HTTP. Pudimos aplicar el enfoque descrito para escribir la API en toda nuestra base de código debido al hecho de que se aplica bien a las funciones de presentación existentes. El valor de lo que hicimos es obvio para todos nuestros programadores. Es decir, estamos hablando del hecho de que la documentación generada automáticamente se ha convertido en un medio efectivo de comunicación para aquellos involucrados en el desarrollo del servidor con aquellos que escriben el cliente de Instagram.
Estimados lectores! ¿Cómo aborda el diseño de las API HTTP en sus proyectos de Python?
