← Back to list

FastAPI

Parte I: lo básico

Ernesto Cullen · 2024-03-21 01:08 · 7 claps · 12.2 min read
#fastapi #python #web-api-development #apuntes
Open on Medium ↗

FastAPI

Parte I: lo básico

En este artículo veremos las características distintivas y la utilización básica de FastAPI para crear APIs de forma rápida, sencilla e intuitiva.

Photo by Nicolas Hoizey on Unsplash

Photo by Nicolas Hoizey on Unsplash

FastApi (https://fastapi.tiangolo.com/) es un framework de aplicaciones web ASGI (Asynchronous Server Gateway Interface) escrito en Python. Es gratuito, rápido y fácil de aprender a usar. El código fuente está en https://github.com/tiangolo/fastapi, y la documentación en https://fastapi.tiangolo.com/.

Es tan fácil de aprender como Flask, pero está implementado desde el inicio pensando en APIs asíncronas, escritas en IDEs modernos: hace un uso intensivo de Pydantic (https://pydantic-docs.helpmanual.io/) y el soporte para tipos (type hints) de las últimas versiones de Python, de manera que los IDEs pueden detectar problemas antes de ejecutar las aplicaciones.

Instalación

FastApi se puede instalar directamente con pip:

pip install fastapi

Aplicación mínima

Una aplicación mínima en FastApi podría ser como sigue:

# hola1.py

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
   return "Hola desde FastApi!"

Para ejecutar este programa, necesitamos un servidor ASGI.

ASGI es el acrónimo de Asynchronous Server Gateway Interface. Es una especificación que describe como se comunica un servidor web con aplicaciones, y como las aplicaciones pueden invocarse en secuencia para procesar un pedido (request). Al contrario que su predecesor WSGI, esta especificación permite múltiples llamadas asincrónicas que pueden ser de larga duración porque no interrumpen el proceso principal.

Las aplicaciones WSGI pueden correrse en servidores ASGI con una capa de adaptación provista por la librería asgiref.

Servidores

Las aplicaciones de FastApi se ejecutan a través de un servidor asgi como uvicorn o hypercorn. Estos servidores se instalan también con pip:

pip install uvicorn

o

pip install hypercorn

Hypercorn acepta el protocolo HTTP 2.0, Uvicorn no.

Una vez instalado el servidor, lo invocamos especificando el script a ejecutar y el nombre del objeto aplicación:

uvicorn — host localhost — port 5555 hola1:app

Al ejecutar el script, si todo va bien veremos un mensaje como el siguiente:

[INFO] Uvicorn running on http://127.0.0.1:5555 (CTRL + C to quit)

y accedemos al mismo con un explorador:

los parámetros --host y --port tienen valores por defecto 127.0.0.1 y 8000, respectivamente.

Se puede también arrancar el servidor desde python, por ejemplo en el mismo archivo fuente donde definimos la app. Con uvicorn:

import uvicorn
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
   return "Hola desde FastApi!"

if __name__ == '__main__':
   uvicorn.run(app, host="127.0.0.1", port=5555)

En el caso de hypercorn, para cambiar el servidor/puerto por defecto hay que usar el parámetro -b o --bind:

hypercorn -b localhost:5555 hello:app

hypercorn está pensado principalmente para ser lanzado desde línea de comandos, pero es posible también invocarlo desde el programa. Ver https://pgjones.gitlab.io/hypercorn/how_to_guides/api_usage.html por más detalles.

Dado que no necesitamos las caracteristicas de HTTP 2.0 en nuestros ejemplos y es más sencillo, usaremos uvicorn en todos los ejemplos siguientes.

Documentación automática

FastApi genera documentación automáticamente en formato OpenApi. La url siguiente

localhost:5555/docs

Muestra la app SwaggerUI con la definición de nuestra API:

mientras que

localhost:5555/redoc

muestra la documentación en formato **redoc**:

Funciones de endpoint (Path functions)

El código se escribe en funciones (llamadas path functions en la documentación de FastApi) que se anotan con atributos (atributos de endpoint) para indicar el verbo http que manejan y los parámetros.

Por ejemplo, para un endpoint GET / podemos escribir

@app.get("/")
def read_root():
    return "Hola desde FastApi!"

Mientras que un endpoint POST /clientes para agregar un cliente podría ser como sigue:

@app.post('/clientes', status_code=status.HTTP_201_CREATED)
def get_clientes(cliente: Cliente):
    return f'Se ha creado un nuevo cliente: {cliente}'

Más adelante veremos los detalles como el código de estado devuelto o el modelo de datos usado para el ingreso (payload).

Serialización de salida

Los datos que se envían de vuelta al cliente serán serializados automáticamente por FastAPI. Los tipos simples como string o integer se devuelven tal cual; los tipos complejos se devuelven en JSON.

Por ejemplo, un diccionario de Python se devolverá como un objeto:

@app.get("/obj")
def get_objeto():
   return {"data": "este es mi objeto"}

Una lista de Python se serializa a un array en JSON:

@app.get("/items")
def get_objetos():
   resultado = [
       {"nombre": "objeto 1", "id": 1},
       {"id": 2, "nombre": "objeto 2"}
   ]
   return {"data": resultado}

FastAPI también puede serializar clases de Pydantic, con el agregado que los datos son convertidos automáticamente al tipo correcto, de ser posible.

Debemos definir la clase como extensión de BaseModel, que se importa desde Pydantic:

from pydantic import BaseModel

class ModeloPydantic(BaseModel):
   id: int
   nombre: str

Ahora en la función de endpoint indicamos el modelo como parámetro en el atributo de endpoint:

@app.get("/items", response_model=list[ModeloPydantic])
def get_objetos():
   return [
       {"nombre": "objeto 1", "id": 1},
       {"id": 2, "nombre": "objeto 2"}

En la definición del modelo se incluyen restricciones que los datos deben cumplir, por ejemplo:

  • tipos de datos: Pydantic verifica los tipos de datos ingresados y convierte tipos si es necesario (y posible)
  • datos opcionales y obligatorios: en el ejemplo anterior, los campos id y nombre son obligatorios (no permiten nulos). Para indicar un campo opcional, declaramos un valor por defecto None o usamos Optional
  • valores por defecto
  • validaciones: si alguna no se cumple, el servidor lanzará una excepción con los detalles correspondientes
  • otros metadatos como una descripción para la documentación, etc.

FastAPI también usa Pydantic para limitar los datos devueltos solamente a los que estén declarados en el modelo (por ejemplo, no mostrar los ids o claves), además de validar y convertir los tipos de datos a los correctos. Y es usado por el sistema de documentación automática para mostrar el esquema esperado en el endpoint.

Metadatos en clases Pydantic

Las clases que extienden BaseModel incluyen el tipo de datos de cada atributo, pero también se pueden agregar validaciones y metadatos. Para esto, se indica como ‘valor por defecto’ del atributo la función Field de Pydantic:

from typing import Optional

from fastapi import Body, FastAPI
from pydantic import BaseModel, Field

class Item(BaseModel):
   name: str
   description: Optional[str] = Field(None, description="Descripción del item", max_length=300)
   price: float = Field(..., gt=0, description="El precio debe ser mayor que cero")
   tax: Optional[float] = None

app = FastAPI()

@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item = Body(..., embed=True)):
   results = {"item_id": item_id, "item": item}
   return results

Los tres puntos en el lugar del valor por defecto (ver la declaración de la clase Field) indican que ese parámetro no tiene valor por defecto, y por lo tanto es obligatorio.

Las validaciones se aplicarán en el ingreso de datos. Los datos extras (title, description…) se usarán al generar la documentación.

Deserialización y validación de ingreso

Pydantic también nos brinda una forma de indicar el tipo de cada elemento de los datos; esto permite la validación y conversión de tipos automática en el ingreso de parámetros.

Parámetros

Las llamadas al servidor pueden incluir parámetros de distintos tipos. Por ejemplo, una llamada de tipo GET como la siguiente:

GET https://miservidor.com/clientes/2?formato=json&incluirnulos=0

incluye un parámetro de ruta (path parameter) id con valor ‘2’ para indicar que queremos acceder al cliente con id=2; y dos parámetros extra (query parameters) llamados formato e incluirnulos que proveen datos adicionales al endpoint.

En todos los casos, los datos se reciben como parámetros en la función de endpoint. Estos parámetros se pueden anotar con el tipo de datos esperado. Si no especificamos el tipo de datos, el parámetro será string:

@app.get('/items/{id}')
def get_item(id):
   return f'Tipo de id: {type(id)}'

Si invocamos este endpoint desde el browser, veremos la siguiente respuesta:

Si queremos trabajar con el parámetro como entero, tendremos que transformarlo. O bien, podemos especificar el tipo directamente en la declaración de la función y dejar que FastAPI lo haga por nosotros:

@app.get('/items/{id}')
def get_item(id: int):
   return f'Tipo de id: {type(id)}'

La especificación de tipos en los parámetros permite realizar las dos funciones: validación y conversión. En el caso del ejemplo, el parámetro se intentará convertir en int. Si no se puede (porque escribimos algún caracter no numérico) se generará y devolverá al usuario un error con todos los detalles.

Si la conversión es posible, entonces la variable del parámetro será del tipo especificado sin tener que hacer nada más.

Veamos las características que tienen los distintos tipos de parámetros.

  • Parámetros de ruta (path parameters)

Especificados en la ruta, entre llaves:

@app.get('/items/{id}')
def get_item(id: int):
   ...

En la función de endpoint se listan los parámetros de ruta como parámetros normales. FastAPI puede discriminar automáticamente los parámetros de ruta de los parámetros extras, pero también podemos explicitarlo usando la función **Path**:

from fastapi import Path

@app.get('/items/{id}')
def get_item(id: int = Path(..., title="Id del item deseado")):
   return f'Tipo de id: {type(id)}'

NOTA: desde la versión 0.95.0 de FastAPI, se recomienda usar Annotated(del paquete estándar typing) para agregar metadatos a los parámetros. En el caso anterior, quedaría

@app.get('/items/{id}')
def get_item(id: Annotated[int, Path(description="Id del item deseado")]):
   return f'Tipo de id: {type(id)}'

Todos los parámetros de ruta son obligatorios, ya que forman parte de la ruta del endpoint.

La descripción y otros metadatos especificados en Pathserán considerados por el generador de documentación, como se ve a continuación:

  • Parámetros extra (query parameters)

Si la función espera más parámetros que los que hay en el path, se toman automáticamente como parámetros extra o query parameters, y se escriben en el path después de “?”.

@app.get('/items/{id}')
def get_item(id: int, formato:str='json', incluirnulos:bool=False):
   return (f'Debería devolver el item {id}, en formato {formato}, '
           f'{"Incluyendo" if incluirnulos else "No incluyendo"} propiedades con valores nulos')

Los parámetros extra son generalmente opcionales, por lo que el endpoint anterior podría ser invocado con cualquier combinación -por ejemplo:

Notemos en el ejemplo anterior que podemos asignar valores por defecto a los parámetros como en funciones Python normales. Esto es necesario si los parámetros son opcionales, como (casi) siempre serán los parámetros extra.

También tenemos una clase especial para anotar los parámetros extras: **Query**. Esta clase nos permite agregar validaciones además de valores por defecto. En el caso anterior, podríamos escribir

@app.get('/items/{id}')
def get_item(id: int,
   formato: Annotated[str, Query(max_length=10, description="Formato a usar en la respuesta")]="json",
   incluirnulos: Annotated[bool, Query(description="Incluir propiedades con valores nulos o no")]=False):
...

Las descripciones son usadas en la página de documentación automática:

O en redoc:

La clase Querytambién permite hacer ciertas validaciones sobre los valores a permitir.

Para parámetros de tipo string:

  • max_length = int, máxima longitud en caracteres para un parámetro de tipo string
  • min_length = int, mínima longitus en caracteres para un parámetro de tipo string
  • pattern = str, expresión regular contra la que se validará el valor del parámetro

Para parámetros de tipo numérico (int o float):

  • ge = int, se verifica que el valor sea mayor o igual a 1 (greater or equal)
  • le = int, menor o igual (less than or equal)
  • gt = int, mayor que (greater than)
  • lt = int, menor que (less than)
  • Parámetros de cuerpo (body parameters)

Para las funciones de POSTy PUT, se puede usar un modelo de Pydantic para validar los datos de entrada desde el cuerpo del request:

@app.post('/clientes')
def get_clientes(cliente: Cliente):
   ...

donde Clientees un modelo Pydantic, por ejemplo

from datetime import datetime, date
from typing import Optional, Union
from pydantic import BaseModel

class Cliente(BaseModel):
   id: int
   nombre: str
   direccion: Optional[str] = None
   sexo: Optional[str] = None
   fecha_nacimiento: Union[date, datetime, None] = None

FastAPI infiere que los parámetros vendrán en el cuerpo del request porque son tipos complejos (clases Pydantic). También se podría haber indicado la clase **Body** como ‘valor por defecto’ de cada parámetro, tal como hicimos con Path y Query, para agregar metadatos a la documentación.

Se puede usar más de una clase en el cuerpo, por ejemplo podemos esperar un objeto JSON complejo como el siguiente:

{
  "contacto": {
     "nombre": "contacto nuevo",
     "edad": 32
  },
  "direccion": {
     "calle": "cucha cucha",
     "numero": 75,
     "piso": null,
     "depto": null
  },
  "telefonos": {
     "personal": "555-883922",
     "trabajo": "9380209",
     "celular": "154-3282793"
  },
  "activo": true
}

Este objeto incluye tres entidades (Contacto, Direccion, Telefonos) y un dato general (activo). Para recibir valores de ese tipo en el body, podemos definir clases de Pydantic para las entidades y especificar que el boolean ‘activo’ viene incluido en el cuerpo también:

from fastapi import FastAPI, Body
from pydantic import BaseModel
import uvicorn

app = FastAPI()

class Contacto(BaseModel):
   nombre: str
   edad: int = None
   altura: float = None

class Direccion(BaseModel):
   calle: str
   numero: int
   piso: int = None
   depto: str = None

class Telefonos(BaseModel):
   personal: str = None
   trabajo: str = None
   celular: str = None

@app.post('/contactos')
def agregar_contacto_completo(contacto: Contacto,
                             direccion: Direccion,
                             telefonos: Telefonos,
                             activo: bool = Body(True)):
   print(f'Contacto: {contacto}')
   print(f'Direccion: {direccion}')
   print(f'Telefonos: {telefonos}')
   print(f'activo: {activo}')
   return "se recibio el contacto"

if __name__ == '__main__':
   uvicorn.run(app)

En este caso, el parámetro activo tiene que especificarse obligatoriamente con Body(), porque al ser de tipo simple se tomaría como parámetro extra (de query).

Ahora en la pantalla de documentación veremos el esquema de cada clase:

y al ingresar, nos muestra un ejemplo de lo que se espera:

Si ejecutamos con el objeto complejo anterior, veremos los objetos en la salida por consola:

Contacto: nombre='contacto 1' edad=18 altura=None
Direccion: calle='Cucha Cucha' numero=75 piso=None depto=None
Telefonos: personal='555-123456' trabajo='666-0292783' celular='N/D'
activo: True
INFO:     127.0.0.1:63233 - "POST /contactos HTTP/1.1" 200 OK

El orden sí importa

FastAPI evalúa las rutas de los endpoints en el orden en que están definidas. Por ejemplo, si tenemos dos endpoints como los siguientes:

@app.get(‘/items/{id}’)

y

@app.get(‘/items/primero’)

Cuando invocamos la api usando

[http://localhost:8000/items/primero](http://localhost:8000/items/primero)

FastAPI ejecutará la primera función, tomando primero como valor del parámetro id.

Esto claramente no es lo que queremos, pero la evaluación de los endpoints procede en orden de aparición y se detiene cuando encuentra la primera ruta que aplica al request enviado. Para resolver este problema, debemos cambiar el orden de los endpoints para que el primero que se encuentre sea el más específico. Esto no afectará la utilización del segundo endpoint con algún id que no sea el string ‘primero’.

Especificar códigos de estado HTTP en la respuesta

Cuando se produce un error en la evaluación de un endpoint, la respuesta debería ser un objeto de error que incluya un mensaje indicando el error, y un código de estado en la respuesta en el rango 400–499 (errores de cliente) o 500–599 (errores de servidor).

Puede ver una lista de los errores estándar HTTP y su significado en https://developer.mozilla.org/es/docs/Web/HTTP/Status.

FastAPI devuelve automáticamente un código 200 si no hubo errores, y un código 500 si se produjo alguna excepción. Se puede cambiar el código de respuesta incluyendo en la función del endpoint un parámetro de tipo Response, que será inyectado por FastAPI.

Por ejemplo, veamos el caso en que se pasa un id de item que no existe. El código de estado debería ser 404 (Not Found):

from fastapi import Response

@app.get('/items/{id}')
def get_item(id: int, resp: Response):
   item = buscar_item(id)
   if not item:
       resp.status_code = 404
       return {"message": f"No se encuentra un item con id {id}"}
   return {"data": item}

No hay necesidad de acordarse de todos los códigos: están definidos como constantes en el módulo **status** de FastAPI. El ejemplo anterior se puede escribir como sigue:

from fastapi import status

@app.get('/items/{id}')
def get_item(id: int, resp: Response):
   item = buscar_item(id)
   if not item:
       resp.status_code = status.HTTP_404_NOT_FOUND
       return {"message": f"No se encuentra un item con id {id}"}
   return {"data": item}

También se puede lograr lo mismo lanzando una excepción de tipo HTTPException, que FastAPI define:

from FastAPI imports status, HTTPException

@app.get('/items/{id}')
def get_items(id: int):
   item = buscar_item(id)
   if not item:
       raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
                           detail=f"No se encuentra un item con id {id}")
   return {"data": item}

Esto resulta un poco más simple e intuitivo ya que no hace falta incluir un manejo especial de la respuesta; sólo lanzar una excepción si algo sale mal. FastAPI se encargará de lo demás.

Hay otro caso común en que la especificación REST indica que se debería enviar un código diferente de [200 — OK] cuando la operación fue correcta: al momento de agregar un nuevo elemento, se debería devolver el nuevo elemento y un código de estado [201 — CREATED] en lugar de 200. Para estos casos, FastAPI permite la especificación del código correcto en la definición del endpoint:

@app.post('/clientes', status_code=status.HTTP_201_CREATED)
def get_clientes(cliente: Cliente):
   ...

Enumeraciones

Python permite utilizar enumeraciones de Python, que son clases que listan todos los valores posibles que puede tomar una instancia. Por ejemplo, un parámetro tipo_articulo que solamente puede tomar tres valores predefinidos se puede modelar con una enumeración como la siguiente:

from enum import Enum

class TipoArticulo(Enum):
   NORMAL = 'normal'
   ESPECIAL = 'especial'
   AMEDIDA = 'a_medida'

Ahora usamos este nuevo tipo en la api:

@app.get('/articulos/{tipo}')
def get_articulo(tipo: TipoArticulo):
   return f"Buscando el articulo tipo '{tipo.value}'"

en la página Swagger automáticamente se usará un Combo Box para la selección del valor:

Esto es todo por ahora! hay muchas cosas más para hablar sobre FastAPI, pero esa información se puede ver en la documentación oficial, que es muy buena y fácil de entender aunque esté en inglés.

Para realmente aprender a usar la librería, hay que usarla. En las entregas siguientes desarrollaremos aplicaciones aplicando distintas capacidades de FastAPI en la práctica.


메타데이터
post_id
6d593e5dfafa
slug
fastapi-6d593e5dfafa
url
https://medium.com/@ernestocullen/fastapi-6d593e5dfafa
canonical_url
https://medium.com/@ernestocullen/fastapi-6d593e5dfafa
author_url
https://medium.com/@ernestocullen
status
ok
fetched_at
2026-08-20 11:54:40