Desplegar una API con GraphQL en Azure Functions

Una vez que has probado tu API con GraphQL en local, llega el momento de desplegarla en un entorno remoto. En este artículo voy a continuar con el ejemplo del anterior, para mostrarte todas las modificaciones que necesitas para publicarlo en Azure Functions.

Modificación de la estructura del proyecto

Para adaptarme a la estructura que sigue un proyecto para Azure Functions, he modificado la ubicación de algunos archivos y he creado otros.

Estructura del proyecto de GraphQL para Azure Functions

Como ves, he creado una nueva carpeta llamada graphql y he incluido en ella la carpeta src con los resolvers y el archivo schema.js. En la raíz de graphql he ubicado además el index.js y un archivo necesario para Azure Functions llamado function.json, que veremos más adelante. Por último, he añadido dos scripts (CLI_CosmosDB.sh y CLI_deploy_on_azure.sh), y dos archivos de configuración para Azure Functions: host.json y local.settings.json. También he añadido el archivo .env para extraer la cadena de conexión de MongoDB.

Los archivos para Azure Functions

En este caso no estamos partiendo de un ejemplo desde cero, sino que ya tenemos una API, la cual queremos ahora adaptar para nuestro despliegue en Azure Functions. Este servicio necesita al menos los siguientes archivos:

host.json

Este archivo se encarga de la configuración a nivel de host, es decir a todas las funciones que estén dentro de un mismo servicio de Azure Function:

{
  "version": "2.0",
  "extensions": {
    "http": {
      "routePrefix": ""
    }
  }
}

Actualmente la versión más reciente es la 2.0. Por otro lado, he modificado el prefijo de la ruta (a nada) para que no añada /api/ antes del nombre de mi trigger. Por lo que en lugar de que quede http://mifunction/api/graphql se quede en http://mifunction/graphql.

function.json

Este archivo es el que define nuestro Trigger dentro de Azure Functions, que en nuestro caso es nuestra API GraphQL:

{
  "disabled": false,
  "entryPoint": "index",
  "bindings": [
    {
      "authLevel": "function",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": [
        "get",
        "post",
        "options"
      ]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "$return"
    }
  ]
}

Tiene que ser del tipo HttpTrigger y necesita de los métodos get, post y options para funcionar correctamente (options es necesario cuando se hace la comprobación de CORS). Es muy importante que en esta configuración el name de salida sea $return para que nuestra API funcione correctamente.

local.settings.json

Como su propio nombre indica, se utiliza para la configuración local. Sin embargo, nos va a ser útil durante la publicación, ya que aquí es donde se especifíca qué runtime queremos usar, en este caso el de Node.js, y func publish necesita saberlo.

{
    "IsEncrypted": false,
    "Values": {
        "FUNCTIONS_WORKER_RUNTIME": "node"
    }
}

Cambiar contenedor de Mongo por CosmosDB

Otra de las cosas que debemos tener en cuenta es que ya no podemos seguir trabajando con el contenedor local de MongoDB. Podríamos trasladarlo a un contenedor en Azure Container Instances, pero te recomiendo usar mejor CosmosDB, el cual es compatible con Mongo, y es gestionado. Para ello, he creado un script que crea este servicio paso a paso:

#variables
SUBSCRIPTION="YOUR_SUBSCRIPTION_NAME"
LOCATION="northeurope"
RESOURCE_GROUP="myfeedapp"
COSMOSDB="myfeedappdb"
#login
az login
#select subscription
az account set --subscription $SUBSCRIPTION
#create a resource group
az group create --name $RESOURCE_GROUP --location $LOCATION
#create a cosmosdb
az cosmosdb create --name $COSMOSDB --resource-group $RESOURCE_GROUP --kind MongoDB

Una vez generado, necesitas recuperar la cadena de conexión y añadirla al archivo .env. Lo más sencillo es que acudas al portal para recuperarla desde el apartado Quick start.

La que te marco en azul ya tiene todos los caracteres escaped. Si coges por ejemplo el valor de PRIMARY CONNECTION STRING es posible que tu código no funcione diciendo algo como «Password contains an illegal unescaped character».

Para generar algunos datos, te recomiendo que hagas uso de Mongo Seeding, que ya te mostré en un articulo anterior. Me he creado un npm script que lanza esta tarea:

    "seed-db": "seed -u YOUR_MONGO_CONNECTION_STRING_WITH_THE_DB --drop-database ./data/",

Modificar index.js

El último paso es modificar el archivo index.js, que era el que hacía de pegamento entre todas las partes que componen nuestra API con GraphQL:

const { ApolloServer } = require('apollo-server-azure-functions');
const { PubSub } = require('apollo-server');
const { MongoClient } = require('mongodb');
require('dotenv').config();
//1. Schema
const typeDefs = require('./src/schema');
//2. Resolvers
const Query = require('./src/resolvers/Query');
const Mutation = require('./src/resolvers/Mutation');
const Subscription = require('./src/resolvers/Subscription');
const pubsub = new PubSub();
const resolvers = {
    Query,
    Mutation,
    Subscription
};

const runHandler = (request, context, handler) =>
    new Promise((resolve, reject) => {
        const callback = (error, body) => (error ? reject(error) : resolve(body))
        handler(context, request, callback)
    })

module.exports = async (context, request) => {
    //We need an async function to wait to MongoDB connection
    const client = await MongoClient.connect(process.env.MONGO_DB_URL);
    db = client.db('realfoodingdb');
    const server = new ApolloServer({
        typeDefs,
        resolvers,
        context: {
            Products: db.collection('products'),
            pubsub
        }
    });
    const handler = server.createHandler({
        cors: {
            origin: 'http://localhost:3000',
            credentials: true,
        }
    });
    const response = await runHandler(request, context, handler)
    return response;
};

Ha sido necesario instalar dos paquetes: dotenv, para recuperar la cadena de conexión desde las variables de entorno, y apollo-server-azure-functions, que será el que utilicemos ahora para crear el servidor. En este caso, al tener la conexión con MongoDB he tenido que modificar el ejemplo que viene en la documentación de Apollo Server, ya que necesitaba gestionar un proceso asíncrono.

Desplegar tu API con GraphQL en Azure Functions

Una vez que ya tienes todo configurado, ya sólo queda desplegar tu API en Azure Functions. Este es el contenido de CLI_deploy_on_azure.sh:

#variables
SUBSCRIPTION="YOUR_SUBSCRIPTION_NAME"
LOCATION="northeurope"
RESOURCE_GROUP="myfeedapp"
STORAGE_ACCOUNT="myfeedappstore"
FUNCTION_APP="myfeedappfunc"
#login
az login
#select subscription
az account set --subscription "Microsoft Azure Internal Consumption"
#create a resource group
az group create --name $RESOURCE_GROUP --location $LOCATION
#create a storage account
az storage account create --name $STORAGE_ACCOUNT --location $LOCATION --resource-group $RESOURCE_GROUP --sku Standard_LRS
#create a function app
az functionapp create --resource-group $RESOURCE_GROUP --name $FUNCTION_APP --consumption-plan-location $LOCATION --runtime node --storage-account $STORAGE_ACCOUNT
#deploy on Azure
func azure functionapp publish $FUNCTION_APP --javascript
#added CORS configuration
az functionapp cors add --resource-group $RESOURCE_GROUP --name $FUNCTION_APP --allowed-origins http://localhost:3000
az functionapp cors show -g $RESOURCE_GROUP -n $FUNCTION_APP
#Delete all
az group delete --name $RESOURCE_GROUP --yes --no-wait

Es importante que habilites en el apartado CORS de tu función las URLs desde las cuales se quiere tener acceso a la API. De no ser así puedes obtener errores debido a ello. Al igual que en local, puedes hacer uso del Playground, pero ahora desde Azure Functions.

Playground de Apollo Server desde Azure Functions

Para poder probar tu función en local, antes de subirla a Azure, puedes hacerlo a través del siguiente script que te dejo en el package.json:

"start": "npm run mongo_docker && func start --javascript --cors *",

Como ves, en él indico que CORS debe aceptar cualquier origen para poder conectarnos desde nuestro cliente remoto.

El ejemplo completo lo tienes en mi GitHub.

¡Saludos!