From 5b66e17b32d4364272e71750ec002f5e94c1a259 Mon Sep 17 00:00:00 2001 From: bertrand <bpinel@ippon.fr> Date: Sun, 2 Dec 2018 11:42:36 +0100 Subject: [PATCH] Add terraform script to build AWS platform --- README.md | 4 + cloud/cloudformation/api.yaml | 388 ++++++++++++++++++ ...mbda-jsonapi.js => lambda-jsonapi-test.js} | 0 cloud/lambda/index.js | 0 cloud/lambda/lambda-jsonapi.js | 221 ++++++++++ cloud/terraform/README.md | 35 ++ cloud/terraform/api-gateway.tf | 244 +++++++++++ cloud/terraform/dynamodb.tf | 16 + cloud/terraform/iam.tf | 30 ++ cloud/terraform/lambda.tf | 36 ++ cloud/terraform/template.vm | 35 ++ 11 files changed, 1009 insertions(+) create mode 100644 cloud/cloudformation/api.yaml rename cloud/{lambda-jsonapi.js => lambda-jsonapi-test.js} (100%) delete mode 100644 cloud/lambda/index.js create mode 100644 cloud/lambda/lambda-jsonapi.js create mode 100644 cloud/terraform/README.md create mode 100644 cloud/terraform/api-gateway.tf create mode 100644 cloud/terraform/dynamodb.tf create mode 100644 cloud/terraform/iam.tf create mode 100644 cloud/terraform/lambda.tf create mode 100644 cloud/terraform/template.vm diff --git a/README.md b/README.md index 18a0394..e245833 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,17 @@ Usage ------------------------------------------------------------------------------ For generating a application, first use the standard command : +``` ember new test-ember-aws-ehipster --no-welcome cd test-ember-aws-ehipster ember install ember-aws-ehipster +``` Then generate a few entity through provided blueprints using the same syntax as the model blueprint : +``` ember g entity-factory blog title:string content:string order:number isVisible:boolean ember g entity-factory post title:string content:string order:number visible:boolean +``` Contributing ------------------------------------------------------------------------------ diff --git a/cloud/cloudformation/api.yaml b/cloud/cloudformation/api.yaml new file mode 100644 index 0000000..03973b9 --- /dev/null +++ b/cloud/cloudformation/api.yaml @@ -0,0 +1,388 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Ember EHipster API +Parameters: + LambdaFunctionName: + Type: String + Default: "EmberEHipsterFunction" + Description: "Name of the lambda function" + AppClientName: + Type: String + Default: "EmberEHipsterWebClient" + Description: "Cognito user pools app client name" +Resources: + ApiGatewayApi: + Type: AWS::Serverless::Api + DependsOn: CognitoUserPool + Properties: + StageName: Prod + DefinitionBody: + swagger: "2.0" + info: + version: "2017-02-24T04:09:00Z" + title: "EmberEHipsterAPI" + basePath: "/Prod" + schemes: + - "https" + paths: + "/{type}": + get: + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "user" + in: "header" + required: false + type: "string" + - name: "id" + in: "query" + required: false + type: "string" + responses: + "200": + description: "200 response" + schema: + $ref: "#/definitions/Empty" + headers: + Access-Control-Allow-Origin: + type: "string" + security: + - EmberEHipster: [] + x-amazon-apigateway-integration: + responses: + default: + statusCode: "200" + responseParameters: + method.response.header.Access-Control-Allow-Origin: "'*'" + requestTemplates: + application/json: "## See http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html\n\ + ## This template will pass through all parameters including path, querystring,\ + \ header, stage variables, and context through to the integration endpoint\ + \ via the body/payload\n#set($allParams = $input.params())\n{\n\"body-json\"\ + \ : $input.json('$'),\n\"params\" : {\n#foreach($type in $allParams.keySet())\n\ + \ #set($params = $allParams.get($type))\n\"$type\" : {\n #foreach($paramName\ + \ in $params.keySet())\n \"$paramName\" : \"$util.escapeJavaScript($params.get($paramName))\"\ + \n #if($foreach.hasNext),#end\n #end\n}\n #if($foreach.hasNext),#end\n\ + #end\n},\n\"stage-variables\" : {\n#foreach($key in $stageVariables.keySet())\n\ + \"$key\" : \"$util.escapeJavaScript($stageVariables.get($key))\"\n \ + \ #if($foreach.hasNext),#end\n#end\n},\n\"context\" : {\n \"user\"\ + \ : \"$context.authorizer.claims.sub\",\n\t\"email\" : \"$context.authorizer.claims.email\"\ + ,\n \"account-id\" : \"$context.identity.accountId\",\n \"api-id\"\ + \ : \"$context.apiId\",\n \"api-key\" : \"$context.identity.apiKey\"\ + ,\n \"authorizer-principal-id\" : \"$context.authorizer.principalId\"\ + ,\n \"caller\" : \"$context.identity.caller\",\n \"cognito-authentication-provider\"\ + \ : \"$context.identity.cognitoAuthenticationProvider\",\n \"cognito-authentication-type\"\ + \ : \"$context.identity.cognitoAuthenticationType\",\n \"cognito-identity-id\"\ + \ : \"$context.identity.cognitoIdentityId\",\n \"cognito-identity-pool-id\"\ + \ : \"$context.identity.cognitoIdentityPoolId\",\n \"httpMethod\" :\ + \ \"$context.httpMethod\",\n \"stage\" : \"$context.stage\",\n \"\ + source-ip\" : \"$context.identity.sourceIp\",\n \"user\" : \"$context.identity.user\"\ + ,\n \"user-agent\" : \"$context.identity.userAgent\",\n \"user-arn\"\ + \ : \"$context.identity.userArn\",\n \"request-id\" : \"$context.requestId\"\ + ,\n \"resource-id\" : \"$context.resourceId\",\n \"resource-path\"\ + \ : \"$context.resourcePath\"\n }\n}\n" + uri: !Join [ "", [ "arn:aws:apigateway:", !Ref "AWS::Region", ":lambda:path/2015-03-31/functions/arn:aws:lambda:", !Ref "AWS::Region", ":", !Ref "AWS::AccountId", ":function:", !Ref "LambdaFunctionName", "/invocations" ] ] + passthroughBehavior: "when_no_templates" + httpMethod: "POST" + contentHandling: "CONVERT_TO_TEXT" + type: "aws" + post: + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "user" + in: "header" + required: false + type: "string" + responses: + "200": + description: "200 response" + schema: + $ref: "#/definitions/Empty" + headers: + Access-Control-Allow-Origin: + type: "string" + security: + - EmberEHipster: [] + x-amazon-apigateway-integration: + responses: + default: + statusCode: "200" + responseParameters: + method.response.header.Access-Control-Allow-Origin: "'*'" + requestTemplates: + application/json: "## See http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html\n\ + ## This template will pass through all parameters including path, querystring,\ + \ header, stage variables, and context through to the integration endpoint\ + \ via the body/payload\n#set($allParams = $input.params())\n{\n\"body-json\"\ + \ : $input.json('$'),\n\"params\" : {\n#foreach($type in $allParams.keySet())\n\ + \ #set($params = $allParams.get($type))\n\"$type\" : {\n #foreach($paramName\ + \ in $params.keySet())\n \"$paramName\" : \"$util.escapeJavaScript($params.get($paramName))\"\ + \n #if($foreach.hasNext),#end\n #end\n}\n #if($foreach.hasNext),#end\n\ + #end\n},\n\"stage-variables\" : {\n#foreach($key in $stageVariables.keySet())\n\ + \"$key\" : \"$util.escapeJavaScript($stageVariables.get($key))\"\n \ + \ #if($foreach.hasNext),#end\n#end\n},\n\"context\" : {\n \"user\"\ + \ : \"$context.authorizer.claims.sub\",\n\t\"email\" : \"$context.authorizer.claims.email\"\ + ,\n \"account-id\" : \"$context.identity.accountId\",\n \"api-id\"\ + \ : \"$context.apiId\",\n \"api-key\" : \"$context.identity.apiKey\"\ + ,\n \"authorizer-principal-id\" : \"$context.authorizer.principalId\"\ + ,\n \"caller\" : \"$context.identity.caller\",\n \"cognito-authentication-provider\"\ + \ : \"$context.identity.cognitoAuthenticationProvider\",\n \"cognito-authentication-type\"\ + \ : \"$context.identity.cognitoAuthenticationType\",\n \"cognito-identity-id\"\ + \ : \"$context.identity.cognitoIdentityId\",\n \"cognito-identity-pool-id\"\ + \ : \"$context.identity.cognitoIdentityPoolId\",\n \"httpMethod\" :\ + \ \"$context.httpMethod\",\n \"stage\" : \"$context.stage\",\n \"\ + source-ip\" : \"$context.identity.sourceIp\",\n \"user\" : \"$context.identity.user\"\ + ,\n \"user-agent\" : \"$context.identity.userAgent\",\n \"user-arn\"\ + \ : \"$context.identity.userArn\",\n \"request-id\" : \"$context.requestId\"\ + ,\n \"resource-id\" : \"$context.resourceId\",\n \"resource-path\"\ + \ : \"$context.resourcePath\"\n }\n}\n" + uri: !Join [ "", [ "arn:aws:apigateway:", !Ref "AWS::Region", ":lambda:path/2015-03-31/functions/arn:aws:lambda:", !Ref "AWS::Region", ":", !Ref "AWS::AccountId", ":function:", !Ref "LambdaFunctionName", "/invocations" ] ] + passthroughBehavior: "when_no_templates" + httpMethod: "POST" + contentHandling: "CONVERT_TO_TEXT" + type: "aws" + delete: + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "user" + in: "header" + required: false + type: "string" + responses: + "200": + description: "200 response" + schema: + $ref: "#/definitions/Empty" + headers: + Access-Control-Allow-Origin: + type: "string" + security: + - EmberEHipster: [] + x-amazon-apigateway-integration: + responses: + default: + statusCode: "200" + responseParameters: + method.response.header.Access-Control-Allow-Origin: "'*'" + requestTemplates: + application/json: "## See http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html\n\ + ## This template will pass through all parameters including path, querystring,\ + \ header, stage variables, and context through to the integration endpoint\ + \ via the body/payload\n#set($allParams = $input.params())\n{\n\"body-json\"\ + \ : $input.json('$'),\n\"params\" : {\n#foreach($type in $allParams.keySet())\n\ + \ #set($params = $allParams.get($type))\n\"$type\" : {\n #foreach($paramName\ + \ in $params.keySet())\n \"$paramName\" : \"$util.escapeJavaScript($params.get($paramName))\"\ + \n #if($foreach.hasNext),#end\n #end\n}\n #if($foreach.hasNext),#end\n\ + #end\n},\n\"stage-variables\" : {\n#foreach($key in $stageVariables.keySet())\n\ + \"$key\" : \"$util.escapeJavaScript($stageVariables.get($key))\"\n \ + \ #if($foreach.hasNext),#end\n#end\n},\n\"context\" : {\n \"user\"\ + \ : \"$context.authorizer.claims.sub\",\n\t\"email\" : \"$context.authorizer.claims.email\"\ + ,\n \"account-id\" : \"$context.identity.accountId\",\n \"api-id\"\ + \ : \"$context.apiId\",\n \"api-key\" : \"$context.identity.apiKey\"\ + ,\n \"authorizer-principal-id\" : \"$context.authorizer.principalId\"\ + ,\n \"caller\" : \"$context.identity.caller\",\n \"cognito-authentication-provider\"\ + \ : \"$context.identity.cognitoAuthenticationProvider\",\n \"cognito-authentication-type\"\ + \ : \"$context.identity.cognitoAuthenticationType\",\n \"cognito-identity-id\"\ + \ : \"$context.identity.cognitoIdentityId\",\n \"cognito-identity-pool-id\"\ + \ : \"$context.identity.cognitoIdentityPoolId\",\n \"httpMethod\" :\ + \ \"$context.httpMethod\",\n \"stage\" : \"$context.stage\",\n \"\ + source-ip\" : \"$context.identity.sourceIp\",\n \"user\" : \"$context.identity.user\"\ + ,\n \"user-agent\" : \"$context.identity.userAgent\",\n \"user-arn\"\ + \ : \"$context.identity.userArn\",\n \"request-id\" : \"$context.requestId\"\ + ,\n \"resource-id\" : \"$context.resourceId\",\n \"resource-path\"\ + \ : \"$context.resourcePath\"\n }\n}\n" + uri: !Join [ "", [ "arn:aws:apigateway:", !Ref "AWS::Region", ":lambda:path/2015-03-31/functions/arn:aws:lambda:", !Ref "AWS::Region", ":", !Ref "AWS::AccountId", ":function:", !Ref "LambdaFunctionName", "/invocations" ] ] + passthroughBehavior: "when_no_templates" + httpMethod: "POST" + contentHandling: "CONVERT_TO_TEXT" + type: "aws" + options: + consumes: + - "application/json" + produces: + - "application/json" + responses: + "200": + description: "200 response" + schema: + $ref: "#/definitions/Empty" + headers: + Access-Control-Allow-Origin: + type: "string" + Access-Control-Allow-Methods: + type: "string" + Access-Control-Allow-Headers: + type: "string" + x-amazon-apigateway-integration: + responses: + default: + statusCode: "200" + responseParameters: + method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS,POST,PUT,DELETE'" + method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,user'" + method.response.header.Access-Control-Allow-Origin: "'*'" + requestTemplates: + application/json: "{\"statusCode\": 200}" + passthroughBehavior: "when_no_match" + type: "mock" + securityDefinitions: + EmberEHipster: + type: "apiKey" + name: "user" + in: "header" + x-amazon-apigateway-authtype: "cognito_user_pools" + x-amazon-apigateway-authorizer: + providerARNs: + - !GetAtt CognitoUserPool.Arn + type: "cognito_user_pools" + definitions: + Empty: + type: "object" + title: "Empty Schema" + LambdaFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Ref LambdaFunctionName + Handler: index.handler + Runtime: nodejs4.3 + Policies: AmazonDynamoDBFullAccess + Environment: + Variables: + TABLE_NAME: !Ref Table + Events: + GetDoc: + Type: Api + Properties: + RestApiId: !Ref ApiGatewayApi + Path: /{type}/{id} + Method: GET + GetDocs: + Type: Api + Properties: + RestApiId: !Ref ApiGatewayApi + Path: /{type} + Method: GET + NewDoc: + Type: Api + Properties: + RestApiId: !Ref ApiGatewayApi + Path: /{type} + Method: POST + DeleteDoc: + Type: Api + Properties: + RestApiId: !Ref ApiGatewayApi + Path: /{type}/{id} + Method: DELETE + CognitoUserPool: + Type: AWS::Cognito::UserPool + Properties: + UserPoolName: EmberEHipster + AutoVerifiedAttributes: + - "email" + CognitoUserPoolClient: + Type: AWS::Cognito::UserPoolClient + DependsOn: CognitoUserPool + Properties: + ClientName: !Ref AppClientName + UserPoolId: !Ref CognitoUserPool + GenerateSecret: false + CognitoIdentityPool: + Type: AWS::Cognito::IdentityPool + Properties: + AllowUnauthenticatedIdentities: true + CognitoIdentityProviders: + - ClientId: !Ref CognitoUserPoolClient + ProviderName: !GetAtt CognitoUserPool.ProviderName + CognitoIdentityPoolRoles: + Type: AWS::Cognito::IdentityPoolRoleAttachment + DependsOn: CognitoIdentityPool + Properties: + IdentityPoolId: !Ref CognitoIdentityPool + Roles: + authenticated: !GetAtt AuthenticatedRole.Arn + unauthenticated: !GetAtt UnauthenticatedRole.Arn + UnauthenticatedRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Federated: cognito-identity.amazonaws.com + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + cognito-identity.amazonaws.com:aud: !Ref CognitoIdentityPool + ForAnyValue:StringLike: + cognito-identity.amazonaws.com:amr: unauthenticated + Policies: + - + PolicyName: EmberEHipsterUnauthenticatedApi + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - mobileanalytics:PutEvents + - cognito-sync:* + Resource: + - "*" + AuthenticatedRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Federated: cognito-identity.amazonaws.com + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + cognito-identity.amazonaws.com:aud: !Ref CognitoIdentityPool + ForAnyValue:StringLike: + cognito-identity.amazonaws.com:amr: authenticated + Policies: + - + PolicyName: EmberEHipsterAuthenticatedApi + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - execute-api:Invoke + Resource: !Join [ "", [ "arn:aws:execute-api:", !Ref "AWS::Region", ":", !Ref "AWS::AccountId", ":", !Ref ApiGatewayApi, "/*" ] ] + Table: + Type: AWS::Serverless::SimpleTable +Outputs: + CognitoIdentityPoolId: + Description: Cognito Identity Pool ID + Value: !Ref CognitoIdentityPool + CognitoUserPoolsId: + Description: Cognito User Pools ID + Value: !Ref CognitoUserPool + CognitoUserPoolsClientId: + Description: Cognito User Pools App Client ID + Value: !Ref CognitoUserPoolClient + Api: + Description: API Gateway ID + Value: !Ref ApiGatewayApi + ApiUrl: + Description: URL of your API endpoint + Value: !Join + - '' + - - https:// + - !Ref ApiGatewayApi + - '.execute-api.' + - !Ref 'AWS::Region' + - '.amazonaws.com/Prod' diff --git a/cloud/lambda-jsonapi.js b/cloud/lambda-jsonapi-test.js similarity index 100% rename from cloud/lambda-jsonapi.js rename to cloud/lambda-jsonapi-test.js diff --git a/cloud/lambda/index.js b/cloud/lambda/index.js deleted file mode 100644 index e69de29..0000000 diff --git a/cloud/lambda/lambda-jsonapi.js b/cloud/lambda/lambda-jsonapi.js new file mode 100644 index 0000000..245f189 --- /dev/null +++ b/cloud/lambda/lambda-jsonapi.js @@ -0,0 +1,221 @@ +'use strict'; +const AWS = require('aws-sdk'); +const dynamo = new AWS.DynamoDB.DocumentClient(); +const tableName = 'JsonApiTable'; +const EPOCH = 1300000000000; + +// Instagram inspired --> https://instagram-engineering.com/sharding-ids-at-instagram-1cf5a71e5a5c +function generateRowId(subid) { + var ts = new Date().getTime() - EPOCH; // limit to recent + // 41 bits for time in milliseconds (gives us 41 years of IDs with a custom epoch + var randid = Math.floor(Math.random() * 512); + ts = (ts * 64); // bit-shift << 6 + // Given shard (if any...) + ts = ts + subid; + // random value + return (ts * 512) + (randid % 512); +} + +const createObject = (obj) => { + let objout = { "type": obj.ObjectType, + "id": obj.Id, + "attributes": {}, + "relationships": {} + }; + for (var attr in obj) { + if (attr !== 'ObjectType' && attr !== 'Id') { + if(!attr.endsWith('_id')) { + objout.attributes[attr] = obj[attr]; + } else { + let relationDetails = attr.split('_'); + let relationId = Number(obj[attr]); + objout.relationships[relationDetails[0]] = { + "links": { + "self": "/"+obj.ObjectType+"/"+obj.Id+"/relationships/"+relationDetails[0], + "related": "/"+obj.ObjectType+"/"+obj.Id+"/"+relationDetails[0] + }, + "data": {"type": relationDetails[1], "id": relationId} + }; + // TODO what's about if relation is not a belongsTo but a hasMany... + } + } else { + objout[(attr==='ObjectType')?'type':'id'] = obj[attr]; + } + } + console.log('||JSONAPI|| Return object is '+JSON.stringify(objout)); + return objout; +} + +const createData = (data) => { + if (Array.isArray(data)) { + let outdata = []; + for (let i=0;i<data.length;i++){ + outdata.push(createObject(data[i])); + } + return outdata; + } else { + return createObject(data); + } +} + +const createRelationships = (data) => { + return {}; +} + +const createResponse = (statusCode, body) => { + console.log("||JSONAPI|| Body is "+JSON.stringify(body)); + return { + 'statusCode': statusCode, + 'data': createData(body), + 'relationships': createRelationships(body) + } +}; + +const getMethod = (event, context, callback) => { + + let params = { + TableName: tableName, + }, + type = event.params.path.type, + id = Number(event.params.path.id), + dbGet = {}; + + if (id) { + params.Key = { + 'ObjectType': type, + 'Id': id + }; + dbGet = (params) => { return dynamo.get(params).promise() }; + console.log('||JSONAPI|| Lambda GET single value with params: ', params); + + } else { + params.KeyConditionExpression = 'ObjectType = :objectType'; + params.ExpressionAttributeValues = { ':objectType': type }; + dbGet = (params) => { return dynamo.query(params).promise() }; + console.log('||JSONAPI|| Lambda GET multiple values with params: ', params); + } + + dbGet(params).then( (data) => { + console.log('||JSONAPI|| Lambda GET data received: ', data); + + if (id && !data.Item) { + callback(null, createResponse(404, "ITEM NOT FOUND")); + return; + } else if (id && data.Item) { + console.log(`||JSONAPI|| RETRIEVED ITEM SUCCESSFULLY WITH doc = ${data.Item}`); + callback(null, createResponse(200, data.Item)); + } else { + console.log(`||JSONAPI|| RETRIEVED ITEMS SUCCESSFULLY WITH doc = ${data.Items}`); + callback(null, createResponse(200, data.Items)); + } + + }).catch( (err) => { + console.log(`||JSONAPI|| GET ITEM FAILED FOR Entry = ${params}, WITH ERROR: ${err}`); + callback(null, createResponse(500, err)); + }); +}; + +const putMethod = (event, context, callback) => { + const body=event['body-json']; + const attrs = body.data.attributes; + const relations = body.data.relationships; + // Without any body content, there is nothing to put... + if (!body || !attrs) { + callback(null, createResponse(500, 'No content found in body')); + return; + } + // Retrieving the type and generating a new id for created item + let type = event.params.path.type, + id = generateRowId(1); + // Final content contains at least these two fields + the atributes + let content = { + "ObjectType": type, + "Id": id + }; + // Adding attributes as column in dynamoDb + for (var prop in attrs) { + content[prop] = attrs[prop]; + } + // Dealing with relationships if any + if (relations){ + for (var relName in relations) { + let relData = relations[relName]["data"]; + let newCol; + if (!Array.isArray(relData)) { + newCol = relName+'_'+relData["type"]+'_id'; + content[newCol] = relData["id"]; + } else { + for (var i=0; i<relData.length;i++){ + let currentData = relData[i]; + newCol = relName+'_'+currentData["type"]+'_id'; + content[newCol] = currentData["id"]; + } + } + } + } + + const entry = { + TableName: tableName, + Item: content + }; + console.log('||JSONAPI|| Try saving entity of type '+type+' and content '+JSON.stringify(entry)); + //let dbPut = (entry) => { return dynamo.put(entry).promise() }; + dynamo.put(entry, function(err, data) { + if (err) { + console.log("||JSONAPI|| Error", err); + callback(null, createResponse(500, 'Error '+err)); + } else { + body.data.id = id; + body['statusCode'] = 200; + console.log(`||JSONAPI|| PUT ITEM SUCCEEDED WITH data=`+JSON.stringify(body)); + callback(null, body); + } + }); +}; + +const deleteMethod = (event, context, callback) => { + let type = event.params.path.type, + id = Number(event.params.path.id), + params = { + 'TableName': tableName, + 'Key': { + 'ObjectType': type, + 'Id': id + }, + 'ReturnValues': 'ALL_OLD' + }; + const body=event['body-json']; + + let dbDelete = (params) => { return dynamo.delete(params).promise() }; + dbDelete(params).then( (data) => { + if (!data.Attributes) { + callback(null, createResponse(404, "ITEM NOT FOUND FOR DELETION")); + return; + } + console.log(||JSONAPI|| `DELETED ITEM OF TYPE ${type} SUCCESSFULLY WITH id = ${id}`); + callback(null, body); + }).catch( (err) => { + console.log(`||JSONAPI|| DELETE ITEM OF TYPE ${type} FAILED FOR id = ${id}, WITH ERROR: ${err}`); + callback(null, createResponse(500, err)); + }); +}; + +exports.handler = (event, context, callback) => { + console.log("||JSONAPI|| ********************** Received Event *******************\n"+ JSON.stringify(event)); + console.log("||JSONAPI|| httpMethod="+event.context.httpMethod); + switch ( event.context.httpMethod ) { + case 'GET': + getMethod(event,context,callback); + break; + case 'PUT': + case 'POST': + putMethod(event,context,callback); + break; + case 'DELETE': + deleteMethod(event,context,callback); + break; + default: + callback(null, createResponse(500, 'Unsupported Method: ' + context.httpMethod)); + break; + } +}; diff --git a/cloud/terraform/README.md b/cloud/terraform/README.md new file mode 100644 index 0000000..a018a6b --- /dev/null +++ b/cloud/terraform/README.md @@ -0,0 +1,35 @@ +Installing AWS infrastructure through Terraform scripts +============================================================================== + +This file explains how to set up the expected infrastructure awaited to implement the JSON API. +The installation is based on Terraform (https://www.terraform.io/) + +It expects that by AWS CLI and Terraform have previously been installed and set up correctly. +The AWS Credentials should also have been set up + +How it works +------------------------------------------------------------------------------ +AWS JSON API server relies on 3 main components, plus the needed IAM roles : +- A DynamoDB table manipuling all serialized objects +- A lambda function in charge of the translation from JSON API format to dynamoDB objects and managing the relationships and the optional parameters provided in the request +- An API Gateway configured to received the REST HTTP requests and proxying the Lambda + +Three Terraform files are available, each one created the needed component and their relationships with each other. + +Installation +------------------------------------------------------------------------------ + +The lambda code needs to be stored in a S3 bucket before running the Terraform scripts. +The script is available in the cloud/lambda directory of this project. +The following operations need to be performed (adapting the region you are using): + +``` +zip lambda-jsonapi.zip ../lambda/lambda-jsonapi.js +aws s3api create-bucket --bucket=lambda-jsonapi-bucket --region=us-east-1 +aws s3 cp lambda-jsonapi-bucket.zip s3://lambda-jsonapi-bucket/v1.0.0/lambda-jsonapi-bucket.zip +``` +One this is done, simply run the terraform script : +``` +terraform apply +``` +For a strange reason I don't understand, the first time you run the terraform command, the mapping for the integration response fails. You have to run the command again to have your complete setup. \ No newline at end of file diff --git a/cloud/terraform/api-gateway.tf b/cloud/terraform/api-gateway.tf new file mode 100644 index 0000000..460b0c6 --- /dev/null +++ b/cloud/terraform/api-gateway.tf @@ -0,0 +1,244 @@ + +resource "aws_api_gateway_rest_api" "jsonapi" { + name = "JsonApiRestGateway" + description = "HTTP request front API for JSON API rest persistance" + minimum_compression_size = 4096 +} + +# Path /{type} +resource "aws_api_gateway_resource" "typePath" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + parent_id = "${aws_api_gateway_rest_api.jsonapi.root_resource_id}" + path_part = "{type}" +} + +# Path /{type}/{id} +resource "aws_api_gateway_resource" "typeIdPath" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + parent_id = "${aws_api_gateway_resource.typePath.id}" + path_part = "{id}" +} + +# GET on path /{type} +resource "aws_api_gateway_method" "typePathGet" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typePath.id}" + http_method = "GET" + authorization = "NONE" +} + +# POST on path /{type} +resource "aws_api_gateway_method" "typePathPost" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typePath.id}" + http_method = "POST" + authorization = "NONE" +} + +# GET on path /{type}/{id} +resource "aws_api_gateway_method" "typeIdPathGet" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typeIdPath.id}" + http_method = "GET" + authorization = "NONE" +} + +# DELETE on path /{type}/{id} +resource "aws_api_gateway_method" "typeIdPathDelete" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typeIdPath.id}" + http_method = "DELETE" + authorization = "NONE" +} + +# PATCH on path /{type}/{id} +resource "aws_api_gateway_method" "typeIdPathPatch" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typeIdPath.id}" + http_method = "PATCH" + authorization = "NONE" +} + +# Setup Integration Request for GET on {type} +resource "aws_api_gateway_integration" "lambdaJsonTypeGet" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_method.typePathGet.resource_id}" + http_method = "${aws_api_gateway_method.typePathGet.http_method}" + + integration_http_method = "POST" + type = "AWS" + uri = "${aws_lambda_function.lambda-jsonapi.invoke_arn}" + # Transforms the incoming XML request to JSON + passthrough_behavior = "WHEN_NO_TEMPLATES" + request_templates { + "application/json" = "${file("template.vm")}" + } +} + +# Setup Integration Request for POST on {type} +resource "aws_api_gateway_integration" "lambdaJsonTypePost" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_method.typePathPost.resource_id}" + http_method = "${aws_api_gateway_method.typePathPost.http_method}" + + integration_http_method = "POST" + type = "AWS" + uri = "${aws_lambda_function.lambda-jsonapi.invoke_arn}" + # Transforms the incoming XML request to JSON + passthrough_behavior = "WHEN_NO_TEMPLATES" + request_templates { + "application/json" = "${file("template.vm")}" + } +} + +# Setup Integration Request for GET on {type}/{id} +resource "aws_api_gateway_integration" "lambdaJsonTypeIdGet" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_method.typeIdPathGet.resource_id}" + http_method = "${aws_api_gateway_method.typeIdPathGet.http_method}" + + integration_http_method = "POST" + type = "AWS" + uri = "${aws_lambda_function.lambda-jsonapi.invoke_arn}" + # Transforms the incoming XML request to JSON + passthrough_behavior = "WHEN_NO_TEMPLATES" + request_templates { + "application/json" = "${file("template.vm")}" + } +} + +# Setup Integration Request for DELETE on {type}/{id} +resource "aws_api_gateway_integration" "lambdaJsonTypeIdDelete" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_method.typeIdPathDelete.resource_id}" + http_method = "${aws_api_gateway_method.typeIdPathDelete.http_method}" + + integration_http_method = "POST" + type = "AWS" + uri = "${aws_lambda_function.lambda-jsonapi.invoke_arn}" + # Transforms the incoming XML request to JSON + passthrough_behavior = "WHEN_NO_TEMPLATES" + request_templates { + "application/json" = "${file("template.vm")}" + } +} + +# Setup Integration Request for PATCH on {type}/{id} +resource "aws_api_gateway_integration" "lambdaJsonTypeIdPatch" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_method.typeIdPathPatch.resource_id}" + http_method = "${aws_api_gateway_method.typeIdPathPatch.http_method}" + + integration_http_method = "POST" + type = "AWS" + uri = "${aws_lambda_function.lambda-jsonapi.invoke_arn}" + # Transforms the incoming XML request to JSON + passthrough_behavior = "WHEN_NO_TEMPLATES" + request_templates { + "application/json" = "${file("template.vm")}" + } +} + +# Setup Integration Response for status code 200 and GET on {type} +resource "aws_api_gateway_method_response" "200TypeGet" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_method.typePathGet.resource_id}" + http_method = "${aws_api_gateway_method.typePathGet.http_method}" + status_code = "200" +} + +resource "aws_api_gateway_integration_response" "200TypeGetIntegrationResponse" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typePath.id}" + http_method = "${aws_api_gateway_method.typePathGet.http_method}" + status_code = "${aws_api_gateway_method_response.200TypeGet.status_code}" +} + +# Setup Integration Response for status code 200 and POST on {type} +resource "aws_api_gateway_method_response" "200TypePost" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typePath.id}" + http_method = "${aws_api_gateway_method.typePathPost.http_method}" + status_code = "200" +} + +resource "aws_api_gateway_integration_response" "200TypePostIntegrationResponse" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typePath.id}" + http_method = "${aws_api_gateway_method.typePathPost.http_method}" + status_code = "${aws_api_gateway_method_response.200TypePost.status_code}" + response_templates = { + "application/json" = "" + } +} + +# Setup Integration Response for status code 200 and GET on {type}/{id} +resource "aws_api_gateway_method_response" "200TypeIdGet" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_method.typeIdPathGet.resource_id}" + http_method = "${aws_api_gateway_method.typeIdPathGet.http_method}" + status_code = "200" +} + +resource "aws_api_gateway_integration_response" "200TypeIdGetIntegrationResponse" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typeIdPath.id}" + http_method = "${aws_api_gateway_method.typeIdPathGet.http_method}" + status_code = "${aws_api_gateway_method_response.200TypeIdGet.status_code}" + response_templates = { + "application/json" = "" + } +} + +# Setup Integration Response for status code 200 and DELETE on {type}/{id} +resource "aws_api_gateway_method_response" "200TypeIdDelete" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_method.typeIdPathDelete.resource_id}" + http_method = "${aws_api_gateway_method.typeIdPathDelete.http_method}" + status_code = "200" +} + +resource "aws_api_gateway_integration_response" "200TypeIdDeleteIntegrationResponse" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typeIdPath.id}" + http_method = "${aws_api_gateway_method.typeIdPathDelete.http_method}" + status_code = "${aws_api_gateway_method_response.200TypeIdDelete.status_code}" + response_templates = { + "application/json" = "" + } +} + +# Setup Integration Response for status code 200 and PATCH on {type}/{id} +resource "aws_api_gateway_method_response" "200TypeIdPatch" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_method.typeIdPathPatch.resource_id}" + http_method = "${aws_api_gateway_method.typeIdPathPatch.http_method}" + status_code = "200" +} + +resource "aws_api_gateway_integration_response" "200TypeIdPatchIntegrationResponse" { + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + resource_id = "${aws_api_gateway_resource.typeIdPath.id}" + http_method = "${aws_api_gateway_method.typeIdPathPatch.http_method}" + status_code = "${aws_api_gateway_method_response.200TypeIdPatch.status_code}" + response_templates = { + "application/json" = "" + } +} + +resource "aws_api_gateway_deployment" "jsonapiDeployment" { + depends_on = [ + "aws_api_gateway_integration.lambdaJsonTypeGet", + "aws_api_gateway_integration.lambdaJsonTypePost", + "aws_api_gateway_integration.lambdaJsonTypeIdGet", + "aws_api_gateway_integration.lambdaJsonTypeIdPatch", + "aws_api_gateway_integration.lambdaJsonTypeIdDelete" + ] + + rest_api_id = "${aws_api_gateway_rest_api.jsonapi.id}" + stage_name = "staging" +} + +output "base_url" { + value = "${aws_api_gateway_deployment.jsonapiDeployment.invoke_url}" +} \ No newline at end of file diff --git a/cloud/terraform/dynamodb.tf b/cloud/terraform/dynamodb.tf new file mode 100644 index 0000000..1790e77 --- /dev/null +++ b/cloud/terraform/dynamodb.tf @@ -0,0 +1,16 @@ +resource "aws_dynamodb_table" "JsonApiTable" { + name = "JsonApiTable" + read_capacity = 3 + write_capacity = 3 + hash_key = "ObjectType" + range_key = "Id" + + attribute { + name = "ObjectType" + type = "S" + } + attribute { + name = "Id" + type = "N" + } +} diff --git a/cloud/terraform/iam.tf b/cloud/terraform/iam.tf new file mode 100644 index 0000000..04cd4a6 --- /dev/null +++ b/cloud/terraform/iam.tf @@ -0,0 +1,30 @@ + +resource "aws_iam_role" "lambda_jsonapi" { + name = "lambda_jsonapi" + path = "/" + + assume_role_policy = <<POLICY +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +} +POLICY +} + +resource "aws_iam_role_policy_attachment" "lambda_jsonapi_AmazonDynamoDBFullAccess" { + role = "${aws_iam_role.lambda_jsonapi.name}" + policy_arn = "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess" +} + +resource "aws_iam_role_policy_attachment" "lambda_jsonapi_CloudWatchFullAccess" { + role = "${aws_iam_role.lambda_jsonapi.name}" + policy_arn = "arn:aws:iam::aws:policy/CloudWatchFullAccess" +} diff --git a/cloud/terraform/lambda.tf b/cloud/terraform/lambda.tf new file mode 100644 index 0000000..8fa7c62 --- /dev/null +++ b/cloud/terraform/lambda.tf @@ -0,0 +1,36 @@ +variable "region" { + default = "us-east-1" +} + +provider "aws" { + region = "${var.region}" +} + +resource "aws_lambda_function" "lambda-jsonapi" { + function_name = "JSONAPILambda" + + # The bucket name as created earlier with "aws s3api create-bucket" + s3_bucket = "lambda-jsonapi-bucket" + s3_key = "v1.0.0/lambda-jsonapi.zip" + + # "main" is the filename within the zip file (main.js) and "handler" + # is the name of the property under which the handler function was + # exported in that file. + handler = "lambda-jsonapi.handler" + runtime = "nodejs8.10" + + role = "${aws_iam_role.lambda_jsonapi.arn}" +} + + +# Allow API Gateway to access the defined lambda function +resource "aws_lambda_permission" "apigw" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = "${aws_lambda_function.lambda-jsonapi.arn}" + principal = "apigateway.amazonaws.com" + + # The /*/* portion grants access from any method on any resource + # within the API Gateway "REST API". + source_arn = "${aws_api_gateway_deployment.jsonapiDeployment.execution_arn}/*/*" +} \ No newline at end of file diff --git a/cloud/terraform/template.vm b/cloud/terraform/template.vm new file mode 100644 index 0000000..57a5b4b --- /dev/null +++ b/cloud/terraform/template.vm @@ -0,0 +1,35 @@ +#set($allParams = $input.params()) +{ +"params" : { +#foreach($type in $allParams.keySet()) + #set($params = $allParams.get($type)) +"$type" : { + #foreach($paramName in $params.keySet()) + "$paramName" : "$util.escapeJavaScript($params.get($paramName))" + #if($foreach.hasNext),#end + #end +} + #if($foreach.hasNext),#end +#end +}, +"context" : { + "httpMethod" : "$context.httpMethod", + "account-id" : "$context.identity.accountId", + "api-id" : "$context.apiId", + "api-key" : "$context.identity.apiKey", + "authorizer-principal-id" : "$context.authorizer.principalId", + "caller" : "$context.identity.caller", + "cognito-authentication-provider" : "$context.identity.cognitoAuthenticationProvider", + "cognito-authentication-type" : "$context.identity.cognitoAuthenticationType", + "cognito-identity-id" : "$context.identity.cognitoIdentityId", + "cognito-identity-pool-id" : "$context.identity.cognitoIdentityPoolId", + "stage" : "$context.stage", + "source-ip" : "$context.identity.sourceIp", + "user" : "$context.identity.user", + "user-agent" : "$context.identity.userAgent", + "user-arn" : "$context.identity.userArn", + "request-id" : "$context.requestId", + "resource-id" : "$context.resourceId", + "resource-path" : "$context.resourcePath" + } +} \ No newline at end of file -- GitLab