Using Step Functions and C# Lambdas to Orchestrate API Calls

tl;dr

AWS Step Functions can be used with a Lambda to orchestrate calls to remote APIs, the Lambda returns Json to the Step Function, and based on the responses other Step Functions use the Lambda to call the next API.

However, the serialized output of the Lambda is escaped and causes problems for the Step Functions, but if the Lambda returns the Json as Stream instead of a string the problem is resolved.

When returning a string the output is something like -

"{\n  \"origin\": \"54.80.87.97\"\n}\n"

But when using a Stream it is -

{
   "origin": "54.80.87.97"
}

Introduction

My goal is to use Step Functions to call a Lambda passing in a URL, the Lambda will use a HttpClient to make a request to the URL, and return the Json response to the Step Function. Based on that response another Step Function will be called, which will, in turn, call the original Lambda, but with a new URL, and so on. Decisions will also be made by the Step Functions based on the Json response.

Below is a generic drawing of how I use Step Functions with a Lambda to orchestrate API calls.

Finding Breweries By IP Address

In my specific case, I will use Step Functions to find breweries based on an IP address.

The three APIs I’m using allow me to -

  • determine my IP address
  • get my location from my IP address
  • find craft breweries based on my location by city or state

These three APIs are very different and, and return very different Json. I want to use a single Lambda that doesn’t need to know anything about the API it is calling. I want to avoid all the work involved in the normal usage of a HttpClient where I have to create lots of POCOs to represent the Json.

The Lambda, and the Problem

This is a simple as I could make it. It takes in the URL of the endpoint I am going to call and makes an HTTP GET request to it. It returns the Json response. The Lambda doesn’t handle other verbs, body, headers, but that would not be hard to add.

1
2
3
4
5
6
7
8
9
public async Task<string> FunctionHandler(ApiRequest input, ILambdaContext context)
{
    HttpClient httpClient = new HttpClient();
    var content = await httpClient.GetStringAsync(input.Uri);

    Console.WriteLine(content);

    return content;
}

But this has a problem, and when working with Step Functions it is a very large problem.

The log of the Lambda execution looks fine, I see the Json in the expected format -

START RequestId: 5dd31d04-b382-4832-a307-44a8778f143f Version: $LATEST
{
"origin": "54.80.87.97"
}
END RequestId: 5dd31d04-b382-4832-a307-44a8778f143f
REPORT RequestId: 5dd31d04-b382-4832-a307-44a8778f143f  Duration: 7.56 ms   Billed Duration: 8 ms   Memory Size: 512 MB Max Memory Used: 70 MB  

But the content returned by the Lambda to the Step Function looks like this -

"{\n  \"origin\": \"54.80.87.97\"\n}\n"

The Json I received from the remote API has been serialized again and has a lot escaping included.

The Step Function cannot parse the origin from the returned value.

Here is what happens if I try -

The JsonPath argument for the field '$.Payload.origin' could not be found in the input '{\"ExecutedVersion\":\"$LATEST\",\"Payload\":\"{\\n  \\\"origin\\\": \\\"54.80.87.97\\\"\\n}

I don’t want to go down the route of creating a class that represents the Json returned from the remote API, my goal here is for the Lambda function to know nothing about the API it is calling. This is not the first challenge I’ve had with Json and C# - see this, and this for more.

Fortunately, there are a few ways to resolve this. One way is to use Intrinsic Functions to clean up the string, I’ve written about that here, but that feels very awkward, I would have to process the input to every Step Function with an Intrinsic Function, not hard, but not what I want.

What I need is to make the Lambda return Json that the Step Function can process without extra steps.

Better Serialization

The response from the API is already a Json string, and I want to prevent it from being escaped as it is sent over the wire.

There are a couple of ways, one is to write my own serializer that implements ILambdaSerializer, I’ve done this and it works, I’ll blog on that later.

But the easiest way I could find was to change the return type of the Lambda function from a string to a Stream!

1
2
3
4
5
6
7
public async Task<System.IO.Stream> FunctionHandler(ApiRequest input, ILambdaContext context)
{
    HttpClient httpClient = new HttpClient();
    var content = await httpClient.GetStreamAsync(input.Uri);

    return content;
}

Note that I’m returning Task<System.IO.Stream>.

Now the output of the Lambda is what I want! This is a tiny change, but it was not obvious and came after quite a bit of effort.

The Step Functions

I’m not going to go into how to create Step Functions, I have another blog post on that.

Here is the state machine -

If no IP address is passed at the start, it will figure out the IP address of the server it is running on, from there it works out the location associated with that IP address. With that location, a call is made to the Open Brewery DB with the city, if no breweries are found in that city, the state is used to look for breweries instead.

I use Intrinsic Functions to combine the output of the previous step with the HTTP request I’m making in the current step, see line 7 below where I combine the city from the output of the previous step with the request to the Lambda.

1
2
3
4
5
6
7
8
9
"Get Brewery by City": {
  "Type": "Task",
  "Resource": "arn:aws:states:::lambda:invoke",
  "Parameters": {
    "FunctionName": "arn:aws:lambda:us-east-1:YOUR_ACCCOUNT_NUMBER:function:ApiCaller:$LATEST",
    "Payload": {
      "Uri.$": "States.Format('https://api.openbrewerydb.org/breweries?by_city={}', $.city)"
    }
  }

There is no error handling in this example beyond the retries that AWS added in by default.

I’m not happy with how I determine if any results are returned by the Open Brewery DB on lines 92-96, but I couldn’t see a way to check the number of results in a Json array, if you know, please get in touch.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
{
  "Comment": "Find breweries by IP address",
  "StartAt": "IP Address Provided?",
  "States": {
    "IP Address Provided?": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.ipaddress",
          "IsPresent": true,
          "Next": "Get Location from IP Address"
        }
      ],
      "Default": "Get IP Address"
    },
    "Get Location from IP Address": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:us-east-1:YOUR_ACCCOUNT_NUMBER:function:ApiCaller:$LATEST",
        "Payload": {
          "Uri.$": "States.Format('http://api.ipstack.com/{}?access_key=YOU_NEED_YOUR_OWN_ACCESS_KEY', $.ipaddress)"
        }
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "Next": "Get Brewery by City",
      "OutputPath": "$.Payload"
    },
    "Get IP Address": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:us-east-1:YOUR_ACCCOUNT_NUMBER:function:ApiCaller:$LATEST",
        "Payload": {
          "Uri": "http://httpbin.org/ip"
        }
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "Next": "Get Location from IP Address",
      "ResultSelector": {
        "ipaddress.$": "$.Payload.origin"
      }
    },
    "Get Brewery by City": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:us-east-1:YOUR_ACCCOUNT_NUMBER:function:ApiCaller:$LATEST",
        "Payload": {
          "Uri.$": "States.Format('https://api.openbrewerydb.org/breweries?by_city={}', $.city)"
        }
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "Next": "Breweries Returned?",
      "ResultPath": "$.result"
    },
    "Breweries Returned?": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.result.SdkHttpMetadata.HttpHeaders.Content-Length",
          "StringEquals": "2",
          "Next": "Get Brewery by State"
        }
      ],
      "Default": "Extract Results"
    },
    "Get Brewery by State": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:us-east-1:YOUR_ACCCOUNT_NUMBER:function:ApiCaller:$LATEST",
        "Payload": {
          "Uri.$": "States.Format('https://api.openbrewerydb.org/breweries?by_state={}', $.region_name)"
        }
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "ResultPath": "$.result",
      "Next": "Extract Results"
    },
    "Extract Results": {
      "Type": "Pass",
      "End": true,
      "OutputPath": "$.result.Payload"
    }
  }
}

Execution and Output

This is the execution history of the state machine -

This is the first of a list of results -

Conclusion

This is not the primary use for Step Functions, but for a situation where you need to orchestrate a few APIs, this feels like a pretty good way of doing it.

comments powered by Disqus

Related