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..d7b3b3ffc8 100644 --- a/backend/controller/ingress/request.go +++ b/backend/controller/ingress/request.go @@ -80,58 +80,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 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 + + case *schema.String: + return string(data), nil - intVal, err := strconv.ParseInt(string(bodyData), 10, 64) + 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 +160,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..4dcf29f23b 100644 --- a/backend/controller/ingress/response.go +++ b/backend/controller/ingress/response.go @@ -63,65 +63,107 @@ func ResponseForVerb(sch *schema.Schema, verb *schema.Verb, response HTTPRespons } } - switch bodyType := fieldType.(type) { + 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: var responseMap map[string]any - err := json.Unmarshal(body, &responseMap) + err := json.Unmarshal(data, &responseMap) 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) + aliasedResponseMap, err := transformToAliasedFields(t, sch, responseMap) if err != nil { - return nil, nil, err + return nil, err } outBody, err := json.Marshal(aliasedResponseMap) - return outBody, headers, err + return outBody, err + + case *schema.Array: + var responseArray []json.RawMessage + err := json.Unmarshal(data, &responseArray) + if err != nil { + return nil, fmt.Errorf("HTTP response body is not valid JSON array: %w", err) + } + + transformedArrayData := make([]any, len(responseArray)) + for i, rawElement := range responseArray { + var transformedElement any + err := json.Unmarshal(rawElement, &transformedElement) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal array element: %w", err) + } + + // Transform the array element to its aliased form. + if dataRef, ok := t.Element.(*schema.DataRef); ok { + if elementMap, ok := transformedElement.(map[string]any); ok { + aliasedElement, err := transformToAliasedFields(dataRef, sch, elementMap) + if err != nil { + return nil, err + } + transformedElement = aliasedElement + } + } + + transformedArrayData[i] = transformedElement + } + + // Marshal the transformed array back to JSON. + outBody, err := json.Marshal(transformedArrayData) + if err != nil { + return nil, fmt.Errorf("failed to marshal transformed array data: %w", err) + } + + return outBody, nil 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/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 1276dbd88a..a0ae4d0bb9 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 sdk.Option[int] }{sdk.Some(42)}, expected: `{"option":42}`}, {name: "OptionPtr", input: struct{ Option *sdk.Option[int] }{&somePtr}, expected: `{"option":42}`}, diff --git a/go-runtime/server/server.go b/go-runtime/server/server.go index 1b52643dd1..4efa870aca 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..e2059988ec 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -145,10 +145,20 @@ 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 63c9a6ef13..6f735bcfd0 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 + ) +}