¿Cómo crear una API simple con Python, Flask y Contenedores?

Por: Andrew Copley, Solutions Architect en Apiux Tecnología
Es irlandés y vive en Chile hace varios años. Tiene un PhD en Física del Imperial College de Londres y hace parte del equipo fundador de Apiux. Es de los que va a la oficina todos los días. Le gusta caminar, pero disfruta más pedalear.

 

¡Hola, futuro yellower! Bienvenido a un nuevo blog de Apiux Devs. Hoy aprenderemos cómo crear una API simple con Python, Flask y Contenedores. Al final de este tutorial, tendrás una plantilla completa de una API. Así que acompáñame.  

 

Empecemos con una introducción a la arquitectura que necesitamos. Para crear una API simple con conectividad a una base de datos, vamos a ocupar la siguiente arquitectura:

 

 

Esta arquitectura consiste en tres contenedores orquestados por Docker Compose, que nos permite integrar los siguientes componentes:

 

1. Nginx Container: Nginx es un web server cuyo trabajo consiste en recibir llamadas http y entregar contenido estático como paginas HTML, o hacer un redirect a un servicio que entrega contenido mas dinámico, como interactuar con una base de datos.

 

El contenedor Nginx funciona en modo reverse-proxy, recibiendo los requests http desde los clientes de la API. Los clientes pueden ser, por ejemplo, un SPA (Single Page Application) como React, u otro tipo de aplicación backend, como un ERP. 

 

Nuestra Yellow Office, por ejemplo, es un sistema ERP.

 

¿Y qué es un reverse-proxy? Bueno, un reverse-proxy recibe un request http de un cliente y termina este request. Luego hace otro request a un servicio de contenido y devuelve los resultados al autor del request inicial. Es una buena manera de controlar el acceso deseado y no deseado a los recursos del backend.

 

2. Web Application Container: Este contenedor hace el trabajo de la API, recibiendo los requests desde el reverse-proxy y luego generando resultados para devolverse, a través del proxy, al autor de la petición.

 

En este caso vamos a usar un aplicativo Flask, un framework web, liviano y compacto que permite desarrollar rápidamente aplicaciones que responden a requests http y devuelven resultados dinámicos.

 

En nuestro caso, vamos a interactuar con un base de datos Postgresql y devolver unos datos de una tabla en la base de datos.

 

3. Postgres Container: El contenedor Postgres se encarga de recibir los pedidos desde el aplicativo Flask y devolver los datos de la base de datos. 

 

Te puede interesar: Testing de aplicaciones en React

 

Estructura de carpetas

 

Para este aplicativo simple vamos a usar la siguiente estructura de carpetas:

 

 

Tenemos una carpeta para nuestro componente Nginx, llamada ‘web’, y otra para nuestra aplicativo Flask, llamada ‘backend’.

 

1. Carpeta Padre

 

  •  .env

POSTGRES_HOST=postgres:5432
POSTGRES_DB=apiux_blog
POSTGRES_USER=******
POSTGRES_PASSWORD=******

 

Aquí tenemos las variables de entorno que van a permitir que Flask pueda hablar con la base de datos. Veremos que, en el archivo docker-compose, Docker Compose toma estas mismas variables para crear la base de datos al levantar el contenedor, si la base de datos no existe.

 

  • Docker-compose.yml

 

Este archivo nos permite coordinar nuestros tres componentes de Nginx, Flask y Postgres. Lo examinaremos a detalle más abajo.

 

2. Web:

 

Dentro de esta carpeta tenemos dos archivos:

 

 

El Dockerfile que contiene la especificación de nuestro contenedor Nginx y el nginx.conf, que tiene la configuración del reverse-proxy.

 

Dentro de este archivo de configuración especificamos que todos los requests http recibidos luego se entregan a nuestra aplicativo Flask. 

 

  • Dockerfile: La configuración es muy sencilla. Primero, se debe basar el contenedor en un imagen nginx de Dockerhub. Después, se debe exponer el puerto 80, y tercero, correr el proceso nginx.

 

FROM nginx:1.19.4-alpine
EXPOSE 80
CMD [«nginx», «-g», «daemon off;»]

 

  • nginx.conf

 

upstream backend_server {
# Docker will automatically resolve this to the correct address.
# beacause we user the same name as the service «supersalud» in docker-compose.yml
server flask_server:8000;
}

# Main server
server {

listen 80;
server_name localhost;
client_max_body_size 20M;


location / {
# Everything is passed to gunicorn
proxy_pass http://backend_server;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
        proxy_redirect off;
        proxy_connect_timeout 300s;
        proxy_read_timeout 300s;
    }
}

 

La primera sección, upstream, define la ruta hacia nuestra aplicativo Flask como flask_server: 8000. El nombre se define dentro de nuestro archivo docker-compose y permite que los contenedores se pueden comunicar entre sí. También se especifica el puerto 8000.

 

La segunda sección, server, instruye a nginx a escuchar por requests http en el puerto 80 con dominio ‘localhost’. Después viene una sub-sección ‘location’, donde se especifica que todos los url que conciden con ‘/’ debe ser reenviado a ‘backend_server’, definido en la primera sección.

 

Entonces, cualquier url de la formahttp://localhost/’ será reenviado a backend_server. Ahora veamos nuestro aplicativo Flask dentro de la carpeta ‘backend’.

 

3. Backend

 

En esta carpeta tenemos los siguientes archivos:

 

 

  • config_params.py

«»»Flask configuration.»»»
from os import environ, path

basedir = path.abspath(path.dirname(__file__))

POSTGRES_HOST = environ.get(‘POSTGRES_HOST’)
POSTGRES_DB = environ.get(‘POSTGRES_DB’)
POSTGRES_USER = environ.get(‘POSTGRES_USER’)
POSTGRES_PASSWORD = environ.get(‘POSTGRES_PASSWORD’)

 

En este archivo se crean las variables estáticas dentro de nuestro aplicativo Flask. En app.py se hace uso de este archivo para configurar la conectividad a la base de datos.

 

  • app.py

 

En este archivo se construye y se inicia nuestro aplicativo Flask. Examinemos el contenido:

 

import time
import os
import sys
from flask.helpers import make_response
from flask import Flask,jsonify,g
from flask import request,abort
from flask_restful import Api

app = Flask(__name__)
«»»Importar config params desde archivo config-params y environment .env.dev»»»
app.config.from_pyfile(‘config_params.py’)

with app.app_context():
import routes

api=Api(app)
routes.initialize_routes(api)

if __name__==»__main__»:
app.run(debug=False,host=’0.0.0.0′,port=5000)

 

De la lista de los comandos ‘import’, los dos más importantes son from flask import Flask,jsonify,g, que nos permite generar una instancia del aplicativo, y from flask_restful import Api, donde el paquete ‘flask_restful’ nos da una manera fácil de crear nuestros endpoints.

 

Con app = Flask(__name_₎, se crea una instancia del aplicativo Flask y después se ocupan las variables estáticas en config_params para crear un diccionario ‘app.config’ dentro de la instancia ‘app’ del aplicativo Flask. Las siguientes líneas hacen una importación e iniciación de nuestros endpoints. Veremos en más detalle cómo exactamente se definen los endpoints: 

 

with app.app_context():
import routes

api=Api(app)
routes.initialize_routes(api)

 

  • models.py

 

Este archivo está importado en el routes.py y de forma implícita en ‘app.py’ con import routes. Dado que vamos a hablar con una Base de Datos (BdeD) Postgresql, el aplicativo Flask tiene que saber en dónde está la BdeD, y qué exactamente tiene por dentro. Veremos que podemos generar las tablas de la BdeD, dentro de la misma sección, con solo un comando adicional:

 

from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow
from flask import current_app as app
from datetime import datetime

app.config[‘SQLALCHEMY_DATABASE_URI’]=’postgresql://{}:{}@{}/{}’.format(app.config[‘POSTGRES_USER’],app.config[‘POSTGRES_PASSWORD’],app.config[‘POSTGRES_HOST’],app.config[‘POSTGRES_DB’])
app.config[‘SQLALCHEMY_TRACK_MODIFICATIONS’]=False

db = SQLAlchemy(app)
ma = Marshmallow(app)

class Document(db.Model):
__tablename__ = ‘document’
id = db.Column(db.Integer, primary_key=True, autoincrement=True, unique=True)
name = db.Column(db.String(100), nullable=False)
type = db.Column(db.String(100), nullable=False)
source = db.Column(db.String(100), nullable=False)
blog = db.Column(db.String(100), nullable=False)
create_date = db.Column(db.DateTime, default=datetime.utcnow ,nullable=False)
active = db.Column(db.Boolean, nullable=False)

def __init__(self, name, type, source, blog, active):
self.name=name
self.type=type
self.source=source
self.blog=blog
self.active=active


class DocumentSchema(ma.SQLAlchemySchema):
class Meta:
model = Document

id = ma.auto_field()
name = ma.auto_field()
type = ma.auto_field()
source = ma.auto_field()
blog = ma.auto_field()
    create_date = ma.auto_field()
    active = ma.auto_field()

db.create_all()
document_schema=DocumentSchema() 

 

En Flask se ocupan dos paquetes: SQLAlchemy y Marshamallow, para interactuar con la BdeD y también generar lindos resultados de consultas para devolverse al cliente. Así que importamos estos dos paquetes.

 

¿Y qué hace from flask import current_app as app? Bueno, necesitamos algo del archivo ‘app.py’, podríamos hacer ‘import app’ y acceder a los objetos con el prefijo ‘app.<objeto>’, ¿no? El problema es que se corre el riesgo de hacer importaciones circulares.

 

Entonces Flask te ofrece un proxy a la instancia app en sí, llamado ‘current_app’. Este proxy permite, por ejemplo, acceder a las variables de ‘app.config’. ¡Pero hay un problema! El proxy ‘current_app’ solo está disponible cuando Flask está gestionando un request http.

 

¿Qué pasa si necesito acceso fuera, por ejemplo, cuando el aplicativo se está iniciando? Ocupamos ‘with app.app_context()’ en el archivo app.py al importar routes.py. ¿Y porqué aquí? Las siguientes líneas nos dicen por qué:

 

app.config[‘SQLALCHEMY_DATABASE_URI’]=’postgresql://{}:{}@{}/{}’.format(app.config[‘POSTGRES_USER’],app.config[‘POSTGRES_PASSWORD’],app.config[‘POSTGRES_HOST’],app.config[‘POSTGRES_DB’])

 

Necesitamos inciar la BdeD con las variables en ‘app.config’, pero no se esta procesando un request http. Finalmente, se inicia un ‘session’ con la Base de Datos. ¿Qué exactamente es un ‘session’? Esto requiere un blog aparte, pero es esencialmente un espacio en memoria para procesar y guardar las interacciones con la BdeD de forma eficiente y temporal.

 

db = SQLAlchemy(app)
ma = Marshmallow(app)

 

Después podríamos definir nuestra tabla dentro de la BdeD con el class Document(db.Model): y las líneas siguientes.

 

Se nota que el campo ‘id’ tiene la propiedad de ‘autoincrement=True, unique=True’, que nos sirve como ‘primary_key’ de la tabla. La próxima clase class DocumentSchema(ma.SQLAlchemySchema): hace uso del paquete Marshmallow, esencialmente definiendo el contenido del json que se devuelve al final del request, usando un método ‘auto_field()’ para encontrar el campo en base de su nombre en la clase.

 

La línea ‘db.create_all()’ nos genera todas las tablas declaradas en base del clase ‘db.Model’ en la BdeD y, al final, se crea una instancia de la schema Marshmallow del model ‘Document’ para usarse en la respuesta http.

 

Te puede interesar: Guía para entender la Arquitectura de Aplicaciones

 

  • routes.py

 

En este archivo finalmente nos encontramos con la definición de los endpoints a través del paquete ‘flask_restful’. Recuerda que en el archivo ‘app.py’ se hace una instancia de API de flask_restful y después se agregan los routes (endpoints).

 

api=Api(app)
routes.initialize_routes(api)

 

Flask_restful se ocupa el concepto de un ‘Resource’ que nos permite vincular un endpoint con su clase correspondiente. Este clase se encarga de los métodos http de post, get, put, delete etc. y se llaman tal cual dentro de la clase. Veamos el código:

 

import models
from flask import current_app as app
from flask_restful import Resource, reqparse


def initialize_routes(api):
«»»POST AND GET»»»

api.add_resource(DocumentsApi, ‘/blog/documents’)

api.add_resource(GetDocumentsApi, ‘/blog/documents/<integer:recordId>’)

«»»DELETE»»»
api.add_resource(DeleteDocumentsApi, ‘/blog/documents/<integer:relativePath>’)



@contextmanager
def session_scope():
    session = models.db.session()
    try:
        yield session
        session.flush()
        session.expunge_all()       
        session.commit()
    except:
        session.rollback()
        raise
    finally:
        session.close()

class DocumentsApi(Resource):


def post(self):

print(«Post data:{}».format(request.json), flush=True)
data=request.json
name=data.get(«name»,»Default name»)
type=data.get(«type»,»Default type»)
source=data.get(«name»,»Default source»)
blog=data.get(«blog»,»Default blog»)
        active=data.get(«active»,True)


        try:
            with session_scope() as session:
                new_document=models.Document(name,type,source,blog,active)
        except Exception as ex:
            return {«message»: «An error occurred updating the item.{}».format(str(ex))}, 500

        return models.document_schema.dump(new_document)


class GetDocumentsApi(Resource):


    def get(self,recordId):
        print(«Get recordId:{}».format(recordId), flush=True)
        try:
            with session_scope() as session:
                new_document=models.Document.query.filter_by(id=recordId).first()
        except Exception as ex:
            return {«message»: «An error occurred updating the item.{}».format(str(ex))}, 500

        return models.document_schema.dump(new_document)

 

Tomemos como ejemplo, el endpoint, que tiene una clase asociada DocumentsApi y que tiene un método ‘post’.

 

api.add_resource(DocumentsApi, ‘/blog/documents’)

 

Entonces este endpoint va a gestionar el verbo http ‘post’ para el url relativo blog/documents

 

data=request.json
name=data.get(«name»,»Default name»)
type=data.get(«type»,»Default type»)
source=data.get(«name»,»Default source»)
blog=data.get(«blog»,»Default blog»)}

 

Los datos del ‘post’ vienen en el diccionario ‘request.json’ y lo extraemos en variables individuales por claridad. Ahora, hay que graduarlos en la BdeD, ¿no? Bueno, cuando uno interactúa con un ‘session’ hay una secuencia de acciones como ‘add’ los datos, ‘commit’ los datos y finalmente ‘close’ el ‘session’.

 

Una manera bonita de hacer todo sin repetir líneas de código, es ocupar un contexto, dentro de lo cual, se agregan los datos del ‘post’. Se define un ‘session_scope’ con decorator @contextmanager así:

 

@contextmanager
def session_scope():
session = models.db.session()
try:
        yield session
        session.flush()
        session.expunge_all()       
        session.commit()
    except:
        session.rollback()
        raise
    finally:
        session.close()

 

Y se ocupa así:

 

  with session_scope() as session:
                new_document=models.Document(name,type,source,blog,active)
                session.add(new_document)

 

La primera línea nos devuelve un ‘session’ a través de ‘yield session’ en el contexto de arriba. El contexto nos ‘presta’ el ‘session’ por la duración del ‘with’ y luego sigue con un ‘flush’, ‘expunge’ y un ‘commit’.

 

El ‘flush’ empuja los datos a la BdeD, y nos genera nuestro campo ‘id’ con el próximo od en la secuencia. El ‘expunge_all’ nos da acceso de la instancia ‘new_document’ fuera del scope del ‘session’. (Recuerda que tenemos que devolver el documento al cliente, con ‘id’) y, finalmente se intenta un ‘commit’ para ‘concretar’ los datos en la BdeD y un ‘close’ para terminar el ‘session’.

 

Ahora tenemos datos en nuestra BdeD, y toda va bien. Podemos devolver en formato json con nuestra schema Marshmallow del modelo Document return models.document_schema.dump(new_document). 

 

La clase ‘GetDocumentsApi’ gestiona el request http GET aprovechando del poder de SqlAlchemy. El modelo en si nos permite hacer una consulta directamente con un filtro, usando el ‘recordId’ del request GET como parámetro en el método ‘get’ de la clase. De este manera podríamos construir endpoints arbitrarios, asegurándonos que los componentes del request url se convierten en parámetros del método ‘get’.

 

La clase ‘GetDocumentsApi’ gestiona el request http GET aprovechando el poder de SqlAlchemy. El modelo en sí, nos permite hacer una consulta directamente con un filtro, usando el ‘recordId’ del request GET como parámetro en el metodo ‘get’ de la clase.

 

De este manera podríamos construir endpoints arbitrarios, asegurándonos que los componentes del request url se convierten en parametros del metodo ‘get’.

 

models.Document.query.filter_by(id=recordId).first()

 

Docker Compose: (docker-compose.yml.)

 

Ahora tenemos que juntar los tres servicios y asegurar que se puedan comunicarse entre sí. Lo haremos a través de Docker Compose, una herramienta orquestador de contenedores Docker.

 

Usando un archivo declarativo .yml, se especifican las instrucciones para construir, levantar y conectar los contenedores individuales.

 

version: «3»
services:
nginx_service:
build:
context: web
volumes:
– ./web/nginx_conf:/etc/nginx/conf.d               
        ports:
          – 80:80
        networks:
            api_net:
                ipv4_address: 172.19.10.2        
        depends_on:
          – flask_service

    flask_service:
        build:
          context: backend   
        networks:
            api_net:
                ipv4_address: 172.19.10.3                     
        env_file:
          – .env
        depends_on:
            – postgres_service

    postgres_service:
        image: postgres:12.0-alpine
        stdin_open: true
        tty: true
        volumes:
            – postgres_data:/var/lib/postgresql/data/
        env_file:
          – .env     
        ports:
            – 5432:5432
        networks:
            api_net:
                ipv4_address: 172.19.10.4

volumes:
    postgres_data:

networks:
    api_net:
        ipam:
            driver: default
            config:
                – subnet: 172.19.10.0/29 

 

Una investigación profunda de Docker Compose está fuera del alcance de este blog, pero examinemos los servicios individuales, empezando con Nginx:

 

  nginx_service:
build:
context: web
        volumes:
          – ./web/nginx_conf:/etc/nginx/conf.d               
        ports:
          – 80:80
        networks:
            api_net:
                ipv4_address: 172.19.10.2        
        depends_on:
          – flask_service

 

1. Se declara un ‘build context’ que indica dónde se encuentra el Dockerfile para este componente.

 

2. Se hace un mapeo de la carpeta ‘web/nginx_conf’ a una carpeta del contenedor. La imagen base de Nginx usando en el Dockerfile, cuenta con un archivo de configuración ‘nginx.conf’ que busca en ‘/etc/nginx/conf.d’ para configuraciónes adicionales. Entonces, el demonio de nginx corriendo en el contenedor tiene acceso.

 

3. Después se hace un ‘bind’ con el puerto interno 80 del contenedor y el puerto 80 de la máquina en que corren los contenedores. El tráfico http llegando al puerto 80 externo, está dirigido al puerto 80 del contenedor.

 

4. Próximamente, se especifica que el contenedor forma parte del network ‘api_net’ con una dirección IP interna de 172.19.10.2

 

5. Finalmente, se introduce una dependencia sobre servicio ‘flask_service’ para que no se levante hasta que se detecte el contenedor del ‘flask_service’. 

 

La especificación del ‘flask_service’ tiene una sola diferencia con una sección ‘env_file’ en que se declara nuestras variables de entorno para que ‘flask_service’ pueda hablar con la BdeD.

 

En la declaración de ‘postgres_service’ se introduce otro tipo de ‘volume’ llamado ‘named volume’. A diferencia del ‘volume’ en el servicio ‘nginx_service’, un ‘named volume’ ocupa su propia carpeta dentro de la carpeta de almacenamiento de Docker y nos da más control a través del Docker CLI (Command Line Interface). 

 

volumes:
postgres_data:

networks:
api_net:
ipam:
driver: default
config:
– subnet: 172.19.10.0/29

 

Aquí se declara el ‘named volume’ postgres_data y el network interna ‘api_net’ con su subnet 172.19.10.0/29. Cualquier contenedor que pertenece a esta red, puede hablar directamente con los otros contenedores en la red, ocupando el nombre de servicio como nombre del host. Por eso pudimos hacer referencia a ‘flask_service’ cuando definimos la configuración, arriba de nginx.

 

En conclusión, hemos visto que es fácil y rápido levantar un API simple con el framework de Python, Flask y encapsularlo en un contenedor Docker.

 

Por seguridad, pusimos un ‘reverse-proxy’ de Nginx y ocupamos una BdeD de postgres como fuente de datos. Finalmente, usamos Docker Compose como orquestador para permitir que los tres contenedores puedan hablarse entre sí, a través de una red interna privada.

 

Puedes acceder al código aquí. Para correr, el comando es ‘docker-compose up -d –build’ y debería correr sin problemas en cualquiera máquina con Docker y Docker Compose instalados.

 

Para probar, te recomiendo Postman. Te dejo un ejemplo abajo. La dirección IP debiese reflejar la IP de máquina propia.

 

 

¡Esperamos que este blog te haya servido! Te invitamos a ser parte de nuestra comunidad de desarrolladores. Da clic en el botón y revisa todas nuestras vacantes disponibles. Sé un yellower.

 

Apiux Jobs
No Comments

Sorry, the comment form is closed at this time.