Skip to content

Commit

Permalink
feat: HttpRequest path and query mapping
Browse files Browse the repository at this point in the history
fixes: #2230
  • Loading branch information
stuartwdouglas committed Aug 19, 2024
1 parent bedc74e commit 668d0b0
Show file tree
Hide file tree
Showing 24 changed files with 478 additions and 226 deletions.
15 changes: 8 additions & 7 deletions backend/controller/console/console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package console
import (
"testing"

"github.com/TBD54566975/ftl/backend/schema"
"github.com/alecthomas/assert/v2"

"github.com/TBD54566975/ftl/backend/schema"
)

func TestVerbSchemaString(t *testing.T) {
Expand All @@ -15,7 +16,7 @@ func TestVerbSchemaString(t *testing.T) {
}
ingressVerb := &schema.Verb{
Name: "Ingress",
Request: &schema.Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []schema.Type{&schema.String{}}},
Request: &schema.Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []schema.Type{&schema.String{}, &schema.Unit{}, &schema.Unit{}}},
Response: &schema.Ref{Module: "builtin", Name: "HttpResponse", TypeParameters: []schema.Type{&schema.String{}, &schema.String{}}},
Metadata: []schema.Metadata{
&schema.MetadataIngress{Type: "http", Method: "GET", Path: []schema.IngressPathComponent{&schema.IngressPathLiteral{Text: "test"}}},
Expand Down Expand Up @@ -107,7 +108,7 @@ verb Echo(foo.EchoRequest) foo.EchoResponse`
func TestVerbSchemaStringIngress(t *testing.T) {
verb := &schema.Verb{
Name: "Ingress",
Request: &schema.Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []schema.Type{&schema.Ref{Module: "foo", Name: "FooRequest"}}},
Request: &schema.Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []schema.Type{&schema.Ref{Module: "foo", Name: "FooRequest"}, &schema.Unit{}, &schema.Unit{}}},
Response: &schema.Ref{Module: "builtin", Name: "HttpResponse", TypeParameters: []schema.Type{&schema.Ref{Module: "foo", Name: "FooResponse"}, &schema.String{}}},
Metadata: []schema.Metadata{
&schema.MetadataIngress{Type: "http", Method: "GET", Path: []schema.IngressPathComponent{&schema.IngressPathLiteral{Text: "foo"}}},
Expand Down Expand Up @@ -135,11 +136,11 @@ func TestVerbSchemaStringIngress(t *testing.T) {
}

expected := `// HTTP request structure used for HTTP ingress verbs.
export data HttpRequest<Body> {
export data HttpRequest<Body, Path, Query> {
method String
path String
pathParameters {String: String}
query {String: [String]}
pathParameters Path
query Query
headers {String: [String]}
body Body
}
Expand All @@ -161,7 +162,7 @@ data FooResponse {
Message String
}
verb Ingress(builtin.HttpRequest<foo.FooRequest>) builtin.HttpResponse<foo.FooResponse, String>
verb Ingress(builtin.HttpRequest<foo.FooRequest, Unit, Unit>) builtin.HttpResponse<foo.FooResponse, String>
+ingress http GET /foo`

schemaString, err := verbSchemaString(sch, verb)
Expand Down
4 changes: 2 additions & 2 deletions backend/controller/console/testdata/go/console/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ type Response struct {
}

//ftl:ingress http GET /test
func Get(ctx context.Context, req builtin.HttpRequest[External]) (builtin.HttpResponse[Response, string], error) {
func Get(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, External]) (builtin.HttpResponse[Response, string], error) {
return builtin.HttpResponse[Response, string]{
Body: ftl.Some(Response{
Message: fmt.Sprintf("Hello, %s", req.Body.Message),
Message: fmt.Sprintf("Hello, %s", req.Query.Message),
}),
}, nil
}
Expand Down
8 changes: 4 additions & 4 deletions backend/controller/ingress/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,16 @@ func TestIngress(t *testing.T) {
foo String
}
export verb getAlias(HttpRequest<test.AliasRequest>) HttpResponse<Empty, Empty>
export verb getAlias(HttpRequest<Unit, Unit, test.AliasRequest>) HttpResponse<Empty, Empty>
+ingress http GET /getAlias
export verb getPath(HttpRequest<test.PathParameterRequest>) HttpResponse<Empty, Empty>
export verb getPath(HttpRequest<Unit, test.PathParameterRequest, Unit>) HttpResponse<Empty, Empty>
+ingress http GET /getPath/{username}
export verb postMissingTypes(HttpRequest<test.MissingTypes>) HttpResponse<Empty, Empty>
export verb postMissingTypes(HttpRequest<test.MissingTypes, Unit, Unit>) HttpResponse<Empty, Empty>
+ingress http POST /postMissingTypes
export verb postJsonPayload(HttpRequest<test.JsonPayload>) HttpResponse<Empty, Empty>
export verb postJsonPayload(HttpRequest<test.JsonPayload, Unit, Unit>) HttpResponse<Empty, Empty>
+ingress http POST /postJsonPayload
}
`)
Expand Down
6 changes: 3 additions & 3 deletions backend/controller/ingress/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,21 @@ func ValidateCallBody(body []byte, verb *schema.Verb, sch *schema.Schema) error
return nil
}

func getBodyField(ref *schema.Ref, sch *schema.Schema) (*schema.Field, error) {
func getField(name string, ref *schema.Ref, sch *schema.Schema) (*schema.Field, error) {
data, err := sch.ResolveMonomorphised(ref)
if err != nil {
return nil, err
}
var bodyField *schema.Field
for _, field := range data.Fields {
if field.Name == "body" {
if field.Name == name {
bodyField = field
break
}
}

if bodyField == nil {
return nil, fmt.Errorf("verb %s must have a 'body' field", ref.Name)
return nil, fmt.Errorf("verb %s must have a '%s' field", ref.Name, name)
}

return bodyField, nil
Expand Down
18 changes: 9 additions & 9 deletions backend/controller/ingress/ingress_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func TestHttpIngress(t *testing.T) {
in.Run(t,
in.CopyModule("httpingress"),
in.Deploy("httpingress"),
in.HttpCall(http.MethodGet, "/users/123/posts/456", nil, in.JsonData(t, in.Obj{}), func(t testing.TB, resp *in.HTTPResponse) {
in.HttpCall(http.MethodGet, "/users/123/posts/456", nil, nil, func(t testing.TB, resp *in.HTTPResponse) {
assert.Equal(t, 200, resp.Status)
assert.Equal(t, []string{"Header from FTL"}, resp.Headers["Get"])
assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.Headers["Content-Type"])
Expand Down Expand Up @@ -82,23 +82,23 @@ func TestHttpIngress(t *testing.T) {
assert.Equal(t, nil, resp.BodyBytes)
}),

in.HttpCall(http.MethodGet, "/string", nil, []byte("Hello, World!"), func(t testing.TB, resp *in.HTTPResponse) {
in.HttpCall(http.MethodPost, "/string", nil, []byte("Hello, World!"), func(t testing.TB, resp *in.HTTPResponse) {
assert.Equal(t, 200, resp.Status)
assert.Equal(t, []string{"text/plain; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, []byte("Hello, World!"), resp.BodyBytes)
}),

in.HttpCall(http.MethodGet, "/int", nil, []byte("1234"), func(t testing.TB, resp *in.HTTPResponse) {
in.HttpCall(http.MethodPost, "/int", nil, []byte("1234"), func(t testing.TB, resp *in.HTTPResponse) {
assert.Equal(t, 200, resp.Status)
assert.Equal(t, []string{"text/plain; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, []byte("1234"), resp.BodyBytes)
}),
in.HttpCall(http.MethodGet, "/float", nil, []byte("1234.56789"), func(t testing.TB, resp *in.HTTPResponse) {
in.HttpCall(http.MethodPost, "/float", nil, []byte("1234.56789"), func(t testing.TB, resp *in.HTTPResponse) {
assert.Equal(t, 200, resp.Status)
assert.Equal(t, []string{"text/plain; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, []byte("1234.56789"), resp.BodyBytes)
}),
in.HttpCall(http.MethodGet, "/bool", nil, []byte("true"), func(t testing.TB, resp *in.HTTPResponse) {
in.HttpCall(http.MethodPost, "/bool", nil, []byte("true"), func(t testing.TB, resp *in.HTTPResponse) {
assert.Equal(t, 200, resp.Status)
assert.Equal(t, []string{"text/plain; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, []byte("true"), resp.BodyBytes)
Expand All @@ -108,7 +108,7 @@ func TestHttpIngress(t *testing.T) {
assert.Equal(t, []string{"text/plain; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, []byte("Error from FTL"), resp.BodyBytes)
}),
in.HttpCall(http.MethodGet, "/array/string", nil, in.JsonData(t, []string{"hello", "world"}), func(t testing.TB, resp *in.HTTPResponse) {
in.HttpCall(http.MethodPost, "/array/string", nil, in.JsonData(t, []string{"hello", "world"}), func(t testing.TB, resp *in.HTTPResponse) {
assert.Equal(t, 200, resp.Status)
assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, in.JsonData(t, []string{"hello", "world"}), resp.BodyBytes)
Expand All @@ -118,7 +118,7 @@ func TestHttpIngress(t *testing.T) {
assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, in.JsonData(t, []in.Obj{{"item": "a"}, {"item": "b"}}), resp.BodyBytes)
}),
in.HttpCall(http.MethodGet, "/typeenum", nil, in.JsonData(t, in.Obj{"name": "A", "value": "hello"}), func(t testing.TB, resp *in.HTTPResponse) {
in.HttpCall(http.MethodPost, "/typeenum", nil, in.JsonData(t, in.Obj{"name": "A", "value": "hello"}), func(t testing.TB, resp *in.HTTPResponse) {
assert.Equal(t, 200, resp.Status)
assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, in.JsonData(t, in.Obj{"name": "A", "value": "hello"}), resp.BodyBytes)
Expand All @@ -134,12 +134,12 @@ func TestHttpIngress(t *testing.T) {
assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Methods"])
assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Headers"])
}),
in.HttpCall(http.MethodGet, "/external", nil, in.JsonData(t, in.Obj{"message": "hello"}), func(t testing.TB, resp *in.HTTPResponse) {
in.HttpCall(http.MethodPost, "/external", nil, in.JsonData(t, in.Obj{"message": "hello"}), func(t testing.TB, resp *in.HTTPResponse) {
assert.Equal(t, 200, resp.Status)
assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, in.JsonData(t, in.Obj{"message": "hello"}), resp.BodyBytes)
}),
in.HttpCall(http.MethodGet, "/external2", nil, in.JsonData(t, in.Obj{"message": "hello"}), func(t testing.TB, resp *in.HTTPResponse) {
in.HttpCall(http.MethodPost, "/external2", nil, in.JsonData(t, in.Obj{"message": "hello"}), func(t testing.TB, resp *in.HTTPResponse) {
assert.Equal(t, 200, resp.Status)
assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.Headers["Content-Type"])
assert.Equal(t, in.JsonData(t, in.Obj{"Message": "hello"}), resp.BodyBytes)
Expand Down
128 changes: 89 additions & 39 deletions backend/controller/ingress/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,16 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche
var requestMap map[string]any

if metadata, ok := verb.GetMetadataIngress().Get(); ok && metadata.Type == "http" {
pathParameters := map[string]any{}
pathParametersMap := map[string]string{}
matchSegments(route.Path, r.URL.Path, func(segment, value string) {
pathParameters[segment] = value
pathParametersMap[segment] = value
})
pathParameters, err := manglePathParameters(pathParametersMap, request, sch)
if err != nil {
return nil, err
}

httpRequestBody, err := extractHTTPRequestBody(route, r, request, sch)
httpRequestBody, err := extractHTTPRequestBody(r, request, sch)
if err != nil {
return nil, err
}
Expand All @@ -56,6 +60,10 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche
queryMap[key] = valuesAny
}

finalQueryParams, err := mangleQueryParameters(queryMap, r.URL.Query(), request, sch)
if err != nil {
return nil, err
}
headerMap := make(map[string]any)
for key, values := range r.Header {
valuesAny := make([]any, len(values))
Expand All @@ -69,15 +77,11 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche
requestMap["method"] = r.Method
requestMap["path"] = r.URL.Path
requestMap["pathParameters"] = pathParameters
requestMap["query"] = queryMap
requestMap["query"] = finalQueryParams
requestMap["headers"] = headerMap
requestMap["body"] = httpRequestBody
} else {
var err error
requestMap, err = buildRequestMap(route, r, request, sch)
if err != nil {
return nil, err
}
return nil, fmt.Errorf("no HTTP ingress metadata for verb %s", verb.Name)
}

requestMap, err = schema.TransformFromAliasedFields(request, sch, requestMap)
Expand All @@ -102,15 +106,15 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche
return body, nil
}

func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, ref *schema.Ref, sch *schema.Schema) (any, error) {
bodyField, err := getBodyField(ref, sch)
func extractHTTPRequestBody(r *http.Request, ref *schema.Ref, sch *schema.Schema) (any, error) {
bodyField, err := getField("body", ref, sch)
if err != nil {
return nil, err
}

if ref, ok := bodyField.Type.(*schema.Ref); ok {
if err := sch.ResolveToType(ref, &schema.Data{}); err == nil {
return buildRequestMap(route, r, ref, sch)
return buildRequestMap(r)
}
}

Expand All @@ -122,6 +126,76 @@ func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, ref *schem
return valueForData(bodyField.Type, bodyData)
}

// Takes the map of path parameters and transforms them into the appropriate type
func manglePathParameters(params map[string]string, ref *schema.Ref, sch *schema.Schema) (any, error) {

paramsField, err := getField("pathParameters", ref, sch)
if err != nil {
return nil, err
}

switch paramsField.Type.(type) {
case *schema.Ref, *schema.Map:
ret := map[string]any{}
for k, v := range params {
ret[k] = v
}
return ret, nil
default:
}
// This is a scalar, there should only be a single param
// This is validated by the schema, we don't need extra validation here
for _, val := range params {

switch paramsField.Type.(type) {
case *schema.String:
return val, nil
case *schema.Int:
parsed, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse int from path parameter: %w", err)
}
return parsed, nil
case *schema.Float:
float, err := strconv.ParseFloat(val, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse float from path parameter: %w", err)
}
return float, nil
case *schema.Bool:
// TODO: is anything else considered truthy?
return val == "true", nil
default:
return nil, fmt.Errorf("unsupported path parameter type %T", paramsField.Type)
}
}
// Empty map
return map[string]any{}, nil
}

// Takes the map of path parameters and transforms them into the appropriate type
func mangleQueryParameters(params map[string]any, underlying map[string][]string, ref *schema.Ref, sch *schema.Schema) (any, error) {

paramsField, err := getField("query", ref, sch)
if err != nil {
return nil, err
}

if m, ok := paramsField.Type.(*schema.Map); ok {
if _, ok := m.Value.(*schema.Array); ok {
return params, nil
}
}
// We need to turn them into straight strings
newParams := map[string]any{}
for k, v := range underlying {
if len(v) > 0 {
newParams[k] = v[0]
}
}
return newParams, nil
}

func valueForData(typ schema.Type, data []byte) (any, error) {
switch typ.(type) {
case *schema.Ref:
Expand Down Expand Up @@ -203,12 +277,7 @@ func readRequestBody(r *http.Request) ([]byte, error) {
return bodyData, nil
}

func buildRequestMap(route *dal.IngressRoute, r *http.Request, ref *schema.Ref, sch *schema.Schema) (map[string]any, error) {
requestMap := map[string]any{}
matchSegments(route.Path, r.URL.Path, func(segment, value string) {
requestMap[segment] = value
})

func buildRequestMap(r *http.Request) (map[string]any, error) {
switch r.Method {
case http.MethodPost, http.MethodPut:
var bodyMap map[string]any
Expand All @@ -217,29 +286,10 @@ func buildRequestMap(route *dal.IngressRoute, r *http.Request, ref *schema.Ref,
return nil, fmt.Errorf("HTTP request body is not valid JSON: %w", err)
}

// Merge bodyMap into params
for k, v := range bodyMap {
requestMap[k] = v
}
return bodyMap, nil
default:
symbol, err := sch.ResolveRequestResponseType(ref)
if err != nil {
return nil, err
}

if data, ok := symbol.(*schema.Data); ok {
queryMap, err := parseQueryParams(r.URL.Query(), data)
if err != nil {
return nil, fmt.Errorf("HTTP query params are not valid: %w", err)
}

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

return requestMap, nil
}

func parseQueryParams(values url.Values, data *schema.Data) (map[string]any, error) {
Expand Down
Loading

0 comments on commit 668d0b0

Please sign in to comment.