Saltar al contenido

Detrás de un Proxy

En muchas situaciones, usarías un proxy como Traefik o Nginx frente a tu app FastAPI.

Estos proxies podrían manejar certificados HTTPS y otras cosas.

Headers de Proxy Reenviados

Un proxy frente a tu aplicación normalmente establecería algunos headers al vuelo antes de enviar las requests a tu servidor para informarle que la request fue reenviada por el proxy, informándole la URL original (pública), incluyendo el dominio, que está usando HTTPS, etc.

El programa servidor (por ejemplo Uvicorn vía FastAPI CLI) es capaz de interpretar estos headers, y luego pasar esa información a tu aplicación.

Pero por seguridad, como el servidor no sabe que está detrás de un proxy de confianza, no interpretará esos headers.

Detalles Técnicos

Los headers del proxy son:

Habilitar Headers de Proxy Reenviados

Puedes iniciar FastAPI CLI con la CLI Option --forwarded-allow-ips y pasar las direcciones IP que deberían ser de confianza para leer esos headers reenviados.

Si lo estableces a --forwarded-allow-ips="*" confiaría en todas las IPs entrantes.

Si tu servidor está detrás de un proxy de confianza y solo el proxy se comunica con él, esto haría que acepte cualquier IP que tenga ese proxy.

fast →fastapi run --forwarded-allow-ips="*"
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

restart ↻

Redirecciones con HTTPS

Por ejemplo, digamos que defines una path operation /items/:

from fastapi import FastAPI

app = FastAPI()


@app.get("/items/")
def read_items():
    return ["plumbus", "portal gun"]

Si el cliente intenta ir a /items, por defecto, sería redirigido a /items/.

Pero antes de establecer la CLI Option --forwarded-allow-ips podría redirigir a http://localhost:8000/items/.

Pero quizás tu aplicación está alojada en https://mysuperapp.com, y la redirección debería ser a https://mysuperapp.com/items/.

Al establecer --proxy-headers ahora FastAPI podría redirigir a la ubicación correcta. 😎

https://mysuperapp.com/items/

Consejo

Si quieres aprender más sobre HTTPS, revisa la guía About HTTPS.

Cómo Funcionan los Headers de Proxy Reenviados

Aquí hay una representación visual de cómo el proxy añade headers reenviados entre el cliente y el servidor de la aplicación:

El proxy intercepta la request original del cliente y añade los headers especiales reenviados (X-Forwarded-*) antes de pasar la request al servidor de la aplicación.

Estos headers preservan información sobre la request original que de otra manera se perdería:

  • X-Forwarded-For: La dirección IP original del cliente
  • X-Forwarded-Proto: El protocolo original (https)
  • X-Forwarded-Host: El host original (mysuperapp.com)

Cuando FastAPI CLI está configurado con --forwarded-allow-ips, confía en estos headers y los usa, por ejemplo para generar las URLs correctas en las redirecciones.

Proxy con un prefijo de ruta eliminado

Podrías tener un proxy que añade un prefijo de ruta a tu aplicación.

En estos casos puedes usar root_path para configurar tu aplicación.

El root_path es un mecanismo proporcionado por la especificación ASGI (sobre la que FastAPI está construido, a través de Starlette).

El root_path se usa para manejar estos casos específicos.

Y también se usa internamente al montar sub-aplicaciones.

Tener un proxy con un prefijo de ruta eliminado, en este caso, significa que podrías declarar una ruta en /app en tu código, pero luego, añades una capa encima (el proxy) que pondría tu aplicación FastAPI bajo una ruta como /api/v1.

En este caso, la ruta original /app realmente se serviría en /api/v1/app.

A pesar de que todo tu código está escrito asumiendo que solo hay /app.

from fastapi import FastAPI, Request

app = FastAPI()


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

Y el proxy estaría "eliminando" el prefijo de ruta al vuelo antes de transmitir la request al servidor de la app (probablemente Uvicorn vía FastAPI CLI), manteniendo a tu aplicación convencida de que se está sirviendo en /app, para que no tengas que actualizar todo tu código para incluir el prefijo /api/v1.

Hasta aquí, todo funcionaría como de costumbre.

Pero luego, cuando abres la docs UI integrada (el frontend), esperaría obtener el esquema de OpenAPI en /openapi.json, en lugar de /api/v1/openapi.json.

Así que, el frontend (que se ejecuta en el navegador) intentaría alcanzar /openapi.json y no podría obtener el esquema de OpenAPI.

Porque tenemos un proxy con un prefijo de ruta de /api/v1 para nuestra app, el frontend necesita obtener el esquema de OpenAPI en /api/v1/openapi.json.

Consejo

La IP 0.0.0.0 se usa comúnmente para indicar que el programa escucha en todas las IPs disponibles en esa máquina/servidor.

La docs UI también necesitaría que el esquema de OpenAPI declare que este servidor de la API está ubicado en /api/v1 (detrás del proxy). Por ejemplo:

{
    "openapi": "3.1.0",
    // More stuff here
    "servers": [
        {
            "url": "/api/v1"
        }
    ],
    "paths": {
            // More stuff here
    }
}

En este ejemplo, el "Proxy" podría ser algo como Traefik. Y el servidor sería algo como FastAPI CLI con Uvicorn, ejecutando tu aplicación FastAPI.

Proporcionar el root_path

Para lograr esto, puedes usar la opción de línea de comandos --root-path así:

fast →fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

restart ↻

Si usas Hypercorn, también tiene la opción --root-path.

Detalles Técnicos

La especificación ASGI define un root_path para este caso de uso.

Y la opción de línea de comandos --root-path proporciona ese root_path.

Verificar el root_path actual

Puedes obtener el root_path actual usado por tu aplicación para cada request, es parte del diccionario scope (eso es parte de la especificación ASGI).

Aquí lo estamos incluyendo en el mensaje solo para fines de demostración.

from fastapi import FastAPI, Request

app = FastAPI()


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

Luego, si inicias Uvicorn con:

fast →fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

restart ↻

La respuesta sería algo como:

{
    "message": "Hello World",
    "root_path": "/api/v1"
}

Establecer el root_path en la app de FastAPI

Alternativamente, si no tienes forma de proporcionar una opción de línea de comandos como --root-path o equivalente, puedes establecer el parámetro root_path al crear tu app FastAPI:

from fastapi import FastAPI, Request

app = FastAPI(root_path="/api/v1")


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

Pasar el root_path a FastAPI sería el equivalente a pasar la opción de línea de comandos --root-path a Uvicorn o Hypercorn.

Acerca de root_path

Ten en cuenta que el servidor (Uvicorn) no usará ese root_path para nada más que pasarlo a la app.

Pero si vas con tu navegador a http://127.0.0.1:8000/app verás la respuesta normal:

{
    "message": "Hello World",
    "root_path": "/api/v1"
}

Así que, no esperará ser accedido en http://127.0.0.1:8000/api/v1/app.

Uvicorn esperará que el proxy acceda a Uvicorn en http://127.0.0.1:8000/app, y luego sería responsabilidad del proxy añadir el prefijo extra /api/v1 encima.

Acerca de proxies con un prefijo de ruta eliminado

Ten en cuenta que un proxy con prefijo de ruta eliminado es solo una de las formas de configurarlo.

Probablemente en muchos casos el valor por defecto será que el proxy no tenga un prefijo de ruta eliminado.

En un caso así (sin un prefijo de ruta eliminado), el proxy escucharía en algo como https://myawesomeapp.com, y luego si el navegador va a https://myawesomeapp.com/api/v1/app y tu servidor (ej. Uvicorn) escucha en http://127.0.0.1:8000 el proxy (sin un prefijo de ruta eliminado) accedería a Uvicorn en la misma ruta: http://127.0.0.1:8000/api/v1/app.

Testing local con Traefik

Puedes ejecutar fácilmente el experimento localmente con un prefijo de ruta eliminado usando Traefik.

Descarga Traefik, es un solo binario, puedes extraer el archivo comprimido y ejecutarlo directamente desde la terminal.

Luego crea un archivo traefik.toml con:

[entryPoints]
  [entryPoints.http]
    address = ":9999"

[providers]
  [providers.file]
    filename = "routes.toml"

Esto le dice a Traefik que escuche en el puerto 9999 y que use otro archivo routes.toml.

Consejo

Estamos usando el puerto 9999 en lugar del puerto HTTP estándar 80 para que no tengas que ejecutarlo con privilegios de administrador (sudo).

Ahora crea ese otro archivo routes.toml:

[http]
  [http.middlewares]

    [http.middlewares.api-stripprefix.stripPrefix]
      prefixes = ["/api/v1"]

  [http.routers]

    [http.routers.app-http]
      entryPoints = ["http"]
      service = "app"
      rule = "PathPrefix(`/api/v1`)"
      middlewares = ["api-stripprefix"]

  [http.services]

    [http.services.app]
      [http.services.app.loadBalancer]
        [[http.services.app.loadBalancer.servers]]
          url = "http://127.0.0.1:8000"

Este archivo configura Traefik para usar el prefijo de ruta /api/v1.

Y luego Traefik redirigirá sus requests a tu Uvicorn ejecutándose en http://127.0.0.1:8000.

Ahora inicia Traefik:

fast →./traefik --configFile=traefik.toml
INFO[0000] Configuration loaded from file: /home/user/awesomeapi/traefik.toml

restart ↻

Y ahora inicia tu app, usando la opción --root-path:

fast →fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

restart ↻

Verificar las respuestas

Ahora, si vas a la URL con el puerto de Uvicorn: http://127.0.0.1:8000/app, verás la respuesta normal:

{
    "message": "Hello World",
    "root_path": "/api/v1"
}

Consejo

Ten en cuenta que aunque estás accediendo en http://127.0.0.1:8000/app muestra el root_path de /api/v1, tomado de la opción --root-path.

Y ahora abre la URL con el puerto de Traefik, incluyendo el prefijo de ruta: http://127.0.0.1:9999/api/v1/app.

Obtenemos la misma respuesta:

{
    "message": "Hello World",
    "root_path": "/api/v1"
}

pero esta vez en la URL con el prefijo de ruta proporcionado por el proxy: /api/v1.

Por supuesto, la idea aquí es que todos accederían a la app a través del proxy, así que la versión con el prefijo de ruta /api/v1 es la "correcta".

Y la versión sin el prefijo de ruta (http://127.0.0.1:8000/app), proporcionada por Uvicorn directamente, sería exclusivamente para que el proxy (Traefik) acceda a ella.

Eso demuestra cómo el Proxy (Traefik) usa el prefijo de ruta y cómo el servidor (Uvicorn) usa el root_path de la opción --root-path.

Verificar la docs UI

Pero aquí viene la parte divertida. ✨

La forma "oficial" de acceder a la app sería a través del proxy con el prefijo de ruta que definimos. Así que, como esperaríamos, si pruebas la docs UI servida por Uvicorn directamente, sin el prefijo de ruta en la URL, no funcionará, porque espera ser accedida a través del proxy.

Puedes verificarlo en http://127.0.0.1:8000/docs:

Pero si accedemos a la docs UI en la URL "oficial" usando el proxy con puerto 9999, en /api/v1/docs, funciona correctamente! 🎉

Puedes verificarlo en http://127.0.0.1:9999/api/v1/docs:

Tal como lo queríamos. ✔️

Esto es porque FastAPI usa este root_path para crear el servidor por defecto en OpenAPI con la URL proporcionada por root_path.

Servidores adicionales

Aviso

Este es un caso de uso más avanzado. Siéntete libre de saltarlo.

Por defecto, FastAPI creará un servidor en el esquema de OpenAPI con la URL del root_path.

Pero también puedes proporcionar otros servidores alternativos, por ejemplo si quieres que la misma docs UI interactúe tanto con un entorno de staging como con uno de producción.

Si pasas una lista personalizada de servidores y hay un root_path (porque tu API vive detrás de un proxy), FastAPI insertará un "servidor" con este root_path al principio de la lista.

Por ejemplo:

from fastapi import FastAPI, Request

app = FastAPI(
    servers=[
        {"url": "https://stag.example.com", "description": "Staging environment"},
        {"url": "https://prod.example.com", "description": "Production environment"},
    ],
    root_path="/api/v1",
)


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

Generará un esquema de OpenAPI como:

{
    "openapi": "3.1.0",
    // More stuff here
    "servers": [
        {
            "url": "/api/v1"
        },
        {
            "url": "https://stag.example.com",
            "description": "Staging environment"
        },
        {
            "url": "https://prod.example.com",
            "description": "Production environment"
        }
    ],
    "paths": {
            // More stuff here
    }
}

Consejo

Notar el servidor auto-generado con un valor de url de /api/v1, tomado del root_path.

En la docs UI en http://127.0.0.1:9999/api/v1/docs se vería así:

Consejo

La docs UI interactuará con el servidor que selecciones.

Detalles Técnicos

La propiedad servers en la especificación de OpenAPI es opcional.

Si no especificas el parámetro servers y el root_path es igual a /, la propiedad servers en el esquema de OpenAPI generado se omitirá completamente por defecto, lo cual es el equivalente a un solo servidor con un valor de url de /.

Deshabilitar servidor automático de root_path

Si no quieres que FastAPI incluya un servidor automático usando el root_path, puedes usar el parámetro root_path_in_servers=False:

from fastapi import FastAPI, Request

app = FastAPI(
    servers=[
        {"url": "https://stag.example.com", "description": "Staging environment"},
        {"url": "https://prod.example.com", "description": "Production environment"},
    ],
    root_path="/api/v1",
    root_path_in_servers=False,
)


@app.get("/app")
def read_main(request: Request):
    return {"message": "Hello World", "root_path": request.scope.get("root_path")}

y entonces no lo incluirá en el esquema de OpenAPI.

Montando una sub-aplicación

Si necesitas montar una sub-aplicación (como se describe en Sub Applications - Mounts) mientras también usas un proxy con root_path, puedes hacerlo normalmente, como esperarías.

FastAPI usará internamente el root_path de forma inteligente, así que simplemente funcionará. ✨