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

improvement of validating arguments #365

Closed

Conversation

shogo82148
Copy link
Contributor

Issue #, if available:

I passed interface { Value(key interface{}) interface{} } to the first argument of the handler.

package main

import (
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda" // v1.13.3
)

type valuer interface {
	Value(key interface{}) interface{}
}

func handler(ctx valuer, v interface{}) (events.APIGatewayProxyResponse, error) {
	return events.APIGatewayProxyResponse{
		Body:       "Hello",
		StatusCode: 200,
	}, nil
}

func main() {
	lambda.Start(handler)
}

Actual Behavior:
It fails with "handler takes two arguments, but the first is not Context. got interface".
Here is the result of running with SAM CLI.

% curl http://localhost:3000/hello
{"message":"Internal server error"}
% sam local start-api
Mounting HelloWorldFunction at http://127.0.0.1:3000/hello [GET]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2021-03-07 18:19:00  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)
Invoking hello-world (go1.x)
Skip pulling image and use local one: amazon/aws-sam-cli-emulation-image-go1.x:rapid-1.20.0.

Mounting /Users/shogoichinose/tmp/2021-03-07-lambda-context/aws-sam-golang/.aws-sam/build/HelloWorldFunction as /var/task:ro,delegated inside runtime container
START RequestId: 2cdebf6d-a4fa-4e36-bf64-996d10686f29 Version: $LATEST
handler takes two arguments, but the first is not Context. got interface: errorString
null
END RequestId: 2cdebf6d-a4fa-4e36-bf64-996d10686f29
REPORT RequestId: 2cdebf6d-a4fa-4e36-bf64-996d10686f29  Init Duration: 0.27 ms  Duration: 100.46 ms     Billed Duration: 200 ms Memory Size: 128 MB     Max Memory Used: 128 MB
Lambda returned empty body!
Invalid lambda response received: Invalid API Gateway Response Keys: {'errorType', 'errorMessage'} in {'errorMessage': 'handler takes two arguments, but the first is not Context. got interface', 'errorType': 'errorString'}
2021-03-07 18:19:05 127.0.0.1 - - [07/Mar/2021 18:19:05] "GET /hello HTTP/1.1" 502 -

Expected Behavior:
It should be same result as the first argument is context.Context.

package main

import (
	"context"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func handler(ctx context.Context, v interface{}) (events.APIGatewayProxyResponse, error) {
	return events.APIGatewayProxyResponse{
		Body:       "Hello",
		StatusCode: 200,
	}, nil
}

func main() {
	lambda.Start(handler)
}
% curl http://localhost:3000/hello
Hello
% sam local start-api
Mounting HelloWorldFunction at http://127.0.0.1:3000/hello [GET]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2021-03-07 18:20:01  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)
Invoking hello-world (go1.x)
Skip pulling image and use local one: amazon/aws-sam-cli-emulation-image-go1.x:rapid-1.20.0.

Mounting /Users/shogoichinose/tmp/2021-03-07-lambda-context/aws-sam-golang/.aws-sam/build/HelloWorldFunction as /var/task:ro,delegated inside runtime container
END RequestId: 4827cdf0-3fab-4a19-b352-f3f10977d442
REPORT RequestId: 4827cdf0-3fab-4a19-b352-f3f10977d442  Init Duration: 0.17 ms  Duration: 111.62 ms     Billed Duration: 200 ms Memory Size: 128 MB     Max Memory Used: 128 MB
No Content-Type given. Defaulting to 'application/json'.
2021-03-07 18:20:05 127.0.0.1 - - [07/Mar/2021 18:20:05] "GET /hello HTTP/1.1" 200 -

Another example:

package main

import (
	"context"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

type customContext interface {
	context.Context
	MyCustomMethod()
}

func handler(ctx customContext, v interface{}) (events.APIGatewayProxyResponse, error) {
	return events.APIGatewayProxyResponse{
		Body:       "Hello",
		StatusCode: 200,
	}, nil
}

func main() {
	lambda.Start(handler)
}

It panics with "reflect: Call using *context.valueCtx as type main.customContext".
aws/aws-lambda-go should return an error with more friendly message.

% curl http://localhost:3000/hello
{"message":"Internal server error"}
% sam local start-api
Mounting HelloWorldFunction at http://127.0.0.1:3000/hello [GET]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2021-03-07 18:16:20  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)
Invoking hello-world (go1.x)
Skip pulling image and use local one: amazon/aws-sam-cli-emulation-image-go1.x:rapid-1.20.0.

Mounting /Users/shogoichinose/tmp/2021-03-07-lambda-context/aws-sam-golang/.aws-sam/build/HelloWorldFunction as /var/task:ro,delegated inside runtime container
START RequestId: 0ae34f7a-c15b-45ce-a094-0e5d7800324e Version: $LATEST
reflect: Call using *context.valueCtx as type main.customContext: string
[{"path":"github.com/aws/aws-lambda-go@v1.13.3/lambda/function.go","line":35,"label":"(*Function).Invoke.func1"},{"path":"runtime/panic.go","line":965,"label":"gopanic"},{"path":"reflect/value.go","line":406,"label":"Value.call"},{"path":"reflect/value.go","line":337,"label":"Value.Call"},{"path":"github.com/aws/aws-lambda-go@v1.13.3/lambda/handler.go","line":124,"label":"NewHandler.func1"},{"path":"github.com/aws/aws-lambda-go@v1.13.3/lambda/handler.go","line":24,"label":"lambdaHandler.Invoke"},{"path":"github.com/aws/aws-lambda-go@v1.13.3/lambda/function.go","line":67,"label":"(*Function).Invoke"},{"path":"reflect/value.go","line":476,"label":"Value.call"},{"path":"reflect/value.go","line":337,"label":"Value.Call"},{"path":"net/rpc/server.go","line":377,"label":"(*service).call"},{"path":"runtime/asm_amd64.s","line":1371,"label":"goexit"}]
END RequestId: 0ae34f7a-c15b-45ce-a094-0e5d7800324e
REPORT RequestId: 0ae34f7a-c15b-45ce-a094-0e5d7800324e  Init Duration: 0.18 ms  Duration: 81.29 ms      Billed Duration: 100 ms Memory Size: 128 MB     Max Memory Used: 128 MB
Lambda returned empty body!
Invalid lambda response received: Invalid API Gateway Response Keys: {'errorType', 'errorMessage', 'stackTrace'} in {'errorMessage': 'reflect: Call using *context.valueCtx as type main.customContext', 'errorType': 'string', 'stackTrace': [{'path': 'github.com/aws/aws-lambda-go@v1.13.3/lambda/function.go', 'line': 35, 'label': '(*Function).Invoke.func1'}, {'path': 'runtime/panic.go', 'line': 965, 'label': 'gopanic'}, {'path': 'reflect/value.go', 'line': 406, 'label': 'Value.call'}, {'path': 'reflect/value.go', 'line': 337, 'label': 'Value.Call'}, {'path': 'github.com/aws/aws-lambda-go@v1.13.3/lambda/handler.go', 'line': 124, 'label': 'NewHandler.func1'}, {'path': 'github.com/aws/aws-lambda-go@v1.13.3/lambda/handler.go', 'line': 24, 'label': 'lambdaHandler.Invoke'}, {'path': 'github.com/aws/aws-lambda-go@v1.13.3/lambda/function.go', 'line': 67, 'label': '(*Function).Invoke'}, {'path': 'reflect/value.go', 'line': 476, 'label': 'Value.call'}, {'path': 'reflect/value.go', 'line': 337, 'label': 'Value.Call'}, {'path': 'net/rpc/server.go', 'line': 377, 'label': '(*service).call'}, {'path': 'runtime/asm_amd64.s', 'line': 1371, 'label': 'goexit'}]}
2021-03-07 18:16:27 127.0.0.1 - - [07/Mar/2021 18:16:27] "GET /hello HTTP/1.1" 502 -

Description of changes:

The validateArguments validates that the first argument implements context.Context.
It is documented in the official document:

https://docs.aws.amazon.com/lambda/latest/dg/golang-handler.html#golang-handler-structs
The handler may take between 0 and 2 arguments. If there are two arguments, the first argument must implement context.Context.

The validateArguments should validate that the first argument is an interface and a subset of context.Context.

The handler may take between 0 and 2 arguments. If there are two arguments, the first argument must be an interface and a subset of context.Context.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov-io
Copy link

Codecov Report

Merging #365 (96bd7dc) into master (6c2af88) will increase coverage by 0.37%.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #365      +/-   ##
==========================================
+ Coverage   72.22%   72.59%   +0.37%     
==========================================
  Files          19       19              
  Lines         738      748      +10     
==========================================
+ Hits          533      543      +10     
  Misses        138      138              
  Partials       67       67              
Impacted Files Coverage Δ
lambda/handler.go 97.33% <100.00%> (+0.41%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 6c2af88...96bd7dc. Read the comment docs.

Copy link
Contributor

@harrisonhjones harrisonhjones left a comment

Choose a reason for hiding this comment

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

Generally LGTM. Would be nice to better understand why you want to pass something other than a context.Context to handler.

lambda/handler.go Outdated Show resolved Hide resolved
return false, nil
}
if !contextType.Implements(argumentType) {
return false, errors.New("the first argument is an interface, but it is not a Context")
Copy link
Contributor

Choose a reason for hiding this comment

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

nit pick: consider following the existing error pattern of handler.... Maybe handler argument is an interface that context.Context does not implement? Or, if we exposed valuer it could be handler argument does not implement Valuer.

if !contextType.Implements(argumentType) {
return false, errors.New("the first argument is an interface, but it is not a Context")
}
if argumentType.NumMethod() == 0 {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't quite understand what this line is doing. Can you provide a bit more detail?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this case, the handler has the following signature.

func handler(v interface{}) {}

func main() { lambda.Start(handler) }

We have two choices to call the handler.
First is:

ctx := context.TODO()
handler(ctx)

and, second is:

v, _ := json.Unmarshal(data)
handler(v)

Calling the handler succeeds in either case, but we need to choose one of them.
Current implementation chooses the second.
So, to avoid breaking backward compatibility, we should the second here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Assuming the first argument does implement valuer (ie contextType.implements(argumentType) == true) wouldn't it always have at least 1 exported method? And if it did wouldn't this check always return false?

Copy link
Contributor Author

@shogo82148 shogo82148 Mar 10, 2021

Choose a reason for hiding this comment

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

And if it did wouldn't this check always return false?

No.
contextType.implements(argumentType) == true means the argumentType is a subset of context.Context.
The subsets of context.Context includes not only valuer but also interface{}.

Here is an example code: https://play.golang.org/p/qZMYLva7Rzo

package main

import (
	"context"
	"fmt"
)

type valuer interface {
	Value(key interface{}) interface{}
}

func main() {
	var ok bool
	ctx := context.TODO()

	_, ok = ctx.(valuer)
	fmt.Printf("ctx implements valuer?: %t\n", ok)

	_, ok = ctx.(interface{})
	fmt.Printf("ctx implements interface{}?: %t\n", ok)

	// Output:
	// ctx implements valuer?: true
	// ctx implements interface{}?: true
}

Comment on lines +64 to +65
contextType := reflect.TypeOf((*context.Context)(nil)).Elem()
argumentType := handler.In(0)
Copy link
Contributor

Choose a reason for hiding this comment

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

Not a big deal but it would be nice if we didn't have to duplicate these lines. I wonder if it would make sense to combine case 1 and case 2 together somehow? I'd have to see what it looked like to understand if it impacts readability/maintainability.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

case 1 has a lot of corner cases. it is too difficult to combine them.

}
for i, testCase := range testCases {
testCase := testCase
t.Run(fmt.Sprintf("testCase[%d] %s", i, testCase.name), func(t *testing.T) {
lambdaHandler := NewHandler(testCase.handler)
_, err := lambdaHandler.Invoke(context.TODO(), make([]byte, 0))
_, err := lambdaHandler.Invoke(context.TODO(), []byte("{}"))
Copy link
Contributor

Choose a reason for hiding this comment

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

Why was this line changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

because it should be valid JSON.
If it is invalid, the Invoke returns an error of parsing JSON.

Copy link
Contributor

Choose a reason for hiding this comment

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

huh, interesting. Thanks for the fix.

Copy link
Contributor

@harrisonhjones harrisonhjones left a comment

Choose a reason for hiding this comment

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

Looking good; a few follow up questions/comments.

}
for i, testCase := range testCases {
testCase := testCase
t.Run(fmt.Sprintf("testCase[%d] %s", i, testCase.name), func(t *testing.T) {
lambdaHandler := NewHandler(testCase.handler)
_, err := lambdaHandler.Invoke(context.TODO(), make([]byte, 0))
_, err := lambdaHandler.Invoke(context.TODO(), []byte("{}"))
Copy link
Contributor

Choose a reason for hiding this comment

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

huh, interesting. Thanks for the fix.

if !contextType.Implements(argumentType) {
return false, errors.New("the first argument is an interface, but it is not a Context")
}
if argumentType.NumMethod() == 0 {
Copy link
Contributor

Choose a reason for hiding this comment

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

Assuming the first argument does implement valuer (ie contextType.implements(argumentType) == true) wouldn't it always have at least 1 exported method? And if it did wouldn't this check always return false?

lambda/handler.go Outdated Show resolved Hide resolved

type customContext interface {
context.Context
MyCustomMethod()
Copy link
Contributor

Choose a reason for hiding this comment

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

You mention in the PR description that you wanted to be able to use this kind of interface with your handlers but if all aws-lambda-go is passing in is a valuer (ie, a context.Context) wouldn't this fail at runtime because context.Context does not satisfy customContext?

Looking at the test it must not fail at runtime but I don't understand exactly how.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I want to use interfaces like valuer, but I don't need to use customContext.

wouldn't this fail at runtime because context.Context does not satisfy customContext?

Yes, actually it panics with "reflect: Call using *context.valueCtx as type main.customContext" in aws/aws-lambda-go.
Users can’t understand it is a bug of aws/aws-lambda-go or wrong usage of aws/aws-lambda-go.
so, aws/aws-lambda-go should return an error with more friendly message rather than panicking.

@bmoffatt
Copy link
Collaborator

The validateArguments should validate that the first argument is an interface and a subset of context.Context.

Defining a handler to take only a subset of context.Context seems weird to me, can you help me understand why this would be a useful feature?

@shogo82148
Copy link
Contributor Author

Actually, I don't have any idea for useful use case of this feature.
But, defining a handler to take only a subset of context.Context comes from the Go language specification.

For example, the handler takes a subset of context.Context, and it works well: https://play.golang.org/p/vPOtIklA3il

package main

import (
	"context"
)

// valuer is a subset of context.Context
type valuer interface {
	Value(key interface{}) interface{}
}

func handler(ctx valuer) {
	return
}

func main() {
	handler(context.TODO())
}

In other hand, the handler takes a superset of context.Context.
It causes a compile error: https://play.golang.org/p/EQ1qAb9zOkJ

package main

import (
	"context"
)

// customContext is a superset of context.Context
// In other words, `customContext` implements `context.Context`.
type customContext interface {
	context.Context
	MyCustomMethod()
}

func handler(ctx customContext) {
	return
}

func main() {
	// compile error:
	// cannot use context.TODO() (type context.Context) as type customContext in argument to handler:
	// context.Context does not implement customContext (missing MyCustomMethod method)
	handler(context.TODO())
}

In this case, the first argument of the handler implements context.Context.
But we can't write handler(context.TODO()).

@shogo82148
Copy link
Contributor Author

Note that I'm using the word "subset" as a term of mathematics.

https://en.wikipedia.org/wiki/Subset

In mathematics, a set A is a subset of a set B if all elements of A are also elements of B; B is then a superset of A. It is possible for A and B to be equal; if they are unequal, then A is a proper subset of B.

So, interface{} is a subset of context.Context, and context.Context is also a subset of context.Context.

@codecov-commenter
Copy link

codecov-commenter commented May 22, 2021

Codecov Report

Merging #365 (fbc19fd) into master (99b35f2) will increase coverage by 0.37%.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #365      +/-   ##
==========================================
+ Coverage   72.22%   72.59%   +0.37%     
==========================================
  Files          19       19              
  Lines         738      748      +10     
==========================================
+ Hits          533      543      +10     
  Misses        138      138              
  Partials       67       67              
Impacted Files Coverage Δ
lambda/handler.go 97.33% <100.00%> (+0.41%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 99b35f2...fbc19fd. Read the comment docs.

@shogo82148
Copy link
Contributor Author

Can we merge this?
Or does someone have some questions?

lambda/handler.go Outdated Show resolved Hide resolved
@shogo82148
Copy link
Contributor Author

Hi, any progress?
It is a very rare corner case, however it confuses users. e.g. #377
I'm still waiting for merging this.

Copy link
Collaborator

@bmoffatt bmoffatt left a comment

Choose a reason for hiding this comment

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

I think the size of the changes to resolve #377 be reduced, by making the context type check an equality one.

eg: doing the following change

	handlerTakesContext := false
	if handler.NumIn() > 2 {
		return false, fmt.Errorf("handlers may not take more than two arguments, but handler takes %d", handler.NumIn())
	} else if handler.NumIn() > 0 {
		contextType := reflect.TypeOf((*context.Context)(nil)).Elem()
		argumentType := handler.In(0)
-		handlerTakesContext = argumentType.Implements(contextType)
+		handlerTakesContext = argumentType == contextType
		if handler.NumIn() > 1 && !handlerTakesContext {
			return false, fmt.Errorf("handler takes two arguments, but the first is not Context. got %s", argumentType.Kind())
		}
	}

If there's no major back-compat issue with something like this, I'd rather make the smaller change. Adding the other logic from this PR to support sub interfaces of context.Context is additional surface area that I'm not comfortable with as a maintainer. (unless there's some usecase that could be enabled by that - but that may be better discussed in a standalone issue)

@shogo82148
Copy link
Contributor Author

I think the size of the changes to resolve #377 be reduced, by making the context type check an equality one.

Your example may break compatibility.

e.g. myContext works with current version, because myContext implements context.Context (argumentType.Implements(contextType) is true)

// myContext has same definition with context.Context.
type myContext interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

func handler(ctx myContext, v interface{}) (events.APIGatewayProxyResponse, error) {
	return events.APIGatewayProxyResponse{
		Body:       "Hello",
		StatusCode: 200,
	}, nil
}

func main() {
	lambda.Start(handler)
}

but after fixing, validation will fail because argumentType == contextType is false. https://go.dev/play/p/Z66z7_GuPZR

package main

import (
	"context"
	"fmt"
	"reflect"
	"time"
)

// myContext has same definition with context.Context.
type myContext interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

func main() {
	contextType := reflect.TypeOf((*context.Context)(nil)).Elem()
	myContextType := reflect.TypeOf((*myContext)(nil)).Elem()

	// but not equals.
	fmt.Println(contextType == myContextType)
	// Output:
	// false
}

@shogo82148 shogo82148 mentioned this pull request Dec 21, 2022
@shogo82148
Copy link
Contributor Author

I've just opened a new pull request #475.
It is rework of this pull request, but it doesn't accept valuer.

@bmoffatt
Copy link
Collaborator

closing since #475 was merged

@bmoffatt bmoffatt closed this Dec 23, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants