Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Lambda Function URL HTTP request and response types #436

Merged
merged 4 commits into from
Apr 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions events/lambda_function_urls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.

package events

// LambdaFunctionURLRequest contains data coming from the HTTP request to a Lambda Function URL.
type LambdaFunctionURLRequest struct {
Version string `json:"version"` // Version is expected to be `"2.0"`
RawPath string `json:"rawPath"`
RawQueryString string `json:"rawQueryString"`
Cookies []string `json:"cookies,omitempty"`
Headers map[string]string `json:"headers"`
QueryStringParameters map[string]string `json:"queryStringParameters,omitempty"`
Comment on lines +11 to +12
Copy link

@jasdel jasdel Apr 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be map[string][]string or similar to events.APIGatewayProxyRequest. (Same comment for LambdaFunctionURLResponse)

Bringing this up mainly because both HTTP header and URL query strings can have multiple keys of the same value.

Header:

x-foo-bar: abc123
x-foo-bar: efg456
x-foo-bar: hij789, lkm000

query:

?foo=abc123&foo=efg456&foo=hij789&foo=lkm000

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lambda Function URLs ended up copying the API Gateway payload 2.0 shape, which I believe got rid of the MultiValue*** option - events.APIGatewayV2HTTPRequest

I'll double check what the Lambda Function URLs behavior is when a client sends multi value headers/query, maybe there is the option for a custom MarshalJSON/UnmarshalJSON so that the struct consumer can still get a map[string][]string - is that what you had in mind @jasdel ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verified that the function urls respects multi-value query string and multi-value headers, but concatenates with , rather than representing as an array in the json

my echo function:

def lambda_handler(event, _):
    return event

my test case:

~/test
$ cat furl.go
package main

import (
	"bytes"
	"io"
	"net/http"
	"os"
)

func main() {
	url := "https://sbehoygxywlg7dihj4baeak4im0sbkyf.lambda-url.us-west-2.on.aws/yolo?hello=world&hello=lambda&foo=bar"
	body := bytes.NewBuffer([]byte("hello"))
	req, _ := http.NewRequest("POST", url, body)
	req.Header.Add("whats", "up")
	req.Header.Add("hello", "world")
	req.Header.Add("hello", "lambda")
	res, _ := http.DefaultClient.Do(req)
	io.Copy(os.Stdout, res.Body)
}
~/test
$ go run furl.go | jq
{
  "headers": {
    "whats": "up",
    "x-amzn-trace-id": "Root=1-62512b3e-7cc00b5e15c31ddd5201e2b6",
    "x-forwarded-proto": "https",
    "host": "sbehoygxywlg7dihj4baeak4im0sbkyf.lambda-url.us-west-2.on.aws",
    "x-forwarded-port": "443",
    "hello": "world,lambda",
    "x-forwarded-for": "205.251.233.105",
    "accept-encoding": "gzip",
    "user-agent": "Go-http-client/1.1"
  },
  "isBase64Encoded": true,
  "rawPath": "/yolo",
  "routeKey": "$default",
  "requestContext": {
    "accountId": "anonymous",
    "timeEpoch": 1649486654761,
    "routeKey": "$default",
    "stage": "$default",
    "domainPrefix": "sbehoygxywlg7dihj4baeak4im0sbkyf",
    "requestId": "274319d8-362b-488d-af20-1350781093f9",
    "domainName": "sbehoygxywlg7dihj4baeak4im0sbkyf.lambda-url.us-west-2.on.aws",
    "http": {
      "path": "/yolo",
      "protocol": "HTTP/1.1",
      "method": "POST",
      "sourceIp": "205.251.233.105",
      "userAgent": "Go-http-client/1.1"
    },
    "time": "09/Apr/2022:06:44:14 +0000",
    "apiId": "sbehoygxywlg7dihj4baeak4im0sbkyf"
  },
  "queryStringParameters": {
    "foo": "bar",
    "hello": "world,lambda"
  },
  "body": "aGVsbG8=",
  "version": "2.0",
  "rawQueryString": "hello=world&hello=lambda&foo=bar"
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I came up with this approach to make the .Headers field more easily assignable to the stdlib http.Headers type. The same could be done for url.Values for the .QueryStringParameters field

type LambdaFunctionURLResponse struct {
// ...
	Headers         functionURLHeaders `json:"headers"`
// ...
}

type functionURLHeaders http.Header

func (headers *functionURLHeaders) UnmarshalJSON(b []byte) error {
	var intermediate map[string]commaSeperatedValues
	if err := json.Unmarshal(b, &intermediate); err != nil {
		return err
	}
	*headers = make(functionURLHeaders, len(intermediate))
	for k, v := range intermediate {
		(*headers)[k] = v
	}
	return nil
}

type commaSeperatedValues []string

func (values *commaSeperatedValues) UnmarshalJSON(b []byte) error {
	var s string
	if err := json.Unmarshal(b, &s); err != nil {
		return err
	}
	*values = strings.Split(s, ",")
	return nil
}

func (values commaSeperatedValues) MarshalJSON() ([]byte, error) {
	return json.Marshal(strings.Join(values, ","))
}

Which was neat, but I'm not 100% convinced this is the right approach for this struct. I'd hoped instead to be able to legally cast like var values map[string]commaSeperatedValue; headers := http.Header(values), but go's type system seemed to demand the make(...) and re-assignment loop, which felt marginally wasteful.

I poked @carlzogh again about this, and right now I'm leaning to keep this PR as-is rather than extending the serialization.

  1. For the most part, structs in this library, and types in the Java and .NET libraries, mirror the implicit JSON schema
  2. Documentation claims that Lambda function URLs are compatible with API Gateway HTTP Payload Version 2.0 - the struct for this type does not customize the .Header or .QueryStringParameters fields.

longer term, I'm open to smarter serialization for the http proxy event sources, as either some v2 types with a richer set of built-in types and appropriate documentation for construction, or with a higher abstraction layer that hides the json goo behind the stdlib http.Request/http.ResponseWriter types

Copy link

@jasdel jasdel Apr 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current implementation that joins multiple headers makes sense, given the API gateway 2.0 style. I'd suggestion not added automatic splitting on commas though, because comma being a delimiter in a header is dependent on the header, and requires domain knowledge.

For example a common date format, HTTP-Date, Wed, 21 Oct 2015 07:28:00 GMT. Contains a comma in its value. Logic that splits on commas without domain knowledge of the header would produce the wrong result. Leaving header value splitting to the consumer probably is the best path forward.

RequestContext LambdaFunctionURLRequestContext `json:"requestContext"`
Body string `json:"body,omitempty"`
IsBase64Encoded bool `json:"isBase64Encoded"`
}

// LambdaFunctionURLRequestContext contains the information to identify the AWS account and resources invoking the Lambda function.
type LambdaFunctionURLRequestContext struct {
AccountID string `json:"accountId"`
RequestID string `json:"requestId"`
Authorizer *LambdaFunctionURLRequestContextAuthorizerDescription `json:"authorizer,omitempty"`
APIID string `json:"apiId"` // APIID is the Lambda URL ID
DomainName string `json:"domainName"` // DomainName is of the format `"<url-id>.lambda-url.<region>.on.aws"`
DomainPrefix string `json:"domainPrefix"` // DomainPrefix is the Lambda URL ID
Time string `json:"time"`
TimeEpoch int64 `json:"timeEpoch"`
HTTP LambdaFunctionURLRequestContextHTTPDescription `json:"http"`
}

// LambdaFunctionURLRequestContextAuthorizerDescription contains authorizer information for the request context.
type LambdaFunctionURLRequestContextAuthorizerDescription struct {
IAM *LambdaFunctionURLRequestContextAuthorizerIAMDescription `json:"iam,omitempty"`
}

// LambdaFunctionURLRequestContextAuthorizerIAMDescription contains IAM information for the request context.
type LambdaFunctionURLRequestContextAuthorizerIAMDescription struct {
AccessKey string `json:"accessKey"`
AccountID string `json:"accountId"`
CallerID string `json:"callerId"`
UserARN string `json:"userArn"`
UserID string `json:"userId"`
}

// LambdaFunctionURLRequestContextHTTPDescription contains HTTP information for the request context.
type LambdaFunctionURLRequestContextHTTPDescription struct {
Method string `json:"method"`
Path string `json:"path"`
Protocol string `json:"protocol"`
SourceIP string `json:"sourceIp"`
UserAgent string `json:"userAgent"`
}

// LambdaFunctionURLResponse configures the HTTP response to be returned by Lambda Function URL for the request.
type LambdaFunctionURLResponse struct {
StatusCode int `json:"statusCode"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
IsBase64Encoded bool `json:"isBase64Encoded"`
Cookies []string `json:"cookies"`
}
57 changes: 57 additions & 0 deletions events/lambda_function_urls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.

package events

import (
"encoding/json"
"io/ioutil"
"testing"

"github.com/stretchr/testify/assert"
)

func TestLambdaFunctionURLResponseMarshaling(t *testing.T) {

// read json from file
inputJSON, err := ioutil.ReadFile("./testdata/lambda-urls-response.json")
if err != nil {
t.Errorf("could not open test file. details: %v", err)
}

// de-serialize into Go object
var inputEvent LambdaFunctionURLResponse
if err := json.Unmarshal(inputJSON, &inputEvent); err != nil {
t.Errorf("could not unmarshal event. details: %v", err)
}

// serialize to json
outputJSON, err := json.Marshal(inputEvent)
if err != nil {
t.Errorf("could not marshal event. details: %v", err)
}

assert.JSONEq(t, string(inputJSON), string(outputJSON))
}

func TestLambdaFunctionURLRequestMarshaling(t *testing.T) {

// read json from file
inputJSON, err := ioutil.ReadFile("./testdata/lambda-urls-request.json")
if err != nil {
t.Errorf("could not open test file. details: %v", err)
}

// de-serialize into Go object
var inputEvent LambdaFunctionURLRequest
if err := json.Unmarshal(inputJSON, &inputEvent); err != nil {
t.Errorf("could not unmarshal event. details: %v", err)
}

// serialize to json
outputJSON, err := json.Marshal(inputEvent)
if err != nil {
t.Errorf("could not marshal event. details: %v", err)
}

assert.JSONEq(t, string(inputJSON), string(outputJSON))
}
44 changes: 44 additions & 0 deletions events/testdata/lambda-urls-request.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"version": "2.0",
"rawPath": "/my/path",
"rawQueryString": "parameter1=value1&parameter1=value2&parameter2=value",
"cookies": [
"cookie1",
"cookie2"
],
"headers": {
"header1": "value1",
"header2": "value1,value2"
},
"queryStringParameters": {
"parameter1": "value1,value2",
"parameter2": "value"
},
"requestContext": {
"accountId": "123456789012",
"apiId": "<urlid>",
"authorizer": {
"iam": {
"accessKey": "AKIA...",
"accountId": "111122223333",
"callerId": "AIDA...",
"userArn": "arn:aws:iam::111122223333:user/example-user",
"userId": "AIDA..."
}
},
"domainName": "<url-id>.lambda-url.us-west-2.on.aws",
"domainPrefix": "<url-id>",
"http": {
"method": "POST",
"path": "/my/path",
"protocol": "HTTP/1.1",
"sourceIp": "123.123.123.123",
"userAgent": "agent"
},
"requestId": "id",
"time": "12/Mar/2020:19:03:58 +0000",
"timeEpoch": 1583348638390
},
"body": "Hello from client!",
"isBase64Encoded": false
}
13 changes: 13 additions & 0 deletions events/testdata/lambda-urls-response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"statusCode": 201,
"headers": {
"Content-Type": "application/json",
"My-Custom-Header": "Custom Value"
},
"body": "{ \"message\": \"Hello, world!\" }",
"cookies": [
"Cookie_1=Value1; Expires=21 Oct 2021 07:48 GMT",
"Cookie_2=Value2; Max-Age=78000"
],
"isBase64Encoded": false
}