Serverless Framework – créer une API REST avec DynamoDB
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 }
Sommaire
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.
Mauvais exemple d’utilisation du PUT pour faire un update. PUT remplace et ne « modifie/fusionne » pas.
On peut bien le voir dans le log:
{« text »: »Happy new year 2017! », »id »: »e3db1400-d13b-11e6-b0a2-2fed86a77637″, »updatedAt »:1483398521165, »author »: »me »}
Le « createdAt » a disparu.
Il faut préférer le fonction « replace », qui fonctionne différemment et ne modifie que les attributs fournis.
Merci pour cette précision !
Effectivement, le « replace » est plus adapté à l’exemple cité.
Pour être encore plus précis :
– pour le PUT REST, il faut faire un PutItem DynamoDB qui remplace complètement la ressource/ligne
– pour le PATCH REST, on peut utiliser le ReplaceItem de DynamoDB qui ne modifie que les attributs fournis
Si je trouve le temps, je complèterai l’exemple.