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

Adding a new config to DefaultBinder #1675

Conversation

pafuent
Copy link
Contributor

@pafuent pafuent commented Nov 10, 2020

Now the DefaultBinder could be configured to avoid binding struct fields by name. This is particularly useful when the user don't want to bind certain struct fields (with this config in true, only the tagged fields will be binded)

Fixes #1620, fixes #1631, partially fixes #1670

Now the DefaultBinder could be configured to avoid binding struct fields
by name. This is particularly useful when the user don't want to bind
certain struct fields (with this config in true, only the tagged fields
will be binded)

Fixes labstack#1620, fixes labstack#1631, partially fixes labstack#1670
@codecov
Copy link

codecov bot commented Nov 10, 2020

Codecov Report

Merging #1675 (eb46e03) into master (7a90304) will increase coverage by 0.02%.
The diff coverage is 55.55%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #1675      +/-   ##
==========================================
+ Coverage   84.35%   84.37%   +0.02%     
==========================================
  Files          28       28              
  Lines        1911     1914       +3     
==========================================
+ Hits         1612     1615       +3     
  Misses        189      189              
  Partials      110      110              
Impacted Files Coverage Δ
bind.go 85.25% <55.55%> (+0.28%) ⬆️

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 7a90304...eb46e03. Read the comment docs.

DefaultBinder struct {
// AvoidBindByFieldName avoid binding struct fields by name automatically. If it's set to true, the binding is
// only performed when a valid tag is pressent
AvoidBindByFieldName bool
Copy link
Contributor

Choose a reason for hiding this comment

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

@pafuent please add tests with http.Request objects also. For example POST body in bind to struct with

echo/bind.go

Line 55 in ceffc10

if err = json.NewDecoder(req.Body).Decode(i); err != nil {

so it would work only when binding from route params or query params and not from body.

in that sense this comment

		// AvoidBindByFieldName avoid binding struct fields by name automatically. If it's set to true, the binding is
		// only performed when a valid tag is pressent
		AvoidBindByFieldName bool

is not correct, even misleading, because json.NewDecoder(req.Body).Decode(i) would still bind to public field even without tag

test like

func TestBindToStructFromJson(t *testing.T) {
	type User struct {
		ID int `json:"id"`
		IsAdmin bool // field without tag
	}

	e := New()
	req := httptest.NewRequest(http.MethodPost, "/api/endpoint", strings.NewReader(`{"id": 1, "IsAdmin": true}`))
	req.Header.Set(HeaderContentType, MIMEApplicationJSON)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)

	err := func(c Context) error {
		var payload User
		if err := c.Bind(&payload); err != nil {
			return c.JSON(http.StatusBadRequest, Map{"error": err})
		}

		if payload.IsAdmin {
			panic("field is filled by json.decode")
		}

		return c.JSON(http.StatusOK, payload)
	}(c)
	if err != nil {
		t.Fatal(err)
	}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After thinking a while about this, I couldn't find a way to achieve this desired behavior. There is no way in the Go encoding/json package to ignore fields dynamically, for example using a particular configuration of the Decoder.
Also, I think that the JSON binding should behave as Go encoding/json package defined it, that is what a user of Echo would expect. For that reason I only rewrite the doc of DefaultBinder#AvoidBindByFieldName and I added a new UT that shows how a struct field should be ignored according the Go encoding/json package.
Hope that could satisfy your change request. If you have any idea that I could better adapt to your needs, please don't hesitate and let me know and I'll implement it.

Copy link
Contributor

@aldas aldas Nov 11, 2020

Choose a reason for hiding this comment

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

Yeap, I know that json decoder does not allow that wanted to point that out. The commend should probably explicitly say that this flag does not apply to 'request body' (except form). mentioning 'json/xml' is maybe too vague as in domain of HTTP you are primarly dealing with method, urls and bodys - and contents of body is one level 'deeper'.

Copy link
Contributor

@aldas aldas Nov 11, 2020

Choose a reason for hiding this comment

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

@pafuent if you already are adding flag to binder why not add flags to disable binding route and query params. This would allow user to customize what is bind

echo/bind.go

Line 52 in 9ba1225

if err = b.bindData(i, c.QueryParams(), "query"); err != nil {

	if !b.DisableRouteParamsBinding {
		if err := b.bindData(i, params, "param"); err != nil {
			return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
		}
	}
	if !b.DisableQueryParamsBinding {
		if err = b.bindData(i, c.QueryParams(), "query"); err != nil {
			return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
		}
	}
	if b.DisableBodyBinding || req.ContentLength == 0 {
		return
	}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe I'm missing something but from my understanding those flags are not needed, due to the new flag that I added. If you don't have a tag on your struct field and AvoidBindByFieldName set to true, that field won't be bind, unless you are dealing with json/xml (which is what I tried to capture in my comment, if the wording is not accurate, please let me know and I'm glad to change it)
I'm not against adding those flags, it's just seems that their are not needed and making that change will could generate a performance impact for every bind on the server (maybe small, I'm not an expert on the cost of multiple if statements)

Copy link
Contributor

@aldas aldas Nov 12, 2020

Choose a reason for hiding this comment

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

change will could generate a performance impact for every bind on the server

This would allow user to switch off bindData method for them in cases (json POST for example hardly ever needs to bind query params actually). What bindData does is more expensive than one boolean check ever does.

by adding if to bindData for i := 0; i < typ.NumField(); i++ { loop
https://github.com/labstack/echo/pull/1675/files#diff-aade326d3512b5a2ada6faa791ddec468f2a0adedb352339c9e314e74c8949d2L122

} else if b.AvoidBindByFieldName == false {

this means that this if is checked for every (settable) struct field for both route and query params. Assuming struct we are binding to has 10 fields this would mean 10+10 IFs for one request. This is more than 3 IFs to see if bindData is needed at all. In that context these 3 ifs does not matter much

from my understanding those flags are not needed

These flags would allow user precise control over where things for binding are taken by switching of things that should not be used. When your endpoint is json post you most of the time do not expect it to bind anything from query.

Also see this example. AvoidBindByFieldName does not fix the need that sometimes you would like not to bind query params and like to bind route params. In this example first test fails because AvoidBindByFieldName switches off route params binding and second test fails because query params have higher priority than route params.

func TestDefaultBinder_Bind(t *testing.T) {
	type Node struct {
		ID   int    `json:"id"`
		Node string `json:"node"`
	}

	var testCases = []struct {
		name                     string
		givenURL                 string
		givenContent             io.Reader
		givenMethod              string
		whenAvoidBindByFieldName bool
		expectNode               string
	}{
		{
			name:                     "bind to struct with route param + query param and AvoidBindByFieldName=true",
			givenMethod:              http.MethodPost,
			givenURL:                 "/api/real_node/endpoint?node=xxx",
			givenContent:             strings.NewReader(`{"id": 1}`),
			whenAvoidBindByFieldName: true,
			expectNode:               "real_node", // why is route param not bind?
		},
		{
			name:         "bind to struct with route param + query param",
			givenMethod:  http.MethodPost,
			givenURL:     "/api/real_node/endpoint?node=xxx",
			givenContent: strings.NewReader(`{"id": 1}`),
			expectNode:   "real_node", // why is route param overwritten by query param?
		},
		{
			name:         "bind to struct with route + query + body = body has priority, AvoidBindByFieldName=false",
			givenMethod:  http.MethodPost,
			givenURL:     "/api/real_node/endpoint?node=xxx",
			givenContent: strings.NewReader(`{"id": 1, "node": "zzz"}`),
			expectNode:   "zzz",
		},
		{
			name:                     "bind to struct with route + query + body = body has priority, AvoidBindByFieldName=true",
			givenMethod:              http.MethodPost,
			givenURL:                 "/api/real_node/endpoint?node=xxx",
			givenContent:             strings.NewReader(`{"id": 1, "node": "zzz"}`),
			whenAvoidBindByFieldName: true,
			expectNode:               "zzz",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			e := New()
			// assume route we are testing is "/api/:node/endpoint"
			req := httptest.NewRequest(tc.givenMethod, tc.givenURL, tc.givenContent)
			req.Header.Set(HeaderContentType, MIMEApplicationJSON)
			rec := httptest.NewRecorder()
			c := e.NewContext(req, rec)

			c.SetParamNames("node")
			c.SetParamValues("real_node")

			defaultUser := &Node{}
			b := new(DefaultBinder)
			b.AvoidBindByFieldName = tc.whenAvoidBindByFieldName

			err := b.Bind(defaultUser, c)
			if err != nil {
				t.Fatal(err)
			}
			assert.Equal(t, tc.expectNode, defaultUser.Node)
		})
	}
}

Copy link
Contributor

@aldas aldas Nov 12, 2020

Choose a reason for hiding this comment

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

I think root cause for many todays problems is still b129098 which caused bindData to be used for POST/PUT methods. Before that query params was bind only for GET/DELETE. We could say that even this change is slight performance degradation for POST/PUTs as post with query params means that now we are reflecting to see if we could fill struct with query params (before decoding body content to struct)

before:

	if req.ContentLength == 0 {
		if req.Method == http.MethodGet || req.Method == http.MethodDelete {
			if err = b.bindData(i, c.QueryParams(), "query"); err != nil {
				return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
			}
			return
		}
		return NewHTTPError(http.StatusBadRequest, "Request body can't be empty")
	}
	ctype := req.Header.Get(HeaderContentType)

now:

	}
	if err = b.bindData(i, c.QueryParams(), "query"); err != nil {
		return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
	}
	if req.ContentLength == 0 {
		return
	}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the testing code, It help me a lot to understand better your point.
I'll try to answer your questions:

		{
			name:                     "bind to struct with route param + query param and AvoidBindByFieldName=true",
			givenMethod:              http.MethodPost,
			givenURL:                 "/api/real_node/endpoint?node=xxx",
			givenContent:             strings.NewReader(`{"id": 1}`),
			whenAvoidBindByFieldName: true,
			expectNode:               "real_node", // why is route param not bind?
		},

Why is route param not bind?
That happens because the Node struct didn't say that. You should tag the Node field as json:"node" param:"node"
So in this case, Echo is giving to the developer the knobs that he/she needs to properly configure the binding.
For this is reason is that I believe that those flags are not needed. If you just add tags to your structs that binds for json/xml and don't add tags for param/query and you set AvoidBindByFieldName to true, you will get the behavior that you are requesting. In other words, the binding of route params, query parameters or form data could be configured through the use of the param/query/form tags. If you don't set those tags, the binding won't happen. The thing is that the use of json/xml tags doesn't prevent the binding from route params or query params, which is what I tried to solve with AvoidBindByFieldName.

		{
			name:         "bind to struct with route param + query param",
			givenMethod:  http.MethodPost,
			givenURL:     "/api/real_node/endpoint?node=xxx",
			givenContent: strings.NewReader(`{"id": 1}`),
			expectNode:   "real_node", // why is route param overwritten by query param?
		}

Here you are right, it's because the order in which the code is executed. Sadly I don't know why this was coded in that way, neither which were the design decisions that lead to that order. Maybe someone with more knowledge in the Echo code base could help us.

This would allow user to switch off bindData method for them in cases (json POST for example hardly ever needs to bind query params actually).

That sounds as a nice Use Case for me, I'll add those flags in favor of the performance gain that you are mentioning.

Adding a JSON Bind new unit test
Adding to the DefaultBinder one flag per binding method to prevent it if
the flag is set to true.
@pafuent pafuent closed this Nov 13, 2020
@pafuent
Copy link
Contributor Author

pafuent commented Nov 13, 2020

Closing this PR in favor of #1681

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants