Lors du précédent article, nous avons vu comment créer une API serverless avec Serverless Framework. Pour implémenter une API REST, il faut pouvoir persister les données dans une base de données. AWS propose une base de données NoSQL scalable sans avoir à gérer d’instance. En d’autres termes, une base de données dans l’esprit serverless : DynamoDB.

Au travers des templates Cloud Formation, Serverless Framework supporte DynamoDB. Nous allons donc voir comment créer étape par étape une API REST de messages. Ces messages auront un auteur "author", un texte "text" et quelques attributs techniques :

{
  "author":"me",
  "text":"Happy new year!",
  "id":"e3db1400-d13b-11e6-b0a2-2fed86a77637",
  "createdAt":1483396583744,
  "updatedAt":1483396583744
}

Création du projet

Dans un premier temps, laissons Serverless Framework créer un template de projet et ajoutons quelques dépendances NPM pour simplifier le développement.

# création du projet
$ serverless create --template aws-nodejs --name restapi
Serverless: Creating new Serverless service…
 _______                             __
|   _   .-----.----.--.--.-----.----|  .-----.-----.-----.
|   |___|  -__|   _|  |  |  -__|   _|  |  -__|__ --|__ --|
|____   |_____|__|  \___/|_____|__| |__|_____|_____|_____|
|   |   |             The Serverless Application Framework
|       |                           serverless.com, v1.0.3
 -------'

Serverless: Successfully created service with template: "aws-nodejs"

# ajout des packages aws-sdk et uuid
$ echo '{"name":"restapi"}' > package.json
$ npm install --save aws-sdk uuid
[...]
restapi@ /home/pnom/projects/restapi
+-- aws-sdk@2.7.20
`-- uuid@3.0.1

npm WARN EPACKAGEJSON restapi@ No description
npm WARN EPACKAGEJSON restapi@ No repository field.
npm WARN EPACKAGEJSON restapi@ No license field.
npm info ok

DynamoDB

Le projet étant initialisé, il est possible d’ajouter la définition de la table messages à créer dans DynamoDB en ajoutant le template ci-dessous à la fin du fichier serverless.yml :

resources:
  Resources:
    MessagesTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: messages
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

Il est également nécessaire de donner les droits suffisants pour que les fonctions lambdas puissent accéder à DynamoDB. Pour cela, il faut ajouter une section dédiée à la configuration d’IAM dans la section provider du fichier serverless.yml :

  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:DescribeTable
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: "arn:aws:dynamodb:us-east-1:*:*"

POST /messages

Maintenant que notre “infrastructure” est prête, nous pouvons passer à la définition et l’implémentation de notre API. Commençons par la création de ressource messages.

La définition de cette URI se fait dans le fichier serverless.yml :

functions:
  create:
    handler: messages/create.create
    events:
     - http:
         path: messages
         method: post
         cors: true

L’implémentation de la fonction Lambda se trouve dans le fichier messages/create.js et ressemble au code suivant :

'use strict';

const uuid = require('uuid');
const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.create = (event, context, callback) => {
  const timestamp = new Date().getTime();
  const data = JSON.parse(event.body);

  if (typeof data.text !== 'string' || typeof data.author !== 'string') {
  	console.error('Validation failed');
  	callback(new Error('Couldn\'t create the message'));
  	return;
  }

  const params = {
    TableName: 'messages',
    Item: {
      id: uuid.v1(),
      text: data.text,
      author: data.author,
      createdAt: timestamp,
      updatedAt: timestamp
    }
  }

  dynamoDb.put(params, (error, result) => {
  	if (error) {
  	  console.error(error);
  	  callback(new Error('Couldn\'t create the message'));
  	  return;
  	}

  	const response = {
  	  statusCode: 201,
  	  body: JSON.stringify(result.Item)
  	}
  	callback(null, response);
  })
}

Il ne reste plus qu’à déployer notre API REST et tester le POST :

# déploiement de l'application
$ serverless deploy
Serverless: Creating Stack…
Serverless: Checking Stack create progress…
.....
Serverless: Stack create finished…
Serverless: Packaging service…
Serverless: Uploading CloudFormation file to S3…
Serverless: Uploading service .zip file to S3…
Serverless: Updating Stack…
Serverless: Checking Stack update progress…
.................................
Serverless: Stack update finished…

Service Information
service: restapi
stage: dev
region: us-east-1
api keys:
  None
endpoints:
  POST - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
functions:
  restapi-dev-create: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-create

# appel du POST /messages
$ curl -X POST https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages --data '{"author":"me","text":"Happy new year!"}'

# vérification dans DynamoDB de la présence du message
$ aws dynamodb scan --table-name messages
{
    "Count": 1,
    "Items": [
        {
            "text": {
                "S": "Happy new year!"
            },
            "updatedAt": {
                "N": "1483317983656"
            },
            "id": {
                "S": "e28d7280-d084-11e6-8a1d-6d3837809c56"
            },
            "createdAt": {
                "N": "1483317983656"
            },
            "author": {
                "S": "me"
            }
        }
    ],
    "ScannedCount": 1,
    "ConsumedCapacity": null
}

Notre API est accessible et permet d’écrire dans DynamoDB. Elle est donc fonctionnelle et il ne reste plus qu’à implémenter les autres verbes pour la ressource messages.

GET /messages

Cette URI est définie dans la section functions du fichier serverless.yml :

  list:
    handler: messages/list.list
    events:
     - http:
         path: messages
         method: post
         cors: true

L’implémentation de la fonction Lambda est à placer dans le fichier messages/list.js :

'use strict';

const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();
const params = {
  TableName: 'messages'
}

module.exports.list = (event, context, callback) => {
  dynamoDb.scan(params, (error, result) => {
    if (error) {
      console.error(error);
      callback(new Error('Couldn\'t fetch the messsages'));
      return;
    }

    const response = {
      statusCode: 200,
      body: JSON.stringify(result.Items)
    };
    callback(null, response);
  });
}

Redéployons et testons :

# redéploiement de l'application
$ serverless deploy
Serverless: Packaging service...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading service .zip file to S3 (3.56 MB)...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
............................
Serverless: Stack update finished...
Serverless: Removing old service versions...
Service Information
service: restapi
stage: dev
region: us-east-1
api keys:
  None
endpoints:
  POST - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
  GET - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
functions:
  restapi-dev-list: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-list
  restapi-dev-create: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-create

# appel du GET /messages
$ curl https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
[{"createdAt":1483317983656,"text":"Happy new year!","id":"e28d7280-d084-11e6-8a1d-6d3837809c56","updatedAt":1483317983656,"author":"me"}]

GET /messages/id

Nous savons lister tous nos messages. Passons à la récupération d’une ressource en particulier.

Comme précédemment, la définition de l’URI se passe dans le fichier serverless.yml :

  get:
    handler: messages/get.get
    events:
     - http:
         path: messages/{id}
         method: get
         cors: true

De même, l’implémentation de la fonction Lambda est placer dans le fichier messages/get.js :

'use strict';

const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.get = (event, context, callback) => {
  const params = {
  	TableName: 'messages',
  	Key: {
  	  id: event.pathParameters.id
  	}
  };

  dynamoDb.get(params, (error, result) => {
  	if (error) {
  	  console.error(error);
  	  callback(new Error('Couldn\'t fetch the message item'));
  	  return;
  	}

  	const response = {
  	  statusCode: 200,
  	  body: JSON.stringify(result.Item)
  	};
  	callback(null, response);
  })
}

Vous savez maintenant comment déployer et tester cette nouvelle URI :

# déploiment de la nouvelle version de l'application
$ serverless deploy
Serverless: Packaging service...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading service .zip file to S3 (3.56 MB)...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
......................................
Serverless: Stack update finished...
Service Information
service: restapi
stage: dev
region: us-east-1
api keys:
  None
endpoints:
  POST - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
  GET - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
  GET - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/{id}
functions:
  restapi-dev-get: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-get
  restapi-dev-list: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-list
  restapi-dev-create: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-create

# appel du GET /messages/id
$ curl https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/e28d7280-d084-11e6-8a1d-6d3837809c56
{"createdAt":1483396583744,"text":"Happy new year!","id":"e3db1400-d13b-11e6-b0a2-2fed86a77637","updatedAt":1483396583744,"author":"me"}

Notre API commence à prendre forme. Nous savons créer, lister et consulter des ressources. Passons à la modification des données.

PUT /messages/id

Comme précédemment, la définition de l’URI se fait dans le fichier serverless.yml :

  update:
    handler: messages/update.update
    events:
     - http:
         path: messages/{id}
         method: put
         cors: true

Et l’implémentation doit être mise dans le fichier messages/update.js :

'use strict';

const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.update = (event, context, callback) => {
  const timestamp = new Date().getTime();
  const data = JSON.parse(event.body);

  if (typeof data.text !== 'string' || typeof data.author !== 'string') {
  	console.error('Validation error');
  	callback(new Error('Couldn\'t update the message item'));
  	return;
  }

  const params = {
  	TableName: 'messages',
  	Item: {
  	  id: event.pathParameters.id,
  	  text: data.text,
  	  author: data.author,
  	  updatedAt: timestamp
  	}
  }

  dynamoDb.put(params, (error, result) => {
    if (error) {
      console.error(error);
      callback(new Error('Couldn\'t update the message item'));
      return;
    }

    const response = {
      statusCode: 200,
      body: JSON.stringify(result.Item)
    }
    callback(null, response);
  });
}

Après avoir redéployé l’application, nous allons essayer le modifier le texte d’un message de cette façon :

# redéploiement de l'application
$ severless deploy
Serverless: Packaging service...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading service .zip file to S3 (3.56 MB)...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
........................................
Serverless: Stack update finished...
Service Information
service: restapi
stage: dev
region: us-east-1
api keys:
  None
endpoints:
  POST - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
  GET - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
  GET - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/{id}
  PUT - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/{id}
functions:
  restapi-dev-update: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-update
  restapi-dev-get: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-get
  restapi-dev-list: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-list
  restapi-dev-create: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-create

# appel du PUT /messages/id
$ curl -X PUT https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/e3db1400-d13b-11e6-b0a2-2fed86a77637 --data '{"author":"me","text":"Happy new year 2017!"}'

# vérificaton du nouveau texte
$ curl https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/e3db1400-d13b-11e6-b0a2-2fed86a77637
{"text":"Happy new year 2017!","id":"e3db1400-d13b-11e6-b0a2-2fed86a77637","updatedAt":1483398521165,"author":"me"}

Notre message a bien été modifié. Passons au dernier verbe à implémenter pour supporter la suppression.

DELETE /messages/id

Faut-il encore rappeler que la définition de l’URI se fait dans le fichier serverless.yml ?

  delete:
    handler: messages/delete.delete
    events:
     - http:
         path: messages/{id}
         method: delete
         cors: true

Le code de la fonction Lambda à mettre dans le fichier messages/delete.js est très simple :

'use strict';

const AWS = require('aws-sdk');
const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.delete = (event, context, callback) => {
  const params = {
  	TableName: 'messages',
  	Key: {
  	  id: event.pathParameters.id
  	}
  };

  dynamoDb.delete(params, (error) => {
  	if (error) {
  	  console.error(error);
  	  callback(new Error('Couldn\'t remove message item'));
  	  return;
  	}

  	const response = {
  		statusCode: 204,
  		body: JSON.stringify({})
  	};
  	callback(null, response);
  });
};

Déployons une dernière fois l’application pour tester la suppression d’une ressource :

# déploiment de l'appliation
$ serverless deploy
Serverless: Packaging service...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading service .zip file to S3 (3.56 MB)...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............................................
Serverless: Stack update finished...
Serverless: Removing old service versions...
Service Information
service: restapi
stage: dev
region: us-east-1
api keys:
  None
endpoints:
  POST - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
  GET - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
  GET - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/{id}
  PUT - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/{id}
  DELETE - https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/{id}
functions:
  restapi-dev-update: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-update
  restapi-dev-get: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-get
  restapi-dev-list: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-list
  restapi-dev-create: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-create
  restapi-dev-delete: arn:aws:lambda:us-east-1:client-id:function:restapi-dev-delete

# liste les ressources
$ curl https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/e3db1400-d13b-11e6-b0a2-2fed86a77637
{"text":"Happy new year 2017!","id":"e3db1400-d13b-11e6-b0a2-2fed86a77637","updatedAt":1483398521165,"author":"me"}

# appel du DELETE /messages/id
$ curl -X DELETE https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages/e3db1400-d 13b-11e6-b0a2-2fed86a77637
{}

# vérification de la suppression du message
$ curl https://api-id.execute-api.us-east-1.amazonaws.com/dev/messages
[]

Avec l’implémentation de notre dernier verbe, notre API REST est complètement fonctionnelle.

Nettoyage

Après avoir testé l’intégration de DynamoDB et AWS Lambda, un petit nettoyage s’impose :

$ serverless remove
Serverless: Getting all objects in S3 bucket…
Serverless: Removing objects in S3 bucket…
Serverless: Removing Stack…
Serverless: Checking Stack removal progress…
......................
Serverless: Stack removal finished...
$ aws logs delete-log-group --log-group-name "/aws/lambda/restapi-dev-create"
$ aws logs delete-log-group --log-group-name "/aws/lambda/restapi-dev-list"
$ aws logs delete-log-group --log-group-name "/aws/lambda/restapi-dev-get"
$ aws logs delete-log-group --log-group-name "/aws/lambda/restapi-dev-update"
$ aws logs delete-log-group --log-group-name "/aws/lambda/restapi-dev-delete"

A noter que si dans votre template Cloud Formation vous avez utilisé la propriété DeletionPolicy: Retain, la table DynamoDB ne sera pas supprimée. Si nécessaire, il faut la supprimer à la main :

$ aws dynamodb delete-table --table-name messages

Conclusion

Cet exemple simpliste d’API REST montre la simplicité avec laquelle il est possible de créer et déployer une telle API avec une architecture serverless. Nous noterons tout de même le côté répétitif de la définition des API dans le fichier serverless.yml.
A savoir qu’il est également possible de tester les fonctions en local avec un DynamoDB local via le plugin serverless-dynamodb-local.