Serverless API Rest

Serverless es un concepto nacido en la Nube. Su gran éxito es ser una arquitectura para backend, del lado del servidor (Server-side), que no tiene estado, de ejecución rápida y que responde a eventos. Literalmente, se traduce como “sin servidor”. Y aquí es donde empieza el conflicto.

Wat if I told you wat?

Y es que serverless es de las tecnologías con nombre más traicionero que existen. Se traduce como “sin servidor”. Pero en realidad se refiere a un tipo de arquitectura en la que el servidor no es importante para el desarrollador. Se ejecutan en entornos aislados, como en contenedores específicos. Pero evidentemente, estos entornos y/o contenedores, se ejecutan en uno o varios servidores.

La forma más conocida de programar serverless es usar las plataformas de “Function as a Service” (FaaS). Este tipo de servicios está totalmente gestionado por el proveedor cloud que utilicemos. Y se caracterizan por basarse en funciones como unidad de trabajo. Estas funciones:

Hoy en día tenemos muchísimas variantes de estos servicios, aunque los más importantes son los que han implementado las “Big Three” de la nube: Amazon, Google y Microsoft. Y como todos sabemos de qué pie cojeo, pues vamos a hablar de la implementación de Microsoft: Azure Functions.

Mi primera Function

La forma más fácil de programar para Azure Functions es usar un Visual Studio Enterprise o Community. Desde ahí crearemos un nuevo proyecto, seleccionaremos como tipo “Cloud”, y dentro de las diferentes propuestas de plantilla, la denominada como “Azure Functions”.

New Visual Studio Enterprise Project

Entonces nos pedirá cierta configuración adicional para el proyecto. La primera será seleccionar el Framework, que en este caso nos hemos decantado por “Azure Functions v2 (.Net Core)”. Después, como vamos a implementar una API Rest, hemos escogido “Http trigger” como desencadenador por defecto. Y finalmente, en el nivel de acceso le hemos puesto “Anonymous”, para que sea pública y no haga falta autenticarse.

New Visual Studio Enterprise Azure Functions Project settings

Si ahora ejecutamos el proyecto, nos aparecerá una consola donde se indica una URL donde se ha montado nuestra Function en la máquina local. Para lanzarla, abriremos el navegador e introduciremos esa URL con el parámetro “name” y un valor. En mi caso ha sido: “http://localhost:7071/api/Function1?name=MaxPower”.

Azure Functions primera ejecución

Está muy bien tener resultados con solo 30’’ de clics en la pantalla :). Os prometo que no os robaré mucho más tiempo implementando una API Rest.

Azure Function API

Si queremos una API, es posible que necesitemos previamente un tipo de recurso que utilizar en la misma. En este caso hemos decidido usar un “Todo”, para implementar una API de gestión de tareas:

public class Todo
{
    public string Id { get; set; } = Guid.NewGuid().ToString("n");

    public DateTime Created { get; set; } = DateTime.UtcNow;

    public string Text { get; set; }

    public bool Done { get; set; }
}

Para almacenar este tipo de datos nos hemos decantado por un Sql Server. Por cuestiones de facilidad, hemos añadido una referencia al paquete de Nuget de Dapper. De esta forma podremos usar sus características de mapping con las conexiones con el motor de base de datos.

Así que hacer una función que lea de nuestra base datos y devuelva esos datos en formato API Rest será un código tan simple como el siguiente:

public static class TodoApi
{
    [FunctionName("Todo_Get")]
    public static async Task<IActionResult> SelectAsync(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todos")]
        HttpRequest req, 
        ILogger log)
    {
        var cnnString = "my_connection_string";
        using (var connection = new SqlConnection(cnnString))
        {
            connection.Open();
            var todos = await connection.QueryAsync<Todo>("select Id, Created, Text, Done from dbo.Todos");
            // QueryAsync<T> is a Dapper function. It maps the result of the query in to a IEnumerable<T>

            if (todos.Count() == 0) return new EmptyResult(); // status code 204 No Content
            return new OkObjectResult(todos); // status code 200 Ok + body { ...todos }
        }
    }
}

Uno de los típicos retos que vamos a encontrar usando Microsoft Azure, es que nos da la posibilidad de usar settings de aplicación configurados en el propio entorno de Azure. De esta forma las aplicaciones no tienen por qué conocer los datos de conexión de, por ejemplo, una base de datos. Esa información se la proveerá el propio entorno.

En el caso de Azure Functions, existen los Application Settings, que pueden ir en archivos de configuración json o también en forma de variables de entorno. Para poder recoger estos valores de cualquiera de las dos fuentes, tendremos que instanciar un “ConfigurationBuilder” para así poder crear un “IConfigurationRoot” de donde leer la información. A este fin crearemos la siguiente función que devolverá una cadena de conexión llamada “DefaultConnection” por defecto:

private static string GetConnectionString(ILogger log, ExecutionContext context)
{
    var config = new ConfigurationBuilder()
                    .SetBasePath(context.FunctionAppDirectory)
                    .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)
                    .AddEnvironmentVariables()
                    .Build();

    return config.GetConnectionString("DefaultConnection");
}

Después en el archivo del proyecto “local.settings.json”, añadiremos una sección nueva con las cadenas de conexión que usamos de forma local:

"ConnectionStrings": {
    "DefaultConnection": "my_connection_string"
}

Y finalmente modificaremos nuestro código para que la “Function” recupere el “ExecutionContext” (añadiéndolo como parámetro) y para que la cadena de conexión la resuelva usando el código que hemos visto:

public static class TodoApi
{
    [FunctionName("Todo_Get")]
    public static async Task<IActionResult> SelectAsync(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todos")]
        HttpRequest req, 
        ILogger log, 
        ExecutionContext context /*Added ExecutionContext parameter*/)
    {
        var cnnString = GetConnectionString(log, context); // call GetConnectionString(..) method
        using (var connection = new SqlConnection(cnnString))
        {
            connection.Open();
            var todos = await connection.QueryAsync<Todo>("select Id, Created, Text, Done from dbo.Todos");

            if (todos.Count() == 0) return new EmptyResult();
            return new OkObjectResult(todos); 
        }
    }
}

Por último, y con el fin de conseguir tener una API completa, tendremos que ser capaces de leer información de la petición HTTP. Hay dos vías:

Url.Path parameter

Para leer un parámetro que nos encontramos en la propia URL de la API, lo primero que tendremos que hacer es añadirlo a la ruta de la función. Esto se especifica en el atributo “HttpTrigger”, en la variable “Route”. De la misma forma que haríamos con Asp.Net, indicando el nombre de la variable entre llaves:

[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todos/{id}")]
HttpRequest req

Y para recogerlo, simplemente añadimos el parámetro a la función:

[FunctionName("Todo_GetById")]
public static async Task<IActionResult> SelectByIdAsync(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todos/{id}")] /* the 'id' parameter in the Route*/
    HttpRequest req, 
    ILogger log, 
    ExecutionContext context, 
    string id /*the 'id' parameter as a function parameter*/)
{
    // ...
}

Request.Body

Por otro lado, si queremos recoger el valor del cuerpo de la petición HTTP, tendremos que usar un deserializador de json. En nuestro proyecto ya tendremos incluido el NewtonSoft.Json. Así que deberíamos serializarlo leyéndolo de la Request:

[FunctionName("Todo_Create")]
public static async Task<IActionResult> CreateAsync(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "todos")]
    HttpRequest req,
    ILogger log,
    ExecutionContext context)
{
    var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
    var input = JsonConvert.DeserializeObject<Todo>(requestBody);

    // ...
}

API Rest

Conociendo estos detalles, ya no tendremos problemas en implementar una versión completa de una API Rest basada en Azure Functions y con una base de datos hospedada en un Azure Sql Database. La podéis ver a continuación:

public static class TodoApi
{
    [FunctionName("Todo_Get")]
    public static async Task<IActionResult> SelectAsync(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todos")]
        HttpRequest req,
        ILogger log,
        ExecutionContext context)
    {
        var cnnString = GetConnectionString(log, context);
        using (var connection = new SqlConnection(cnnString))
        {
            connection.Open();
            var todos = await connection.QueryAsync<Todo>("select Id, Created, Text, Done from dbo.Todos");

            if (todos.Count() == 0) return new EmptyResult();
            return new OkObjectResult(todos);
        }
    }

    [FunctionName("Todo_GetById")]
    public static async Task<IActionResult> SelectByIdAsync(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todos/{id}")]
        HttpRequest req,
        ILogger log,
        ExecutionContext context,
        string id)
    {
        var cnnString = GetConnectionString(log, context);
        using (var connection = new SqlConnection(cnnString))
        {
            connection.Open();
            var todos = await connection.QueryAsync<Todo>("select Id, Created, Text, Done from dbo.Todos where Id = @id", new { id });

            if (todos.Count() == 0) return new NotFoundResult();
            return new OkObjectResult(todos.First());
        }
    }

    [FunctionName("Todo_Create")]
    public static async Task<IActionResult> CreateAsync(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "todos")]
        HttpRequest req,
        ILogger log,
        ExecutionContext context)
    {
        var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        var input = JsonConvert.DeserializeObject<Todo>(requestBody);

        var cnnString = GetConnectionString(log, context);
        using (var connection = new SqlConnection(cnnString))
        {
            connection.Open();
            await connection.ExecuteAsync("insert into dbo.Todos (Id, Created, Text, Done) values (@Id, @Created, @Text, @Done)", input);

            var location = $"{req.Scheme}://{req.Host}{req.Path}{req.QueryString}/{input.Id}";
            return new CreatedResult(location, input);
        }
    }

    [FunctionName("Todo_Update")]
    public static async Task<IActionResult> UpdateAsync(
        [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "todos/{id}")]
        HttpRequest req,
        ILogger log,
        ExecutionContext context, 
        string id)
    {
        var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
        var input = JsonConvert.DeserializeObject<Todo>(requestBody);

        var cnnString = GetConnectionString(log, context);
        using (var connection = new SqlConnection(cnnString))
        {
            connection.Open();
            await connection.ExecuteAsync("update dbo.Todos set Text = @Text, Done = @Done where Id = @Id", input);

            return new OkObjectResult(input);
        }
    }

    [FunctionName("Todo_Delete")]
    public static async Task<IActionResult> DeleteAsync(
        [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "todos/{id}")]
        HttpRequest req,
        ILogger log,
        ExecutionContext context,
        string id)
    {
        var cnnString = GetConnectionString(log, context);
        using (var connection = new SqlConnection(cnnString))
        {
            connection.Open();
            await connection.ExecuteAsync("delete from dbo.Todos where Id = @id", new {  id });

            return new OkObjectResult(id);
        }
    }

    private static string GetConnectionString(ILogger log, ExecutionContext context)
    {
        var config = new ConfigurationBuilder()
                        .SetBasePath(context.FunctionAppDirectory)
                        .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true)
                        .AddEnvironmentVariables()
                        .Build();

        return config.GetConnectionString("DefaultConnection");
    }
}

Conclusiones

Decidir desarrollar una arquitectura basada en serverless va a proporcionarnos muchas ventajas:

Pero siempre tendremos que conocer ciertas limitaciones:

Así que queda en los problemas y consideraciones de cada proyecto el aplicar este modelo o no.