github.com/mweagle/Sparta@v1.15.0/docs_source/content/reference/apiv2gateway/_index.md (about) 1 --- 2 date: 2016-03-09T19:56:50+01:00 3 pre: "<b>API V2 Gateway</b>" 4 alwaysopen: false 5 weight: 100 6 --- 7 8 # API V2 Gateway 9 10 The API V2 Gateway service provides a way to expose a WebSocket API that is 11 supported by a set of Lambda functions. The AWS [blog post](https://aws.amazon.com/blogs/compute/announcing-websocket-apis-in-amazon-api-gateway/) supplies an excellent overview of 12 the pros and cons of this approach that enables a near real time, pushed-based 13 application. This section will provide an overview of how to configure a WebSocket 14 API using Sparta. It is based on the [SpartaWebSocket](https://github.com/mweagle/SpartaWebSocket) sample project. 15 16 ## Payload 17 18 Similar to the AWS blog post, our WebSocket API will transmit messages of the form 19 20 ```json 21 { 22 "message":"sendmessage", 23 "data":"hello world !" 24 } 25 ``` 26 27 We'll use [ws](https://github.com/hashrocket/ws) to test the API from the command line. 28 29 ## Routes 30 31 The Sparta service consists of three lambda functions: 32 33 * `connectWorld(context.Context, awsEvents.APIGatewayWebsocketProxyRequest) (*wsResponse, error)` 34 * `disconnectWorld(context.Context, awsEvents.APIGatewayWebsocketProxyRequest) (*wsResponse, error)` 35 * `sendMessage(context.Context, awsEvents.APIGatewayWebsocketProxyRequest) (*wsResponse, error)` 36 37 Our functions will use the [PROXY](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-create-api-as-simple-proxy) style integration and therefore accept an instance of the [APIGatewayWebsocketProxyRequest](https://godoc.org/github.com/aws/aws-lambda-gevents#APIGatewayWebsocketProxyRequest) type. 38 39 Each function returns a `*wsResponse` instance that satisfies the __PROXY__ response: 40 41 ```go 42 type wsResponse struct { 43 StatusCode int `json:"statusCode"` 44 Body string `json:"body"` 45 } 46 ``` 47 48 ### connectWorld 49 50 The `connectWorld` AWS Lambda function is responsible for saving the incoming _connectionID_ into a dynamically provisioned DynamoDB database so that subsequent _sendMessage_ requests can broadcast to all subscribed parties. 51 52 The table name is advertised in the Lambda function via a user-defined environment variable. The specifics of how that table is provisioned will be addressed in a section below. 53 54 ```go 55 ... 56 // Operation 57 putItemInput := &dynamodb.PutItemInput{ 58 TableName: aws.String(os.Getenv(envKeyTableName)), 59 Item: map[string]*dynamodb.AttributeValue{ 60 ddbAttributeConnectionID: &dynamodb.AttributeValue{ 61 S: aws.String(request.RequestContext.ConnectionID), 62 }, 63 }, 64 } 65 _, putItemErr := dynamoClient.PutItem(putItemInput) 66 ... 67 ``` 68 69 ### disconnectWorld 70 71 The complement to `connectWorld` is `disconnectWorld` which is responsible for removing the _connectionID_ from the list of registered connections: 72 73 ```go 74 delItemInput := &dynamodb.DeleteItemInput{ 75 TableName: aws.String(os.Getenv(envKeyTableName)), 76 Key: map[string]*dynamodb.AttributeValue{ 77 ddbAttributeConnectionID: &dynamodb.AttributeValue{ 78 S: aws.String(connectionID), 79 }, 80 }, 81 } 82 _, delItemErr := ddbService.DeleteItem(delItemInput) 83 ``` 84 85 ### sendMessage 86 87 With the `connectWorld` and `disconnectWorld` connection management functions created, the core of the WebSocket API is `sendMessage`. This function is responsible for scanning over the set of registered _connectionIDs_ and forwarding a request to [PostConnectionWithContext](https://godoc.org/github.com/aws/aws-sdk-go/service/apigatewaymanagementapi#ApiGatewayManagementApi.PostToConnectionWithContext). This function sends the message to the registered connections. 88 89 The `sendMessage` function can be broken down into a few sections. 90 91 #### Setup API Gateway Management Instance 92 93 The first requirement is to setup the API Gateway Management service instance using the proper endpoint. The endpoint can be constructed from the incoming [APIGatewayWebsocketProxyRequestContext](https://godoc.org/github.com/aws/aws-lambda-go/events#APIGatewayWebsocketProxyRequestContext) member of the request. 94 95 ```go 96 endpointURL := fmt.Sprintf("%s/%s", 97 request.RequestContext.DomainName, 98 request.RequestContext.Stage) 99 logger.WithField("Endpoint", endpointURL).Info("API Gateway Endpoint") 100 dynamoClient := dynamodb.New(sess) 101 apigwMgmtClient := apigwManagement.New(sess, aws.NewConfig().WithEndpoint(endpointURL)) 102 ``` 103 104 #### Validate Input 105 106 The new step is to unmarshal and validate the incoming JSON request body: 107 108 ```go 109 // Get the input request... 110 var objMap map[string]*json.RawMessage 111 unmarshalErr := json.Unmarshal([]byte(request.Body), &objMap) 112 if unmarshalErr != nil || objMap["data"] == nil { 113 return &wsResponse{ 114 StatusCode: 500, 115 Body: "Failed to unmarshal request: " + unmarshalErr.Error(), 116 }, nil 117 } 118 ``` 119 120 Once we have verified that the input is valid, the final step is to notify all the subscribers. 121 122 #### Scan and Publish 123 124 Once the incoming `data` property is validated, the next step is to scan the DynamoDB table for the registered connections and post a message to each one. Note that the scan callback also attempts to cleanup connections that are no longer valid, but which haven't been cleanly removed. 125 126 ```go 127 scanCallback := func(output *dynamodb.ScanOutput, lastPage bool) bool { 128 // Send the message to all the clients 129 for _, eachItem := range output.Items { 130 // Get the connectionID 131 receiverConnection := "" 132 if eachItem[ddbAttributeConnectionID].S != nil { 133 receiverConnection = *eachItem[ddbAttributeConnectionID].S 134 } 135 136 // Post to this connectionID 137 postConnectionInput := &apigwManagement.PostToConnectionInput{ 138 ConnectionId: aws.String(receiverConnection), 139 Data: *objMap["data"], 140 } 141 _, respErr := apigwMgmtClient.PostToConnectionWithContext(ctx, postConnectionInput) 142 if respErr != nil { 143 if receiverConnection != "" && 144 strings.Contains(respErr.Error(), apigwManagement.ErrCodeGoneException) { 145 // Cleanup in case the connection is stale 146 go deleteConnection(receiverConnection, dynamoClient) 147 } else { 148 logger.WithField("Error", respErr).Warn("Failed to post to connection") 149 } 150 } 151 return true 152 } 153 return true 154 } 155 156 // Scan the connections table 157 scanInput := &dynamodb.ScanInput{ 158 TableName: aws.String(os.Getenv(envKeyTableName)), 159 } 160 scanItemErr := dynamoClient.ScanPagesWithContext(ctx, 161 scanInput, 162 scanCallback) 163 ... 164 ``` 165 166 These three functions are the core of the WebSocket service. 167 168 ## API V2 Gateway Decorator 169 170 The next step is to create the API V2 API object which is comprised of: 171 172 * [Stage](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigatewayv2-stage.html) 173 * [API](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigatewayv2-api.html) 174 * [Routes](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigatewayv2-route.html) 175 176 There is one Stage and one API per service, but a given service (including this one) may include multiple Routes. 177 178 ```go 179 // APIv2 Websockets 180 stage, _ := sparta.NewAPIV2Stage("v1") 181 stage.Description = "New deploy!" 182 183 apiGateway, _ := sparta.NewAPIV2(sparta.Websocket, 184 "sample", 185 "$request.body.message", 186 stage) 187 ``` 188 189 The `NewAPIV2` creation function requires: 190 191 * The protocol to use (`sparta.Websocket`) 192 * The name of the API (`sample`) 193 * The [route selection expression](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-selection-expressions.html#apigateway-websocket-api-route-selection-expressions) that represents a JSONPath selection expression to map input data to the corresponding lambda function. 194 * The stage 195 196 Once the API is defined, each route is associated with the API as in: 197 198 ```go 199 apiv2ConnectRoute, _ := apiGateway.NewAPIV2Route("$connect", 200 lambdaConnect) 201 apiv2ConnectRoute.OperationName = "ConnectRoute" 202 ... 203 apiv2SendRoute, _ := apiGateway.NewAPIV2Route("sendmessage", 204 lambdaSend) 205 apiv2SendRoute.OperationName = "SendRoute" 206 ... 207 ``` 208 209 The `$connect` routeKey is a special [route key value](https://aws.amazon.com/blogs/compute/announcing-websocket-apis-in-amazon-api-gateway/) that is sent when a client first connects to the WebSocket API. See the official [documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-route-keys-connect-disconnect.html) for more information. 210 211 In comparison, the `sendmessage` routeKey value of `sendmessage` means that a payload of the form: 212 213 ```json 214 { 215 "message":"sendmessage", 216 "data":"hello world !" 217 } 218 ``` 219 220 will trigger the `lambdaSend` function given the parent API's route selection expression of `$request.body.message`. 221 222 ### Additional Privileges 223 224 Because the `lambdaSend` function also needs to invoke the API Gateway Management APIs to broadcast, an additional IAM Privilege must be enabled: 225 226 ```go 227 var apigwPermissions = []sparta.IAMRolePrivilege{ 228 { 229 Actions: []string{"execute-api:ManageConnections"}, 230 Resource: gocf.Join("", 231 gocf.String("arn:aws:execute-api:"), 232 gocf.Ref("AWS::Region"), 233 gocf.String(":"), 234 gocf.Ref("AWS::AccountId"), 235 gocf.String(":"), 236 gocf.Ref(apiGateway.LogicalResourceName()), 237 gocf.String("/*")), 238 }, 239 } 240 lambdaSend.RoleDefinition.Privileges = append(lambdaSend.RoleDefinition.Privileges, apigwPermissions...) 241 ``` 242 243 ## Annotating Lambda Functions 244 245 The final configuration step is to use the API gateway to create an instance of the `APIV2GatewayDecorator`. This decorator is responsible for: 246 247 * Provisioning the DynamoDB table. 248 * Ensuring DynamoDB CRUD permissions for all the AWS Lambda functions. 249 * Publishing the table name into the Lambda function's Environment block. 250 * Adding the WebSocket `wss://...` URL to the Stack's Outputs. 251 252 The decorator is created by a call to `NewConnectionTableDecorator` which accepts: 253 254 * The environment variable to populate with the dynamically assigned DynamoDB table 255 * The DynamoDB attribute name to use to store the connectionID 256 * The read capacity units 257 * The write capacity units 258 259 For instance: 260 261 ```go 262 decorator, _ := apiGateway.NewConnectionTableDecorator(envKeyTableName, 263 ddbAttributeConnectionID, 264 5, 265 5) 266 267 var lambdaFunctions []*sparta.LambdaAWSInfo 268 lambdaFunctions = append(lambdaFunctions, 269 lambdaConnect, 270 lambdaDisconnect, 271 lambdaSend) 272 decorator.AnnotateLambdas(lambdaFunctions) 273 ``` 274 275 ## Provision 276 277 With everything defined, provide the API V2 Decorator as a Workflow hook as in: 278 279 ```go 280 // Set everything up and run it... 281 workflowHooks := &sparta.WorkflowHooks{ 282 ServiceDecorators: []sparta.ServiceDecoratorHookHandler{decorator}, 283 } 284 err := sparta.MainEx(awsName, 285 "Sparta application that demonstrates API v2 Websocket support", 286 lambdaFunctions, 287 apiGateway, 288 nil, 289 workflowHooks, 290 false) 291 ``` 292 293 and then provision the application: 294 295 ```bash 296 go run main.go provision --s3Bucket $S3_BUCKET --noop 297 INFO[0000] ════════════════════════════════════════════════ 298 INFO[0000] ╔═╗╔═╗╔═╗╦═╗╔╦╗╔═╗ Version : 1.9.4 299 INFO[0000] ╚═╗╠═╝╠═╣╠╦╝ ║ ╠═╣ SHA : cfd44e2 300 INFO[0000] ╚═╝╩ ╩ ╩╩╚═ ╩ ╩ ╩ Go : go1.12.6 301 INFO[0000] ════════════════════════════════════════════════ 302 INFO[0000] Service: SpartaWebSocket-123412341234 LinkFlags= Option=provision UTC="2019-07-25T05:26:57Z" 303 INFO[0000] ════════════════════════════════════════════════ 304 INFO[0000] Using `git` SHA for StampedBuildID Command="git rev-parse HEAD" SHA=6b26f8e645e9d58c1b678e46576e19bbc29886c0 305 INFO[0000] Provisioning service BuildID=6b26f8e645e9d58c1b678e46576e19bbc29886c0 CodePipelineTrigger= InPlaceUpdates=false NOOP=false Tags= 306 INFO[0000] Verifying IAM Lambda execution roles 307 INFO[0000] IAM roles verified Count=3 308 INFO[0000] Checking S3 versioning Bucket=weagle VersioningEnabled=true 309 INFO[0000] Checking S3 region Bucket=weagle Region=us-west-2 310 INFO[0000] Running `go generate` 311 INFO[0000] Compiling binary Name=Sparta.lambda.amd64 312 INFO[0002] Creating code ZIP archive for upload TempName=./.sparta/SpartaWebSocket_123412341234-code.zip 313 INFO[0002] Lambda code archive size Size="23 MB" 314 INFO[0002] Uploading local file to S3 Bucket=weagle Key=SpartaWebSocket-123412341234/SpartaWebSocket_123412341234-code.zip Path=./.sparta/SpartaWebSocket_123412341234-code.zip Size="23 MB" 315 INFO[0011] Calling WorkflowHook ServiceDecoratorHook= WorkflowHookContext="map[]" 316 INFO[0011] Uploading local file to S3 Bucket=weagle Key=SpartaWebSocket-123412341234/SpartaWebSocket_123412341234-cftemplate.json Path=./.sparta/SpartaWebSocket_123412341234-cftemplate.json Size="14 kB" 317 INFO[0011] Creating stack StackID="arn:aws:cloudformation:us-west-2:123412341234:stack/SpartaWebSocket-123412341234/d8a405b0-ae9c-11e9-a05a-0a1528792fce" 318 INFO[0122] CloudFormation Metrics ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 319 ... 320 INFO[0122] Stack Outputs ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 321 INFO[0122] APIGatewayURL Description="API Gateway Websocket URL" Value="wss://gu4vmnia27.execute-api.us-west-2.amazonaws.com/v1" 322 INFO[0122] Stack provisioned CreationTime="2019-07-25 05:27:08.687 +0000 UTC" StackId="arn:aws:cloudformation:us-west-2:123412341234:stack/SpartaWebSocket-123412341234/d8a405b0-ae9c-11e9-a05a-0a1528792fce" StackName=SpartaWebSocket-123412341234 323 INFO[0122] ════════════════════════════════════════════════ 324 INFO[0122] SpartaWebSocket-123412341234 Summary 325 INFO[0122] ════════════════════════════════════════════════ 326 INFO[0122] Verifying IAM roles Duration (s)=0 327 INFO[0122] Verifying AWS preconditions Duration (s)=0 328 INFO[0122] Creating code bundle Duration (s)=1 329 INFO[0122] Uploading code Duration (s)=9 330 INFO[0122] Ensuring CloudFormation stack Duration (s)=112 331 INFO[0122] Total elapsed time Duration (s)=122 332 ``` 333 334 ## Test 335 336 With the API Gateway deployed, the last step is to test it. Download and install the [ws](go get -u github.com/hashrocket/ws 337 ) tool: 338 339 ```bash 340 go get -u github.com/hashrocket/ws 341 ``` 342 343 then connect to your new API and send a message as in: 344 345 ```bash 346 22:31 $ ws wss://gu4vmnia27.execute-api.us-west-2.amazonaws.com/v1 347 > {"message":"sendmessage", "data":"hello world !"} 348 < "hello world !" 349 ``` 350 351 You can also send messages with [Firecamp](https://chrome.google.com/webstore/detail/firecamp-a-campsite-for-d/eajaahbjpnhghjcdaclbkeamlkepinbl?hl=en), a Chrome extension, and send messages between your `ws` session and the web (or vice versa). 352 353 ## Conclusion 354 355 While a production ready application would likely need to include authentication and authorization, this is the beginnings of a full featured WebSocket service in fewer than 200 lines of application code: 356 357 ```bash 358 ------------------------------------------------------------------------------- 359 Language files blank comment code 360 ------------------------------------------------------------------------------- 361 Go 1 21 52 183 362 Markdown 1 0 2 0 363 ------------------------------------------------------------------------------- 364 TOTAL 2 21 54 183 365 ------------------------------------------------------------------------------- 366 ``` 367 368 Remember to terminate the stack when you're done to avoid any unintentional costs! 369 370 ## References 371 372 * The SpartaWebSocket application is modeled after the [https://github.com/aws-samples/simple-websockets-chat-app](https://github.com/aws-samples/simple-websockets-chat-app) sample.