Skip to content

Commit

Permalink
feat: add support for complex query params (#704)
Browse files Browse the repository at this point in the history
Fixes #703 

Given a ingress route defined as `/productcatalog/{id}`

Parse this request
`localhost:8892/ingress/productcatalog/123456?test=a&test=b&a.b=test`

Result will be a request map that combines the path parameter `{id}` and
the query params fields:
```
map[a:map[b:test] id:123456 test:[a b]]
```
  • Loading branch information
wesbillman authored Dec 6, 2023
1 parent 42a3175 commit e2ce1f7
Show file tree
Hide file tree
Showing 2 changed files with 116 additions and 3 deletions.
67 changes: 64 additions & 3 deletions backend/controller/ingress/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"math/rand"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
Expand Down Expand Up @@ -124,9 +125,13 @@ func buildRequestMap(route *dal.IngressRoute, r *http.Request) (map[string]any,
requestMap[k] = v
}
default:
// TODO: Support query params correctly for map and array
for key, value := range r.URL.Query() {
requestMap[key] = value[len(value)-1]
queryMap, err := parseQueryParams(r.URL.Query())
if err != nil {
return nil, fmt.Errorf("HTTP query params are not valid: %w", err)
}

for key, value := range queryMap {
requestMap[key] = value
}
}

Expand Down Expand Up @@ -249,3 +254,59 @@ func validateValue(fieldType schema.Type, path path, value any, sch *schema.Sche
}
return nil
}

func parseQueryParams(values url.Values) (map[string]any, error) {
if jsonStr, ok := values["@json"]; ok {
if len(values) > 1 {
return nil, fmt.Errorf("only '@json' parameter is allowed, but other parameters were found")
}
if len(jsonStr) > 1 {
return nil, fmt.Errorf("'@json' parameter must be provided exactly once")
}

return decodeQueryJSON(jsonStr[0])
}

queryMap := make(map[string]any)
for key, value := range values {
if hasInvalidQueryChars(key) {
return nil, fmt.Errorf("complex key '%s' is not supported, use '@json=' instead", key)
}
if len(value) == 1 {
if hasInvalidQueryChars(value[0]) {
return nil, fmt.Errorf("complex value '%s' is not supported, use '@json=' instead", value[0])
}
queryMap[key] = value[0]
} else {
for _, v := range value {
if hasInvalidQueryChars(v) {
return nil, fmt.Errorf("complex value '%s' is not supported, use '@json=' instead", v)
}
}
// Assign as an array of strings if there are multiple values for the key
queryMap[key] = value
}
}

return queryMap, nil
}

func decodeQueryJSON(query string) (map[string]any, error) {
decodedJSONStr, err := url.QueryUnescape(query)
if err != nil {
return nil, fmt.Errorf("failed to decode '@json' query parameter: %w", err)
}

// Unmarshal the JSON string into a map
var resultMap map[string]any
err = json.Unmarshal([]byte(decodedJSONStr), &resultMap)
if err != nil {
return nil, fmt.Errorf("failed to parse '@json' query parameter: %w", err)
}

return resultMap, nil
}

func hasInvalidQueryChars(s string) bool {
return strings.ContainsAny(s, "{}[]|\\^`")
}
52 changes: 52 additions & 0 deletions backend/controller/ingress/ingress_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ingress

import (
"net/url"
"testing"

"github.com/TBD54566975/ftl/backend/schema"
Expand Down Expand Up @@ -73,3 +74,54 @@ func TestValidation(t *testing.T) {
})
}
}

func TestParseQueryParams(t *testing.T) {
tests := []struct {
query string
request obj
err string
}{
{query: "", request: obj{}},
{query: "a=10", request: obj{"a": "10"}},
{query: "a=10&a=11", request: obj{"a": []string{"10", "11"}}},
{query: "a=10&b=11&b=12", request: obj{"a": "10", "b": []string{"11", "12"}}},
{query: "[a,b]=c", request: nil, err: "complex key '[a,b]' is not supported, use '@json=' instead"},
{query: "a=[1,2]", request: nil, err: "complex value '[1,2]' is not supported, use '@json=' instead"},
}

for _, test := range tests {
parsedQuery, err := url.ParseQuery(test.query)
assert.NoError(t, err)
actual, err := parseQueryParams(parsedQuery)
assert.EqualError(t, err, test.err)
assert.Equal(t, test.request, actual, test.query)
}
}

func TestParseQueryJson(t *testing.T) {
tests := []struct {
query string
request obj
err string
}{
{query: "@json=", request: nil, err: "failed to parse '@json' query parameter: unexpected end of JSON input"},
{query: "@json=10", request: nil, err: "failed to parse '@json' query parameter: json: cannot unmarshal number into Go value of type map[string]interface {}"},
{query: "@json=10&a=b", request: nil, err: "only '@json' parameter is allowed, but other parameters were found"},
{query: "@json=%7B%7D", request: obj{}},
{query: `@json=%7B%22a%22%3A%2010%7D`, request: obj{"a": 10.0}},
{query: `@json=%7B%22a%22%3A%2010%2C%20%22b%22%3A%2011%7D`, request: obj{"a": 10.0, "b": 11.0}},
{query: `@json=%7B%22a%22%3A%20%7B%22b%22%3A%2010%7D%7D`, request: obj{"a": obj{"b": 10.0}}},
{query: `@json=%7B%22a%22%3A%20%7B%22b%22%3A%2010%7D%2C%20%22c%22%3A%2011%7D`, request: obj{"a": obj{"b": 10.0}, "c": 11.0}},

// also works with non-urlencoded json
{query: `@json={"a": {"b": 10}, "c": 11}`, request: obj{"a": obj{"b": 10.0}, "c": 11.0}},
}

for _, test := range tests {
parsedQuery, err := url.ParseQuery(test.query)
assert.NoError(t, err)
actual, err := parseQueryParams(parsedQuery)
assert.EqualError(t, err, test.err)
assert.Equal(t, test.request, actual, test.query)
}
}

0 comments on commit e2ce1f7

Please sign in to comment.