From 2c5f3feb6e54de70f230c28d458ce3ec1490f410 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 13 Feb 2024 09:27:10 -0700 Subject: [PATCH] feat: add array support to http ingress (#915) Co-authored-by: Alec Thomas --- backend/controller/ingress/alias.go | 102 ++++++++------ backend/controller/ingress/alias_test.go | 129 ++++++++++++++++++ backend/controller/ingress/ingress.go | 1 + backend/controller/ingress/ingress_test.go | 22 +++ backend/controller/ingress/request.go | 102 +++++++++----- backend/controller/ingress/response.go | 66 +++++---- backend/schema/data.go | 4 +- backend/schema/data_test.go | 37 +++-- go-runtime/encoding/encoding.go | 2 +- go-runtime/encoding/encoding_test.go | 1 + go-runtime/server/server.go | 1 + integration/integration_test.go | 14 +- integration/testdata/go/httpingress/echo.go | 20 +++ .../testdata/kotlin/httpingress/Echo.kt | 26 +++- 14 files changed, 415 insertions(+), 112 deletions(-) create mode 100644 backend/controller/ingress/alias_test.go diff --git a/backend/controller/ingress/alias.go b/backend/controller/ingress/alias.go index b10b3aafd0..8f678d9aa1 100644 --- a/backend/controller/ingress/alias.go +++ b/backend/controller/ingress/alias.go @@ -1,57 +1,83 @@ package ingress import ( + "fmt" + "github.com/TBD54566975/ftl/backend/schema" ) -func transformFromAliasedFields(dataRef *schema.DataRef, sch *schema.Schema, request map[string]any) (map[string]any, error) { - data, err := sch.ResolveDataRefMonomorphised(dataRef) - if err != nil { - return nil, err - } +func transformAliasedFields(sch *schema.Schema, t schema.Type, obj any, aliaser func(obj map[string]any, field *schema.Field) string) error { + switch t := t.(type) { + case *schema.DataRef: + data, err := sch.ResolveDataRefMonomorphised(t) + if err != nil { + return fmt.Errorf("failed to resolve data type: %w", err) + } + m, ok := obj.(map[string]any) + if !ok { + return fmt.Errorf("expected map, got %T", obj) + } + for _, field := range data.Fields { + name := aliaser(m, field) + if err := transformAliasedFields(sch, field.Type, m[name], aliaser); err != nil { + return err + } + } - for _, field := range data.Fields { - if _, ok := request[field.Name]; !ok && field.Alias != "" && request[field.Alias] != nil { - request[field.Name] = request[field.Alias] - delete(request, field.Alias) + case *schema.Array: + a, ok := obj.([]any) + if !ok { + return fmt.Errorf("expected array, got %T", obj) + } + for _, elem := range a { + if err := transformAliasedFields(sch, t.Element, elem, aliaser); err != nil { + return err + } } - if d, ok := field.Type.(*schema.DataRef); ok { - if _, found := request[field.Name]; found { - rMap, err := transformFromAliasedFields(d, sch, request[field.Name].(map[string]any)) - if err != nil { - return nil, err - } - request[field.Name] = rMap + case *schema.Map: + m, ok := obj.(map[string]any) + if !ok { + return fmt.Errorf("expected map, got %T", obj) + } + for key, value := range m { + if err := transformAliasedFields(sch, t.Key, key, aliaser); err != nil { + return err + } + if err := transformAliasedFields(sch, t.Value, value, aliaser); err != nil { + return err } } - } - return request, nil -} + case *schema.Optional: + if obj == nil { + return nil + } + return transformAliasedFields(sch, t.Type, obj, aliaser) -func transformToAliasedFields(dataRef *schema.DataRef, sch *schema.Schema, request map[string]any) (map[string]any, error) { - data, err := sch.ResolveDataRefMonomorphised(dataRef) - if err != nil { - return nil, err + case *schema.Any, *schema.Bool, *schema.Bytes, *schema.Float, *schema.Int, + *schema.String, *schema.Time, *schema.Unit: } + return nil +} - for _, field := range data.Fields { - if field.Alias != "" && field.Name != field.Alias { - request[field.Alias] = request[field.Name] - delete(request, field.Name) +func transformFromAliasedFields(dataRef *schema.DataRef, sch *schema.Schema, request map[string]any) (map[string]any, error) { + return request, transformAliasedFields(sch, dataRef, request, func(obj map[string]any, field *schema.Field) string { + if _, ok := obj[field.Name]; !ok && field.Alias != "" && obj[field.Alias] != nil { + obj[field.Name] = obj[field.Alias] + delete(obj, field.Alias) } + return field.Name + }) +} - if d, ok := field.Type.(*schema.DataRef); ok { - if _, found := request[field.Name]; found { - rMap, err := transformToAliasedFields(d, sch, request[field.Name].(map[string]any)) - if err != nil { - return nil, err - } - request[field.Name] = rMap - } +func transformToAliasedFields(dataRef *schema.DataRef, sch *schema.Schema, request map[string]any) (map[string]any, error) { + return request, transformAliasedFields(sch, dataRef, request, func(obj map[string]any, field *schema.Field) string { + if field.Alias != "" && field.Name != field.Alias { + obj[field.Alias] = obj[field.Name] + delete(obj, field.Name) + return field.Alias } - } - - return request, nil + return field.Name + }) } diff --git a/backend/controller/ingress/alias_test.go b/backend/controller/ingress/alias_test.go new file mode 100644 index 0000000000..78186f7c4b --- /dev/null +++ b/backend/controller/ingress/alias_test.go @@ -0,0 +1,129 @@ +package ingress + +import ( + "testing" + + "github.com/alecthomas/assert/v2" + + "github.com/TBD54566975/ftl/backend/schema" +) + +func TestTransformFromAliasedFields(t *testing.T) { + schemaText := ` + module test { + data Inner { + waz String alias foo + } + + data Test { + scalar String alias bar + inner Inner + array [Inner] + map {String: Inner} + optional Inner + } + } + ` + sch, err := schema.ParseString("test", schemaText) + assert.NoError(t, err) + actual, err := transformFromAliasedFields(&schema.DataRef{Module: "test", Name: "Test"}, sch, map[string]any{ + "bar": "value", + "inner": map[string]any{ + "foo": "value", + }, + "array": []any{ + map[string]any{ + "foo": "value", + }, + }, + "map": map[string]any{ + "key": map[string]any{ + "foo": "value", + }, + }, + "optional": map[string]any{ + "foo": "value", + }, + }) + expected := map[string]any{ + "scalar": "value", + "inner": map[string]any{ + "waz": "value", + }, + "array": []any{ + map[string]any{ + "waz": "value", + }, + }, + "map": map[string]any{ + "key": map[string]any{ + "waz": "value", + }, + }, + "optional": map[string]any{ + "waz": "value", + }, + } + assert.NoError(t, err) + assert.Equal(t, expected, actual) +} + +func TestTransformToAliasedFields(t *testing.T) { + schemaText := ` + module test { + data Inner { + waz String alias foo + } + + data Test { + scalar String alias bar + inner Inner + array [Inner] + map {String: Inner} + optional Inner + } + } + ` + sch, err := schema.ParseString("test", schemaText) + assert.NoError(t, err) + actual, err := transformToAliasedFields(&schema.DataRef{Module: "test", Name: "Test"}, sch, map[string]any{ + "scalar": "value", + "inner": map[string]any{ + "waz": "value", + }, + "array": []any{ + map[string]any{ + "waz": "value", + }, + }, + "map": map[string]any{ + "key": map[string]any{ + "waz": "value", + }, + }, + "optional": map[string]any{ + "waz": "value", + }, + }) + expected := map[string]any{ + "bar": "value", + "inner": map[string]any{ + "foo": "value", + }, + "array": []any{ + map[string]any{ + "foo": "value", + }, + }, + "map": map[string]any{ + "key": map[string]any{ + "foo": "value", + }, + }, + "optional": map[string]any{ + "foo": "value", + }, + } + assert.NoError(t, err) + assert.Equal(t, expected, actual) +} diff --git a/backend/controller/ingress/ingress.go b/backend/controller/ingress/ingress.go index 3267e5c433..6af57fe3bb 100644 --- a/backend/controller/ingress/ingress.go +++ b/backend/controller/ingress/ingress.go @@ -100,6 +100,7 @@ func validateValue(fieldType schema.Type, path path, value any, sch *schema.Sche typeMatches = true case *schema.Unit: + // TODO: Use type assertions consistently in this function rather than reflection. rv := reflect.ValueOf(value) if rv.Kind() != reflect.Map || rv.Len() != 0 { return fmt.Errorf("%s must be an empty map", path) diff --git a/backend/controller/ingress/ingress_test.go b/backend/controller/ingress/ingress_test.go index b850261654..c3586813b7 100644 --- a/backend/controller/ingress/ingress_test.go +++ b/backend/controller/ingress/ingress_test.go @@ -221,3 +221,25 @@ func TestResponseBodyForVerb(t *testing.T) { }) } } + +func TestValueForData(t *testing.T) { + tests := []struct { + typ schema.Type + data []byte + result any + }{ + {&schema.String{}, []byte("test"), "test"}, + {&schema.Int{}, []byte("1234"), 1234}, + {&schema.Float{}, []byte("12.34"), 12.34}, + {&schema.Bool{}, []byte("true"), true}, + {&schema.Array{Element: &schema.String{}}, []byte(`["test1", "test2"]`), []any{"test1", "test2"}}, + {&schema.Map{Key: &schema.String{}, Value: &schema.String{}}, []byte(`{"key1": "value1", "key2": "value2"}`), obj{"key1": "value1", "key2": "value2"}}, + {&schema.DataRef{Module: "test", Name: "Test"}, []byte(`{"intValue": 10.0}`), obj{"intValue": 10.0}}, + } + + for _, test := range tests { + result, err := valueForData(test.typ, test.data) + assert.NoError(t, err) + assert.Equal(t, test.result, result) + } +} diff --git a/backend/controller/ingress/request.go b/backend/controller/ingress/request.go index f0833c680b..458e73b784 100644 --- a/backend/controller/ingress/request.go +++ b/backend/controller/ingress/request.go @@ -31,7 +31,7 @@ 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]string{} + pathParameters := map[string]any{} matchSegments(route.Path, r.URL.Path, func(segment, value string) { pathParameters[segment] = value }) @@ -41,12 +41,33 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche return nil, err } + // Since the query and header parameters are a `map[string][]string` + // we need to convert them before they go through the `transformFromAliasedFields` call + // otherwise they will fail the type check. + queryMap := make(map[string]any) + for key, values := range r.URL.Query() { + valuesAny := make([]any, len(values)) + for i, v := range values { + valuesAny[i] = v + } + queryMap[key] = valuesAny + } + + headerMap := make(map[string]any) + for key, values := range r.Header { + valuesAny := make([]any, len(values)) + for i, v := range values { + valuesAny[i] = v + } + headerMap[key] = valuesAny + } + requestMap = map[string]any{} requestMap["method"] = r.Method requestMap["path"] = r.URL.Path requestMap["pathParameters"] = pathParameters - requestMap["query"] = r.URL.Query() - requestMap["headers"] = r.Header + requestMap["query"] = queryMap + requestMap["headers"] = headerMap requestMap["body"] = httpRequestBody } else { var err error @@ -80,58 +101,77 @@ func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, dataRef *s return nil, err } - switch bodyType := bodyField.Type.(type) { + if dataRef, ok := bodyField.Type.(*schema.DataRef); ok { + return buildRequestMap(route, r, dataRef, sch) + } + + bodyData, err := readRequestBody(r) + if err != nil { + return nil, err + } + + return valueForData(bodyField.Type, bodyData) +} + +func valueForData(typ schema.Type, data []byte) (any, error) { + switch typ.(type) { case *schema.DataRef: - bodyMap, err := buildRequestMap(route, r, bodyType, sch) + var bodyMap map[string]any + err := json.Unmarshal(data, &bodyMap) if err != nil { - return nil, err + return nil, fmt.Errorf("HTTP request body is not valid JSON: %w", err) } return bodyMap, nil - case *schema.Bytes: - bodyData, err := readRequestBody(r) + case *schema.Array: + var rawData []json.RawMessage + err := json.Unmarshal(data, &rawData) if err != nil { - return nil, err + return nil, fmt.Errorf("HTTP request body is not a valid JSON array: %w", err) } - return bodyData, nil - case *schema.String: - bodyData, err := readRequestBody(r) - if err != nil { - return nil, err + arrayData := make([]any, len(rawData)) + for i, rawElement := range rawData { + var parsedElement any + err := json.Unmarshal(rawElement, &parsedElement) + if err != nil { + return nil, fmt.Errorf("failed to parse array element: %w", err) + } + arrayData[i] = parsedElement } - return string(bodyData), nil - case *schema.Int: - bodyData, err := readRequestBody(r) + return arrayData, nil + + case *schema.Map: + var bodyMap map[string]any + err := json.Unmarshal(data, &bodyMap) if err != nil { - return nil, err + return nil, fmt.Errorf("HTTP request body is not valid JSON: %w", err) } + return bodyMap, nil + + case *schema.Bytes: + return data, nil - intVal, err := strconv.ParseInt(string(bodyData), 10, 64) + case *schema.String: + return string(data), nil + + case *schema.Int: + intVal, err := strconv.ParseInt(string(data), 10, 64) if err != nil { return nil, fmt.Errorf("failed to parse integer from request body: %w", err) } return intVal, nil case *schema.Float: - bodyData, err := readRequestBody(r) - if err != nil { - return nil, err - } - - floatVal, err := strconv.ParseFloat(string(bodyData), 64) + floatVal, err := strconv.ParseFloat(string(data), 64) if err != nil { return nil, fmt.Errorf("failed to parse float from request body: %w", err) } return floatVal, nil case *schema.Bool: - bodyData, err := readRequestBody(r) - if err != nil { - return nil, err - } - boolVal, err := strconv.ParseBool(string(bodyData)) + boolVal, err := strconv.ParseBool(string(data)) if err != nil { return nil, fmt.Errorf("failed to parse boolean from request body: %w", err) } @@ -141,7 +181,7 @@ func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, dataRef *s return map[string]any{}, nil default: - return nil, fmt.Errorf("unsupported HttpRequest.Body type %T", bodyField.Type) + return nil, fmt.Errorf("unsupported data type %T", typ) } } diff --git a/backend/controller/ingress/response.go b/backend/controller/ingress/response.go index 0d1e7c41ef..456bd69e1b 100644 --- a/backend/controller/ingress/response.go +++ b/backend/controller/ingress/response.go @@ -63,65 +63,77 @@ func ResponseForVerb(sch *schema.Schema, verb *schema.Verb, response HTTPRespons } } - switch bodyType := fieldType.(type) { - case *schema.DataRef: - var responseMap map[string]any - err := json.Unmarshal(body, &responseMap) + outBody, err := bodyForType(fieldType, sch, body) + return outBody, headers, err +} + +func bodyForType(typ schema.Type, sch *schema.Schema, data []byte) ([]byte, error) { + switch t := typ.(type) { + case *schema.DataRef, *schema.Array, *schema.Map: + var response any + err := json.Unmarshal(data, &response) if err != nil { - return nil, nil, fmt.Errorf("HTTP response body is not valid JSON: %w", err) + return nil, fmt.Errorf("HTTP response body is not valid JSON: %w", err) } - aliasedResponseMap, err := transformToAliasedFields(bodyType, sch, responseMap) + err = transformAliasedFields(sch, t, response, func(obj map[string]any, field *schema.Field) string { + if field.Alias != "" && field.Name != field.Alias { + obj[field.Alias] = obj[field.Name] + delete(obj, field.Name) + return field.Alias + } + return field.Name + }) if err != nil { - return nil, nil, err + return nil, err } - outBody, err := json.Marshal(aliasedResponseMap) - return outBody, headers, err + outBody, err := json.Marshal(response) + return outBody, err case *schema.Bytes: var base64String string - if err := json.Unmarshal(body, &base64String); err != nil { - return nil, nil, fmt.Errorf("HTTP response body is not valid base64: %w", err) + if err := json.Unmarshal(data, &base64String); err != nil { + return nil, fmt.Errorf("HTTP response body is not valid base64: %w", err) } decodedBody, err := base64.StdEncoding.DecodeString(base64String) if err != nil { - return nil, nil, fmt.Errorf("failed to decode base64 response body: %w", err) + return nil, fmt.Errorf("failed to decode base64 response body: %w", err) } - return decodedBody, headers, nil + return decodedBody, nil case *schema.String: var responseString string - if err := json.Unmarshal(body, &responseString); err != nil { - return nil, nil, fmt.Errorf("HTTP response body is not a valid string: %w", err) + if err := json.Unmarshal(data, &responseString); err != nil { + return nil, fmt.Errorf("HTTP response body is not a valid string: %w", err) } - return []byte(responseString), headers, nil + return []byte(responseString), nil case *schema.Int: var responseInt int - if err := json.Unmarshal(body, &responseInt); err != nil { - return nil, nil, fmt.Errorf("HTTP response body is not a valid int: %w", err) + if err := json.Unmarshal(data, &responseInt); err != nil { + return nil, fmt.Errorf("HTTP response body is not a valid int: %w", err) } - return []byte(strconv.Itoa(responseInt)), headers, nil + return []byte(strconv.Itoa(responseInt)), nil case *schema.Float: var responseFloat float64 - if err := json.Unmarshal(body, &responseFloat); err != nil { - return nil, nil, fmt.Errorf("HTTP response body is not a valid float: %w", err) + if err := json.Unmarshal(data, &responseFloat); err != nil { + return nil, fmt.Errorf("HTTP response body is not a valid float: %w", err) } - return []byte(strconv.FormatFloat(responseFloat, 'f', -1, 64)), headers, nil + return []byte(strconv.FormatFloat(responseFloat, 'f', -1, 64)), nil case *schema.Bool: var responseBool bool - if err := json.Unmarshal(body, &responseBool); err != nil { - return nil, nil, fmt.Errorf("HTTP response body is not a valid bool: %w", err) + if err := json.Unmarshal(data, &responseBool); err != nil { + return nil, fmt.Errorf("HTTP response body is not a valid bool: %w", err) } - return []byte(strconv.FormatBool(responseBool)), headers, nil + return []byte(strconv.FormatBool(responseBool)), nil case *schema.Unit: - return []byte{}, headers, nil + return []byte{}, nil default: - return body, headers, nil + return data, nil } } diff --git a/backend/schema/data.go b/backend/schema/data.go index 3aacb5ea41..26eabd67b9 100644 --- a/backend/schema/data.go +++ b/backend/schema/data.go @@ -175,8 +175,8 @@ func DataToSchema(s *schemapb.Data) *Data { // MonoType returns the monomorphised type of this data type if applicable, or returns the original type. func maybeMonomorphiseType(t Type, typeParameters map[string]Type) (Type, error) { - if t, ok := t.(*DataRef); ok { - if tp, ok := typeParameters[t.Name]; ok && t.Module == "" { + if t, ok := t.(*DataRef); ok && t.Module == "" { + if tp, ok := typeParameters[t.Name]; ok { return tp, nil } return nil, fmt.Errorf("%s: unknown type parameter %q", t.Position(), t.Name) diff --git a/backend/schema/data_test.go b/backend/schema/data_test.go index 5d13e98152..b9b9977b40 100644 --- a/backend/schema/data_test.go +++ b/backend/schema/data_test.go @@ -14,13 +14,34 @@ func TestMonomorphisation(t *testing.T) { {Name: "a", Type: &DataRef{Name: "T"}}, }, } - actual, err := data.Monomorphise(&DataRef{TypeParameters: []Type{&String{}}}) - assert.NoError(t, err) - expected := &Data{ - Comments: []string{}, - Name: "Data", - Fields: []*Field{{Comments: []string{}, Name: "a", Type: &String{}}}, - Metadata: []Metadata{}, + + tests := []struct { + typ Type + }{ + {typ: &String{}}, + {typ: &Int{}}, + {typ: &Float{}}, + {typ: &Bool{}}, + {typ: &Array{Element: &String{}}}, + {typ: &Array{Element: &DataRef{Module: "builtin", Name: "Test"}}}, + {typ: &DataRef{Module: "builtin", Name: "Test"}}, + {typ: &DataRef{Module: "builtin", Name: "Test", TypeParameters: []Type{&String{}}}}, + {typ: &Map{Key: &String{}, Value: &Int{}}}, + {typ: &Map{Key: &DataRef{Module: "builtin", Name: "Test"}, Value: &DataRef{Module: "builtin", Name: "Test"}}}, + {typ: &Optional{Type: &String{}}}, + {typ: &Optional{Type: &DataRef{Module: "builtin", Name: "Test"}}}, + {typ: &Any{}}, + } + + for _, test := range tests { + actual, err := data.Monomorphise(&DataRef{TypeParameters: []Type{test.typ}}) + assert.NoError(t, err) + expected := &Data{ + Comments: []string{}, + Name: "Data", + Fields: []*Field{{Comments: []string{}, Name: "a", Type: test.typ}}, + Metadata: []Metadata{}, + } + assert.Equal(t, expected, actual, assert.OmitEmpty()) } - assert.Equal(t, expected, actual, assert.OmitEmpty()) } diff --git a/go-runtime/encoding/encoding.go b/go-runtime/encoding/encoding.go index 917e20ef81..43852b0860 100644 --- a/go-runtime/encoding/encoding.go +++ b/go-runtime/encoding/encoding.go @@ -91,7 +91,7 @@ func encodeValue(v reflect.Value, w *bytes.Buffer) error { return encodeBool(v, w) default: - panic(fmt.Sprintf("unsupported typefoo: %s", v.Type())) + panic(fmt.Sprintf("unsupported type: %s", v.Type())) } } diff --git a/go-runtime/encoding/encoding_test.go b/go-runtime/encoding/encoding_test.go index ca29f4b48d..f713307117 100644 --- a/go-runtime/encoding/encoding_test.go +++ b/go-runtime/encoding/encoding_test.go @@ -27,6 +27,7 @@ func TestMarshal(t *testing.T) { {name: "Bool", input: struct{ Bool bool }{true}, expected: `{"bool":true}`}, {name: "Nil", input: struct{ Nil *int }{nil}, expected: `{"nil":null}`}, {name: "Slice", input: struct{ Slice []int }{[]int{1, 2, 3}}, expected: `{"slice":[1,2,3]}`}, + {name: "SliceOfStrings", input: struct{ Slice []string }{[]string{"hello", "world"}}, expected: `{"slice":["hello","world"]}`}, {name: "Map", input: struct{ Map map[string]int }{map[string]int{"foo": 42}}, expected: `{"map":{"foo":42}}`}, {name: "Option", input: struct{ Option ftl.Option[int] }{ftl.Some(42)}, expected: `{"option":42}`}, {name: "OptionPtr", input: struct{ Option *ftl.Option[int] }{&somePtr}, expected: `{"option":42}`}, diff --git a/go-runtime/server/server.go b/go-runtime/server/server.go index 17611e08c1..6907be74cd 100644 --- a/go-runtime/server/server.go +++ b/go-runtime/server/server.go @@ -71,6 +71,7 @@ func Handle[Req, Resp any](verb func(ctx context.Context, req Req) (Resp, error) if err != nil { return nil, err } + return respdata, nil }, } diff --git a/integration/integration_test.go b/integration/integration_test.go index 85436cbbc8..24a59bb36f 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -145,9 +145,19 @@ func TestHttpIngress(t *testing.T) { assert.Equal(t, []byte("true"), resp.bodyBytes) }), httpCall(rd, http.MethodGet, "/error", nil, func(t testing.TB, resp *httpResponse) { + assert.Equal(t, 500, resp.status) assert.Equal(t, []string{"text/plain; charset=utf-8"}, resp.headers["Content-Type"]) assert.Equal(t, []byte("Error from FTL"), resp.bodyBytes) - assert.Equal(t, 500, resp.status) + }), + httpCall(rd, http.MethodGet, "/array/string", jsonData(t, []string{"hello", "world"}), func(t testing.TB, resp *httpResponse) { + assert.Equal(t, 200, resp.status) + assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.headers["Content-Type"]) + assert.Equal(t, jsonData(t, []string{"hello", "world"}), resp.bodyBytes) + }), + httpCall(rd, http.MethodPost, "/array/data", jsonData(t, []obj{{"item": "a"}, {"item": "b"}}), func(t testing.TB, resp *httpResponse) { + assert.Equal(t, 200, resp.status) + assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.headers["Content-Type"]) + assert.Equal(t, jsonData(t, []obj{{"item": "a"}, {"item": "b"}}), resp.bodyBytes) }), }}, } @@ -407,7 +417,7 @@ type httpResponse struct { bodyBytes []byte } -func jsonData(t testing.TB, body obj) []byte { +func jsonData(t testing.TB, body interface{}) []byte { b, err := json.Marshal(body) assert.NoError(t, err) return b diff --git a/integration/testdata/go/httpingress/echo.go b/integration/testdata/go/httpingress/echo.go index dc1092837f..0bacfb120d 100644 --- a/integration/testdata/go/httpingress/echo.go +++ b/integration/testdata/go/httpingress/echo.go @@ -144,3 +144,23 @@ func Error(ctx context.Context, req builtin.HttpRequest[ftl.Unit]) (builtin.Http Error: ftl.Some("Error from FTL"), }, nil } + +//ftl:verb +//ftl:ingress http GET /array/string +func ArrayString(ctx context.Context, req builtin.HttpRequest[[]string]) (builtin.HttpResponse[[]string, string], error) { + return builtin.HttpResponse[[]string, string]{ + Body: ftl.Some(req.Body), + }, nil +} + +type ArrayType struct { + Item string `alias:"item"` +} + +//ftl:verb +//ftl:ingress http POST /array/data +func ArrayData(ctx context.Context, req builtin.HttpRequest[[]ArrayType]) (builtin.HttpResponse[[]ArrayType, string], error) { + return builtin.HttpResponse[[]ArrayType, string]{ + Body: ftl.Some(req.Body), + }, nil +} diff --git a/integration/testdata/kotlin/httpingress/Echo.kt b/integration/testdata/kotlin/httpingress/Echo.kt index eec54a96cc..265422a430 100644 --- a/integration/testdata/kotlin/httpingress/Echo.kt +++ b/integration/testdata/kotlin/httpingress/Echo.kt @@ -43,11 +43,12 @@ data class DeleteRequest( @Alias("userId") val userID: String, ) +data class ArrayType( + @Alias("item") val item: String, +) @Verb -@HttpIngress( - Method.GET, "/users/{userID}/posts/{postID}" -) +@HttpIngress(Method.GET, "/users/{userID}/posts/{postID}") fun `get`(context: Context, req: HttpRequest): HttpResponse { return HttpResponse( status = 200, @@ -169,3 +170,22 @@ fun error(context: Context, req: HttpRequest): HttpResponse>): HttpResponse, String> { + return HttpResponse( + status = 200, + headers = mapOf("ArrayString" to arrayListOf("Header from FTL")), + body = req.body + ) +} + +@Verb +@HttpIngress(Method.POST, "/array/data") +fun arrayData(context: Context, req: HttpRequest>): HttpResponse, String> { + return HttpResponse( + status = 200, + headers = mapOf("ArrayData" to arrayListOf("Header from FTL")), + body = req.body + ) +}