diff --git a/backend/controller/ingress/ingress.go b/backend/controller/ingress/ingress.go index a4d7c67a58..e317bb477c 100644 --- a/backend/controller/ingress/ingress.go +++ b/backend/controller/ingress/ingress.go @@ -6,6 +6,7 @@ import ( "fmt" "math/rand" "net/http" + "net/url" "reflect" "strconv" "strings" @@ -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 } } @@ -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, "{}[]|\\^`") +} diff --git a/backend/controller/ingress/ingress_test.go b/backend/controller/ingress/ingress_test.go index 1e0a361abc..8588830e7d 100644 --- a/backend/controller/ingress/ingress_test.go +++ b/backend/controller/ingress/ingress_test.go @@ -1,6 +1,7 @@ package ingress import ( + "net/url" "testing" "github.com/TBD54566975/ftl/backend/schema" @@ -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) + } +}