diff --git a/backend/controller/ingress/ingress_integration_test.go b/backend/controller/ingress/ingress_integration_test.go index 52767e637..2d6831e09 100644 --- a/backend/controller/ingress/ingress_integration_test.go +++ b/backend/controller/ingress/ingress_integration_test.go @@ -5,6 +5,7 @@ package ingress_test import ( "net/http" "os" + "strings" "testing" "github.com/alecthomas/assert/v2" @@ -15,146 +16,161 @@ import ( func TestHttpIngress(t *testing.T) { in.Run(t, + in.WithLanguages("go", "java"), in.CopyModule("httpingress"), in.Deploy("httpingress"), - 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"]) + in.SubTests( + in.SubTest{Name: "GetWithPathParams", Action: 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"]) + expectContentType(t, resp, "application/json;charset=utf-8") - message, ok := resp.JsonBody["msg"].(string) - assert.True(t, ok, "msg is not a string: %s", repr.String(resp.JsonBody)) - assert.Equal(t, "UserID: 123, PostID: 456", message) + message, ok := resp.JsonBody["msg"].(string) + assert.True(t, ok, "msg is not a string: %s", repr.String(resp.JsonBody)) + assert.Equal(t, "UserID: 123, PostID: 456", message) - nested, ok := resp.JsonBody["nested"].(map[string]any) - assert.True(t, ok, "nested is not a map: %s", repr.String(resp.JsonBody)) - goodStuff, ok := nested["good_stuff"].(string) - assert.True(t, ok, "good_stuff is not a string: %s", repr.String(resp.JsonBody)) - assert.Equal(t, "This is good stuff", goodStuff) - }), - in.HttpCall(http.MethodPost, "/users", nil, in.JsonData(t, in.Obj{"userId": 123, "postId": 345}), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 201, resp.Status) - assert.Equal(t, []string{"Header from FTL"}, resp.Headers["Post"]) - success, ok := resp.JsonBody["success"].(bool) - assert.True(t, ok, "success is not a bool: %s", repr.String(resp.JsonBody)) - assert.True(t, success) - }), - // contains aliased field - in.HttpCall(http.MethodPost, "/users", nil, in.JsonData(t, in.Obj{"user_id": 123, "postId": 345}), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 201, resp.Status) - }), - in.HttpCall(http.MethodPut, "/users/123", nil, in.JsonData(t, in.Obj{"postId": "346"}), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 200, resp.Status) - assert.Equal(t, []string{"Header from FTL"}, resp.Headers["Put"]) - assert.Equal(t, map[string]any{}, resp.JsonBody) - }), - in.HttpCall(http.MethodDelete, "/users/123", nil, in.JsonData(t, in.Obj{}), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 200, resp.Status) - assert.Equal(t, []string{"Header from FTL"}, resp.Headers["Delete"]) - assert.Equal(t, map[string]any{}, resp.JsonBody) - }), + nested, ok := resp.JsonBody["nested"].(map[string]any) + assert.True(t, ok, "nested is not a map: %s", repr.String(resp.JsonBody)) + goodStuff, ok := nested["good_stuff"].(string) + assert.True(t, ok, "good_stuff is not a string: %s", repr.String(resp.JsonBody)) + assert.Equal(t, "This is good stuff", goodStuff) + })}, + in.SubTest{Name: "PostUsers", Action: in.HttpCall(http.MethodPost, "/users", nil, in.JsonData(t, in.Obj{"userId": 123, "postId": 345}), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 201, resp.Status) + assert.Equal(t, []string{"Header from FTL"}, resp.Headers["Post"]) + success, ok := resp.JsonBody["success"].(bool) + assert.True(t, ok, "success is not a bool: %s", repr.String(resp.JsonBody)) + assert.True(t, success) + })}, + // contains aliased field + in.SubTest{Name: "PostUsersAliased", Action: in.HttpCall(http.MethodPost, "/users", nil, in.JsonData(t, in.Obj{"user_id": 123, "postId": 345}), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 201, resp.Status) + })}, + in.SubTest{Name: "PutUsers", Action: in.HttpCall(http.MethodPut, "/users/123", nil, in.JsonData(t, in.Obj{"postId": "346"}), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + assert.Equal(t, []string{"Header from FTL"}, resp.Headers["Put"]) + assert.Equal(t, map[string]any{}, resp.JsonBody) + })}, + in.SubTest{Name: "DeleteUsers", Action: in.HttpCall(http.MethodDelete, "/users/123", nil, nil, func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + assert.Equal(t, []string{"Header from FTL"}, resp.Headers["Delete"]) + assert.Equal(t, map[string]any{}, resp.JsonBody) + })}, - in.HttpCall(http.MethodGet, "/queryparams?foo=bar", nil, in.JsonData(t, in.Obj{}), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 200, resp.Status) - assert.Equal(t, "bar", string(resp.BodyBytes)) - }), + in.SubTest{Name: "GetQueryParams", Action: in.HttpCall(http.MethodGet, "/queryparams?foo=bar", nil, nil, func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + assert.Equal(t, "bar", string(resp.BodyBytes)) + })}, - in.HttpCall(http.MethodGet, "/queryparams", nil, in.JsonData(t, in.Obj{}), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 200, resp.Status) - assert.Equal(t, "No value", string(resp.BodyBytes)) - }), + in.SubTest{Name: "GetMissingQueryParams", Action: in.HttpCall(http.MethodGet, "/queryparams", nil, nil, func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + assert.Equal(t, "No value", string(resp.BodyBytes)) + })}, - in.HttpCall(http.MethodGet, "/html", nil, in.JsonData(t, in.Obj{}), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 200, resp.Status) - assert.Equal(t, []string{"text/html; charset=utf-8"}, resp.Headers["Content-Type"]) - assert.Equal(t, "

HTML Page From FTL 🚀!

", string(resp.BodyBytes)) - }), + in.SubTest{Name: "GetHTML", Action: in.HttpCall(http.MethodGet, "/html", nil, nil, func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + expectContentType(t, resp, "text/html;charset=utf-8") + assert.Equal(t, "

HTML Page From FTL 🚀!

", string(resp.BodyBytes)) + })}, - in.HttpCall(http.MethodPost, "/bytes", nil, []byte("Hello, World!"), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 200, resp.Status) - assert.Equal(t, []string{"application/octet-stream"}, resp.Headers["Content-Type"]) - assert.Equal(t, []byte("Hello, World!"), resp.BodyBytes) - }), + in.SubTest{Name: "PostBytes", Action: in.HttpCall(http.MethodPost, "/bytes", nil, []byte("Hello, World!"), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + expectContentType(t, resp, "application/octet-stream") + assert.Equal(t, []byte("Hello, World!"), resp.BodyBytes) + })}, - in.HttpCall(http.MethodGet, "/empty", nil, nil, func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 200, resp.Status) - assert.Equal(t, nil, resp.Headers["Content-Type"]) - assert.Equal(t, nil, resp.BodyBytes) - }), - - 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.SubTest{Name: "GetEmpty", Action: in.HttpCall(http.MethodGet, "/empty", nil, nil, func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + assert.Equal(t, nil, resp.Headers["Content-Type"]) + assert.Equal(t, nil, resp.BodyBytes) + })}, - 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.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.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) - }), - in.HttpCall(http.MethodGet, "/error", nil, nil, func(t testing.TB, resp *in.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) - }), - 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) - }), - in.HttpCall(http.MethodPost, "/array/data", nil, in.JsonData(t, []in.Obj{{"item": "a"}, {"item": "b"}}), 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{{"item": "a"}, {"item": "b"}}), resp.BodyBytes) - }), - 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) - }), - // CORS preflight request without CORS middleware enabled - in.HttpCall(http.MethodOptions, "/typeenum", map[string][]string{ - "Origin": {"http://localhost:8892"}, - "Access-Control-Request-Method": {"GET"}, - "Access-Control-Request-Headers": {"x-forwarded-capabilities"}, - }, nil, func(t testing.TB, resp *in.HTTPResponse) { - // should not return access control headers because we have not set up cors in this controller - assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Origin"]) - assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Methods"]) - assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Headers"]) - }), - 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.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) - }), - // not lenient - in.HttpCall(http.MethodPost, "/users", nil, in.JsonData(t, in.Obj{"user_id": 123, "postId": 345, "extra": "blah"}), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 400, resp.Status) - }), - // lenient - in.HttpCall(http.MethodPost, "/lenient", nil, in.JsonData(t, in.Obj{"user_id": 123, "postId": 345, "extra": "blah"}), func(t testing.TB, resp *in.HTTPResponse) { - assert.Equal(t, 201, resp.Status) - }), + in.SubTest{Name: "PostString", Action: in.HttpCall(http.MethodPost, "/string", nil, []byte("Hello, World!"), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + expectContentType(t, resp, "text/plain;charset=utf-8") + assert.Equal(t, []byte("Hello, World!"), resp.BodyBytes) + })}, + in.SubTest{Name: "GetError", Action: in.HttpCall(http.MethodGet, "/error", nil, nil, func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 500, resp.Status) + expectContentType(t, resp, "text/plain;charset=utf-8") + assert.True(t, strings.Contains(string(resp.BodyBytes), "Error")) + })}, + in.SubTest{Name: "PostArrayString", Action: 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) + expectContentType(t, resp, "application/json;charset=utf-8") + assert.Equal(t, in.JsonData(t, []string{"hello", "world"}), resp.BodyBytes) + })}, + in.SubTest{Name: "PostArrayJSON", Action: in.HttpCall(http.MethodPost, "/array/data", nil, in.JsonData(t, []in.Obj{{"item": "a"}, {"item": "b"}}), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + expectContentType(t, resp, "application/json;charset=utf-8") + assert.Equal(t, in.JsonData(t, []in.Obj{{"item": "a"}, {"item": "b"}}), resp.BodyBytes) + })}, + // CORS preflight request without CORS middleware enabled + in.SubTest{Name: "TestOptionsRequest", Action: in.HttpCall(http.MethodOptions, "/typeenum", map[string][]string{ + "Origin": {"http://localhost:8892"}, + "Access-Control-Request-Method": {"GET"}, + "Access-Control-Request-Headers": {"x-forwarded-capabilities"}, + }, nil, func(t testing.TB, resp *in.HTTPResponse) { + // should not return access control headers because we have not set up cors in this controller + assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Origin"]) + assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Methods"]) + assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Headers"]) + })}, + ), + in.IfLanguage("go", in.SubTests( + // Double, Int etc work in java with JSON encoding, but test/plain is not implemented yet + in.SubTest{Name: "PostInt", Action: in.HttpCall(http.MethodPost, "/int", nil, []byte("1234"), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + expectContentType(t, resp, "text/plain;charset=utf-8") + assert.Equal(t, []byte("1234"), resp.BodyBytes) + })}, + in.SubTest{Name: "PostFloat", Action: in.HttpCall(http.MethodPost, "/float", nil, []byte("1234.56789"), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + expectContentType(t, resp, "text/plain;charset=utf-8") + assert.Equal(t, []byte("1234.56789"), resp.BodyBytes) + })}, + in.SubTest{Name: "PostBool", Action: in.HttpCall(http.MethodPost, "/bool", nil, []byte("true"), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 200, resp.Status) + expectContentType(t, resp, "text/plain;charset=utf-8") + assert.Equal(t, []byte("true"), resp.BodyBytes) + })}, + in.SubTest{Name: "PostTypeEnum", Action: 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) + expectContentType(t, resp, "application/json;charset=utf-8") + assert.Equal(t, in.JsonData(t, in.Obj{"name": "A", "value": "hello"}), resp.BodyBytes) + })}, + in.SubTest{Name: "TestExternalType", Action: 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) + expectContentType(t, resp, "application/json;charset=utf-8") + assert.Equal(t, in.JsonData(t, in.Obj{"message": "hello"}), resp.BodyBytes) + })}, + in.SubTest{Name: "TestExternalType2", Action: 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) + expectContentType(t, resp, "application/json;charset=utf-8") + assert.Equal(t, in.JsonData(t, in.Obj{"Message": "hello"}), resp.BodyBytes) + })}, + // not lenient + in.SubTest{Name: "TestExtraFieldStrict", Action: in.HttpCall(http.MethodPost, "/users", nil, in.JsonData(t, in.Obj{"user_id": 123, "postId": 345, "extra": "blah"}), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 400, resp.Status) + })}, + // lenient + in.SubTest{Name: "TestExtraFieldLenient", Action: in.HttpCall(http.MethodPost, "/lenient", nil, in.JsonData(t, in.Obj{"user_id": 123, "postId": 345, "extra": "blah"}), func(t testing.TB, resp *in.HTTPResponse) { + assert.Equal(t, 201, resp.Status) + })}, + )), ) } +func expectContentType(t testing.TB, resp *in.HTTPResponse, expected string) { + t.Helper() + headers := resp.Headers["Content-Type"] + for k, v := range headers { + headers[k] = strings.ReplaceAll(strings.ToLower(v), " ", "") + } + expected = strings.ReplaceAll(strings.ToLower(expected), " ", "") + assert.Equal(t, []string{expected}, headers) +} + // Run with CORS enabled via FTL_CONTROLLER_ALLOW_ORIGIN and FTL_CONTROLLER_ALLOW_HEADERS // This test is similar to TestHttpIngress above with the addition of CORS enabled in the controller. func TestHttpIngressWithCors(t *testing.T) { diff --git a/backend/controller/ingress/testdata/java/httpingress/ftl.toml b/backend/controller/ingress/testdata/java/httpingress/ftl.toml new file mode 100644 index 000000000..e277e2457 --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/ftl.toml @@ -0,0 +1,2 @@ +module = "httpingress" +language = "java" diff --git a/backend/controller/ingress/testdata/java/httpingress/pom.xml b/backend/controller/ingress/testdata/java/httpingress/pom.xml new file mode 100644 index 000000000..3081bbc8b --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + xyz.block.ftl.examples + httpingress + 1.0-SNAPSHOT + + + xyz.block.ftl + ftl-build-parent-java + 1.0-SNAPSHOT + + + diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/ArrayType.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/ArrayType.java new file mode 100644 index 000000000..bbcdebde1 --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/ArrayType.java @@ -0,0 +1,15 @@ +package xyz.block.ftl.java.test.http; + +public class ArrayType { + + private String item; + + public String getItem() { + return item; + } + + public ArrayType setItem(String item) { + this.item = item; + return this; + } +} diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/DeleteRequest.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/DeleteRequest.java new file mode 100644 index 000000000..7ba6a56cf --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/DeleteRequest.java @@ -0,0 +1,13 @@ +package xyz.block.ftl.java.test.http; + +public class DeleteRequest { + private String userId; + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } +} \ No newline at end of file diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/DeleteResponse.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/DeleteResponse.java new file mode 100644 index 000000000..0cfac2829 --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/DeleteResponse.java @@ -0,0 +1,4 @@ +package xyz.block.ftl.java.test.http; + +public class DeleteResponse { +} diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/GetRequest.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/GetRequest.java new file mode 100644 index 000000000..3e0bdef23 --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/GetRequest.java @@ -0,0 +1,23 @@ +package xyz.block.ftl.java.test.http; + +public class GetRequest { + + private String userId; + private String postId; + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getPostId() { + return postId; + } + + public void setPostId(String postId) { + this.postId = postId; + } +} diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/GetResponse.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/GetResponse.java new file mode 100644 index 000000000..ee54ff249 --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/GetResponse.java @@ -0,0 +1,27 @@ +package xyz.block.ftl.java.test.http; + +public class GetResponse { + + private Nested nested; + + private String msg; + + public Nested getNested() { + return nested; + } + + public GetResponse setNested(Nested nested) { + this.nested = nested; + return this; + + } + + public String getMsg() { + return msg; + } + + public GetResponse setMsg(String msg) { + this.msg = msg; + return this; + } +} diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/Nested.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/Nested.java new file mode 100644 index 000000000..9885ee93a --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/Nested.java @@ -0,0 +1,18 @@ +package xyz.block.ftl.java.test.http; + +import com.fasterxml.jackson.annotation.JsonAlias; + +public class Nested { + + @JsonAlias("good_stuff") + private String goodStuff; + + public String getGoodStuff() { + return goodStuff; + } + + public Nested setGoodStuff(String goodStuff) { + this.goodStuff = goodStuff; + return this; + } +} diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PostRequest.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PostRequest.java new file mode 100644 index 000000000..675140969 --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PostRequest.java @@ -0,0 +1,25 @@ +package xyz.block.ftl.java.test.http; + +import com.fasterxml.jackson.annotation.JsonAlias; + +public class PostRequest { + @JsonAlias("user_id") + private int userId; + private int postId; + + public int getUserId() { + return userId; + } + + public void setUserId(int userId) { + this.userId = userId; + } + + public int getPostId() { + return postId; + } + + public void setPostId(int postId) { + this.postId = postId; + } +} \ No newline at end of file diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PostResponse.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PostResponse.java new file mode 100644 index 000000000..4082def8a --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PostResponse.java @@ -0,0 +1,14 @@ +package xyz.block.ftl.java.test.http; + +public class PostResponse { + private boolean success; + + public boolean isSuccess() { + return success; + } + + public PostResponse setSuccess(boolean success) { + this.success = success; + return this; + } +} \ No newline at end of file diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PutRequest.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PutRequest.java new file mode 100644 index 000000000..f6219cf8c --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PutRequest.java @@ -0,0 +1,13 @@ +package xyz.block.ftl.java.test.http; + +public class PutRequest { + private String postId; + + public String getPostId() { + return postId; + } + + public void setPostId(String postId) { + this.postId = postId; + } +} \ No newline at end of file diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PutResponse.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PutResponse.java new file mode 100644 index 000000000..27fa8e67e --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/PutResponse.java @@ -0,0 +1,5 @@ +package xyz.block.ftl.java.test.http; + +public class PutResponse { + +} \ No newline at end of file diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/QueryParamRequest.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/QueryParamRequest.java new file mode 100644 index 000000000..bce8e6b60 --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/QueryParamRequest.java @@ -0,0 +1,18 @@ +package xyz.block.ftl.java.test.http; + +import org.jetbrains.annotations.Nullable; + +public class QueryParamRequest { + + @Nullable + String foo; + + public @Nullable String getFoo() { + return foo; + } + + public QueryParamRequest setFoo(@Nullable String foo) { + this.foo = foo; + return this; + } +} diff --git a/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/TestHTTP.java b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/TestHTTP.java new file mode 100644 index 000000000..bb4da42d2 --- /dev/null +++ b/backend/controller/ingress/testdata/java/httpingress/src/main/java/xyz/block/ftl/java/test/http/TestHTTP.java @@ -0,0 +1,124 @@ +package xyz.block.ftl.java.test.http; + +import java.util.List; + +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; + +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.ResponseHeader; +import org.jboss.resteasy.reactive.ResponseStatus; +import org.jboss.resteasy.reactive.RestPath; +import org.jboss.resteasy.reactive.RestQuery; + +@Path("/") +public class TestHTTP { + + @GET + @Path("/users/{userId}/posts/{postId}") + @ResponseHeader(name = "Get", value = "Header from FTL") + public GetResponse get(@RestPath String userId, @RestPath String postId) { + return new GetResponse() + .setMsg(String.format("UserID: %s, PostID: %s", userId, postId)) + .setNested(new Nested().setGoodStuff("This is good stuff")); + } + + @POST + @Path("/users") + @ResponseStatus(201) + @ResponseHeader(name = "Post", value = "Header from FTL") + public PostResponse post(PostRequest req) { + return new PostResponse().setSuccess(true); + } + + @PUT + @Path("/users/{userId}") + @ResponseHeader(name = "Put", value = "Header from FTL") + public PutResponse put(PutRequest req) { + return new PutResponse(); + } + + @DELETE + @Path("/users/{userId}") + @ResponseHeader(name = "Delete", value = "Header from FTL") + @ResponseStatus(200) + public DeleteResponse delete(@RestPath String userId) { + System.out.println("delete"); + return new DeleteResponse(); + } + + @GET + @Path("/queryparams") + public String query(@RestQuery String foo) { + return foo == null ? "No value" : foo; + } + + @GET + @Path("/html") + @Produces("text/html; charset=utf-8") + public String html() { + return "

HTML Page From FTL 🚀!

"; + } + + @POST + @Path("/bytes") + public byte[] bytes(byte[] b) { + return b; + } + + @GET + @Path("/empty") + @ResponseStatus(200) + public void empty() { + } + + @POST + @Path("/string") + public String string(String val) { + return val; + } + + @POST + @Path("/int") + @Produces(MediaType.APPLICATION_JSON) + public int intMethod(int val) { + return val; + } + + @POST + @Path("/float") + @Produces(MediaType.APPLICATION_JSON) + public float floatVerb(float val) { + return val; + } + + @POST + @Path("/bool") + @Produces(MediaType.APPLICATION_JSON) + public boolean bool(boolean val) { + return val; + } + + @GET + @Path("/error") + public String error() { + throw new RuntimeException("Error from FTL"); + } + + @POST + @Path("/array/string") + public String[] arrayString(String[] array) { + return array; + } + + @POST + @Path("/array/data") + public List arrayData(List array) { + return array; + } + +} diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java index 70f98075a..6da8c8b58 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java @@ -37,6 +37,7 @@ import org.jboss.resteasy.reactive.server.processor.scanning.MethodScanner; import org.jetbrains.annotations.NotNull; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -79,7 +80,6 @@ import xyz.block.ftl.Verb; import xyz.block.ftl.VerbName; import xyz.block.ftl.runtime.FTLDatasourceCredentials; -import xyz.block.ftl.runtime.FTLHttpHandler; import xyz.block.ftl.runtime.FTLRecorder; import xyz.block.ftl.runtime.JsonSerializationConfig; import xyz.block.ftl.runtime.TopicHelper; @@ -89,6 +89,7 @@ import xyz.block.ftl.runtime.builtin.HttpRequest; import xyz.block.ftl.runtime.builtin.HttpResponse; import xyz.block.ftl.runtime.config.FTLConfigSource; +import xyz.block.ftl.runtime.http.FTLHttpHandler; import xyz.block.ftl.v1.CallRequest; import xyz.block.ftl.v1.schema.Any; import xyz.block.ftl.v1.schema.Array; @@ -104,6 +105,7 @@ import xyz.block.ftl.v1.schema.IngressPathParameter; import xyz.block.ftl.v1.schema.Int; import xyz.block.ftl.v1.schema.Metadata; +import xyz.block.ftl.v1.schema.MetadataAlias; import xyz.block.ftl.v1.schema.MetadataCalls; import xyz.block.ftl.v1.schema.MetadataCronJob; import xyz.block.ftl.v1.schema.MetadataIngress; @@ -277,7 +279,7 @@ public void registerVerbs(CombinedIndexBuildItem index, for (var endpoint : restEndpoints.getEntries()) { //TODO: naming var verbName = methodToName(endpoint.getMethodInfo()); - recorder.registerHttpIngress(moduleName, verbName); + boolean base64 = false; //TODO: handle type parameters properly org.jboss.jandex.Type bodyParamType = VoidType.VOID; @@ -290,13 +292,25 @@ public void registerVerbs(CombinedIndexBuildItem index, } } + if (bodyParamType instanceof ArrayType) { + org.jboss.jandex.Type component = ((ArrayType) bodyParamType).component(); + if (component instanceof PrimitiveType) { + base64 = component.asPrimitiveType().equals(PrimitiveType.BYTE); + } + } + + recorder.registerHttpIngress(moduleName, verbName, base64); + StringBuilder pathBuilder = new StringBuilder(); if (endpoint.getBasicResourceClassInfo().getPath() != null) { pathBuilder.append(endpoint.getBasicResourceClassInfo().getPath()); } if (endpoint.getResourceMethod().getPath() != null && !endpoint.getResourceMethod().getPath().isEmpty()) { - if (pathBuilder.charAt(pathBuilder.length() - 1) != '/' - && !endpoint.getResourceMethod().getPath().startsWith("/")) { + boolean builderEndsSlash = pathBuilder.charAt(pathBuilder.length() - 1) == '/'; + boolean pathStartsSlash = endpoint.getResourceMethod().getPath().startsWith("/"); + if (builderEndsSlash && pathStartsSlash) { + pathBuilder.setLength(pathBuilder.length() - 1); + } else if (!builderEndsSlash && !pathStartsSlash) { pathBuilder.append('/'); } pathBuilder.append(endpoint.getResourceMethod().getPath()); @@ -311,15 +325,17 @@ public void registerVerbs(CombinedIndexBuildItem index, + methodToName(endpoint.getMethodInfo()) + " FTL does not support custom regular expressions"); } else if (i.type == URITemplate.Type.LITERAL) { - if (i.literalText.equals("/")) { - continue; + for (var part : i.literalText.split("/")) { + if (part.isEmpty()) { + continue; + } + pathComponents.add(IngressPathComponent.newBuilder() + .setIngressPathLiteral(IngressPathLiteral.newBuilder().setText(part)) + .build()); } - pathComponents.add(IngressPathComponent.newBuilder() - .setIngressPathLiteral(IngressPathLiteral.newBuilder().setText(i.literalText.replace("/", ""))) - .build()); } else { pathComponents.add(IngressPathComponent.newBuilder() - .setIngressPathParameter(IngressPathParameter.newBuilder().setName(i.name.replace("/", ""))) + .setIngressPathParameter(IngressPathParameter.newBuilder().setName(i.name)) .build()); } } @@ -794,8 +810,18 @@ private void buildDataElement(ExtractionContext context, Data.Builder data, DotN //TODO: handle getters and setters properly, also Jackson annotations etc for (var field : clazz.fields()) { if (!Modifier.isStatic(field.flags())) { - data.addFields(Field.newBuilder().setName(field.name()) - .setType(buildType(context, field.type(), data.getExport())).build()); + Field.Builder builder = Field.newBuilder().setName(field.name()) + .setType(buildType(context, field.type(), data.getExport())); + if (field.hasAnnotation(JsonAlias.class)) { + var aliases = field.annotation(JsonAlias.class); + if (aliases.value() != null) { + for (var alias : aliases.value().asStringArray()) { + builder.addMetadata( + Metadata.newBuilder().setAlias(MetadataAlias.newBuilder().setKind(0).setAlias(alias))); + } + } + } + data.addFields(builder.build()); } } buildDataElement(context, data, clazz.superName()); diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java index 6f0208c38..5fd81a623 100644 --- a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java +++ b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLRecorder.java @@ -12,6 +12,8 @@ import io.quarkus.arc.Arc; import io.quarkus.runtime.annotations.Recorder; +import xyz.block.ftl.runtime.http.FTLHttpHandler; +import xyz.block.ftl.runtime.http.HTTPVerbInvoker; import xyz.block.ftl.v1.CallRequest; @Recorder @@ -34,10 +36,11 @@ public void registerVerb(String module, String verbName, String methodName, List } } - public void registerHttpIngress(String module, String verbName) { + public void registerHttpIngress(String module, String verbName, boolean base64Encoded) { try { - Arc.container().instance(VerbRegistry.class).get().register(module, verbName, - Arc.container().instance(FTLHttpHandler.class).get()); + FTLHttpHandler ftlHttpHandler = Arc.container().instance(FTLHttpHandler.class).get(); + VerbRegistry verbRegistry = Arc.container().instance(VerbRegistry.class).get(); + verbRegistry.register(module, verbName, new HTTPVerbInvoker(base64Encoded, ftlHttpHandler)); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/JsonSerializationConfig.java b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/JsonSerializationConfig.java index 5f19e2ca2..a4343b441 100644 --- a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/JsonSerializationConfig.java +++ b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/JsonSerializationConfig.java @@ -3,8 +3,7 @@ import java.io.IOException; import java.util.Base64; -import jakarta.enterprise.event.Observes; -import jakarta.json.stream.JsonGenerator; +import jakarta.inject.Singleton; import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.core.JsonParser; @@ -19,16 +18,19 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import io.quarkus.runtime.StartupEvent; +import io.quarkus.arc.Unremovable; +import io.quarkus.jackson.ObjectMapperCustomizer; /** * This class configures the FTL serialization */ -public class JsonSerializationConfig { +@Singleton +@Unremovable +public class JsonSerializationConfig implements ObjectMapperCustomizer { - void startup(@Observes StartupEvent event, ObjectMapper mapper) { + @Override + public void customize(ObjectMapper mapper) { mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); - SimpleModule module = new SimpleModule("ByteArraySerializer", new Version(1, 0, 0, "")); module.addSerializer(byte[].class, new ByteArraySerializer()); module.addDeserializer(byte[].class, new ByteArrayDeserializer()); diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLHttpHandler.java b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/http/FTLHttpHandler.java similarity index 75% rename from jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLHttpHandler.java rename to jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/http/FTLHttpHandler.java index 48596bffe..04144b79a 100644 --- a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/FTLHttpHandler.java +++ b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/http/FTLHttpHandler.java @@ -1,11 +1,16 @@ -package xyz.block.ftl.runtime; +package xyz.block.ftl.runtime.http; import java.io.ByteArrayOutputStream; import java.net.InetSocketAddress; import java.nio.channels.Channels; import java.nio.channels.WritableByteChannel; import java.nio.charset.StandardCharsets; -import java.util.*; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import jakarta.inject.Singleton; @@ -19,18 +24,27 @@ import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.FileRegion; -import io.netty.handler.codec.http.*; +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.DefaultLastHttpContent; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.LastHttpContent; import io.netty.util.ReferenceCountUtil; import io.quarkus.netty.runtime.virtual.VirtualClientConnection; import io.quarkus.netty.runtime.virtual.VirtualResponseHandler; import io.quarkus.vertx.http.runtime.QuarkusHttpHeaders; import io.quarkus.vertx.http.runtime.VertxHttpRecorder; +import xyz.block.ftl.runtime.FTLRecorder; +import xyz.block.ftl.runtime.builtin.HttpRequest; import xyz.block.ftl.v1.CallRequest; import xyz.block.ftl.v1.CallResponse; @SuppressWarnings("unused") @Singleton -public class FTLHttpHandler implements VerbInvoker { +public class FTLHttpHandler { public static final String CONTENT_TYPE = "Content-Type"; final ObjectMapper mapper; @@ -50,13 +64,16 @@ public FTLHttpHandler(ObjectMapper mapper) { this.mapper = mapper; } - @Override - public CallResponse handle(CallRequest in) { + public CallResponse handle(CallRequest in, boolean base64Encoded) { try { var body = mapper.createParser(in.getBody().newInput()) .readValueAs(xyz.block.ftl.runtime.builtin.HttpRequest.class); body.getHeaders().put(FTLRecorder.X_FTL_VERB, List.of(in.getVerb().getName())); - var ret = handleRequest(body); + var ret = handleRequest(body, base64Encoded); + if (ret.getBody() == null) { + ret.setBody("{}"); + } + ret.getHeaders().remove("content-length"); var mappedResponse = mapper.writer().writeValueAsBytes(ret); return CallResponse.newBuilder().setBody(ByteString.copyFrom(mappedResponse)).build(); } catch (Exception e) { @@ -66,10 +83,10 @@ public CallResponse handle(CallRequest in) { } - public xyz.block.ftl.runtime.builtin.HttpResponse handleRequest(xyz.block.ftl.runtime.builtin.HttpRequest request) { + public xyz.block.ftl.runtime.builtin.HttpResponse handleRequest(HttpRequest request, boolean base64Encoded) { InetSocketAddress clientAddress = null; try { - return nettyDispatch(clientAddress, request); + return nettyDispatch(clientAddress, request, base64Encoded); } catch (Exception e) { log.error("Request Failure", e); xyz.block.ftl.runtime.builtin.HttpResponse res = new xyz.block.ftl.runtime.builtin.HttpResponse(); @@ -83,12 +100,15 @@ public xyz.block.ftl.runtime.builtin.HttpResponse handleRequest(xyz.block.ftl.ru private class NettyResponseHandler implements VirtualResponseHandler { xyz.block.ftl.runtime.builtin.HttpResponse responseBuilder = new xyz.block.ftl.runtime.builtin.HttpResponse(); + final boolean base64Encoded; ByteArrayOutputStream baos; WritableByteChannel byteChannel; final xyz.block.ftl.runtime.builtin.HttpRequest request; CompletableFuture future = new CompletableFuture<>(); + boolean json = false; - public NettyResponseHandler(xyz.block.ftl.runtime.builtin.HttpRequest request) { + public NettyResponseHandler(boolean base64Encoded, xyz.block.ftl.runtime.builtin.HttpRequest request) { + this.base64Encoded = base64Encoded; this.request = request; } @@ -114,6 +134,12 @@ public void handleMessage(Object msg) { } headers.put(name, allForName); } + if (res.headers().contains(CONTENT_TYPE)) { + String contentType = res.headers().get(CONTENT_TYPE); + if (contentType != null && !contentType.isEmpty()) { + json = contentType.toLowerCase(Locale.ROOT).contains("application/json"); + } + } } if (msg instanceof HttpContent) { HttpContent content = (HttpContent) msg; @@ -137,15 +163,21 @@ public void handleMessage(Object msg) { } if (msg instanceof LastHttpContent) { if (baos != null) { + if (json) { + responseBuilder.setBody(baos.toString(StandardCharsets.UTF_8)); + } else if (base64Encoded) { + responseBuilder.setBody( + mapper.writer().writeValueAsString(Base64.getEncoder().encodeToString(baos.toByteArray()))); + } else { + responseBuilder.setBody(mapper.writer().writeValueAsString(baos.toString(StandardCharsets.UTF_8))); + } List ct = responseBuilder.getHeaders().get(CONTENT_TYPE); if (ct == null || ct.isEmpty()) { //TODO: how to handle this responseBuilder.setBody(baos.toString(StandardCharsets.UTF_8)); } else if (ct.get(0).contains(MediaType.TEXT_PLAIN)) { // need to encode as JSON string - responseBuilder.setBody(mapper.writer().writeValueAsString(baos.toString(StandardCharsets.UTF_8))); } else { - responseBuilder.setBody(baos.toString(StandardCharsets.UTF_8)); } } future.complete(responseBuilder); @@ -167,7 +199,7 @@ public void close() { } private xyz.block.ftl.runtime.builtin.HttpResponse nettyDispatch(InetSocketAddress clientAddress, - xyz.block.ftl.runtime.builtin.HttpRequest request) + HttpRequest request, boolean base64Encoded) throws Exception { QuarkusHttpHeaders quarkusHeaders = new QuarkusHttpHeaders(); quarkusHeaders.setContextObject(xyz.block.ftl.runtime.builtin.HttpRequest.class, request); @@ -212,10 +244,22 @@ private xyz.block.ftl.runtime.builtin.HttpResponse nettyDispatch(InetSocketAddre if (request.getBody() != null) { // See https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.3 nettyRequest.headers().add(HttpHeaderNames.TRANSFER_ENCODING, "chunked"); - ByteBuf body = Unpooled.copiedBuffer(request.getBody().toString(), StandardCharsets.UTF_8); //TODO: do we need to look at the request encoding? - requestContent = new DefaultLastHttpContent(body); + if (base64Encoded) { + requestContent = new DefaultLastHttpContent( + Unpooled.copiedBuffer(Base64.getDecoder().decode(request.getBody().asText()))); + } else if (request.getBody().isTextual()) { + requestContent = new DefaultLastHttpContent( + Unpooled.copiedBuffer(request.getBody().asText(), StandardCharsets.UTF_8)); + } else if (request.getBody().isBigDecimal() || request.getBody().isDouble() || request.getBody().isFloat() + || request.getBody().isInt() || request.getBody().isBigInteger()) { + requestContent = new DefaultLastHttpContent( + Unpooled.copiedBuffer(request.getBody().toString(), StandardCharsets.UTF_8)); + } else { + ByteBuf body = Unpooled.copiedBuffer(request.getBody().toString(), StandardCharsets.UTF_8); //TODO: do we need to look at the request encoding? + requestContent = new DefaultLastHttpContent(body); + } } - NettyResponseHandler handler = new NettyResponseHandler(request); + NettyResponseHandler handler = new NettyResponseHandler(base64Encoded, request); VirtualClientConnection connection = VirtualClientConnection.connect(handler, VertxHttpRecorder.VIRTUAL_HTTP, clientAddress); diff --git a/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/http/HTTPVerbInvoker.java b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/http/HTTPVerbInvoker.java new file mode 100644 index 000000000..b5d3c32bb --- /dev/null +++ b/jvm-runtime/ftl-runtime/common/runtime/src/main/java/xyz/block/ftl/runtime/http/HTTPVerbInvoker.java @@ -0,0 +1,24 @@ +package xyz.block.ftl.runtime.http; + +import xyz.block.ftl.runtime.VerbInvoker; +import xyz.block.ftl.v1.CallRequest; +import xyz.block.ftl.v1.CallResponse; + +public class HTTPVerbInvoker implements VerbInvoker { + + /** + * If this is true then the request is base 64 encoded bytes + */ + final boolean base64Encoded; + final FTLHttpHandler ftlHttpHandler; + + public HTTPVerbInvoker(boolean base64Encoded, FTLHttpHandler ftlHttpHandler) { + this.base64Encoded = base64Encoded; + this.ftlHttpHandler = ftlHttpHandler; + } + + @Override + public CallResponse handle(CallRequest in) { + return ftlHttpHandler.handle(in, base64Encoded); + } +} diff --git a/jvm-runtime/ftl-runtime/java/integration-tests/pom.xml b/jvm-runtime/ftl-runtime/java/integration-tests/pom.xml index 3577f0260..2161409dc 100644 --- a/jvm-runtime/ftl-runtime/java/integration-tests/pom.xml +++ b/jvm-runtime/ftl-runtime/java/integration-tests/pom.xml @@ -29,6 +29,12 @@ rest-assured test + + + xyz.block.ftl + ftl-java-runtime-deployment + test + io.quarkus quarkus-junit5-mockito diff --git a/jvm-runtime/ftl-runtime/java/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResource.java b/jvm-runtime/ftl-runtime/java/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResource.java index 55f9fe300..85907b006 100644 --- a/jvm-runtime/ftl-runtime/java/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResource.java +++ b/jvm-runtime/ftl-runtime/java/integration-tests/src/main/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResource.java @@ -16,24 +16,36 @@ */ package xyz.block.ftl.java.runtime.it; +import java.nio.charset.StandardCharsets; + import jakarta.enterprise.context.ApplicationScoped; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; import jakarta.ws.rs.core.MediaType; import ftl.echo.EchoClient; import ftl.echo.EchoRequest; import xyz.block.ftl.Verb; +@Path("/test") @ApplicationScoped public class FtlJavaRuntimeResource { @POST @Consumes(MediaType.APPLICATION_JSON) + @Path("/post") public String post(Person person) { return "Hello " + person.first() + " " + person.last(); } + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Path("/bytes") + public String bytesHttp(byte[] data) { + return "Hello " + new String(data, StandardCharsets.UTF_8); + } + @Verb public String hello(String name, EchoClient echoClient) { return "Hello " + echoClient.call(new EchoRequest().setName(name)).getMessage(); diff --git a/jvm-runtime/ftl-runtime/java/integration-tests/src/test/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResourceTest.java b/jvm-runtime/ftl-runtime/java/integration-tests/src/test/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResourceTest.java index 77b17a2df..a440670c3 100644 --- a/jvm-runtime/ftl-runtime/java/integration-tests/src/test/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResourceTest.java +++ b/jvm-runtime/ftl-runtime/java/integration-tests/src/test/java/xyz/block/ftl/java/runtime/it/FtlJavaRuntimeResourceTest.java @@ -1,5 +1,8 @@ package xyz.block.ftl.java.runtime.it; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; import java.util.function.Function; import jakarta.inject.Inject; @@ -9,10 +12,12 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import ftl.builtin.HttpRequest; +import ftl.builtin.HttpResponse; import ftl.echo.EchoClient; import ftl.echo.EchoRequest; import ftl.echo.EchoResponse; -import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.common.WithTestResource; import io.quarkus.test.junit.QuarkusTest; import xyz.block.ftl.VerbClient; import xyz.block.ftl.VerbClientDefinition; @@ -22,7 +27,7 @@ import xyz.block.ftl.java.test.internal.TestVerbServer; @QuarkusTest -@QuarkusTestResource(FTLTestResource.class) +@WithTestResource(FTLTestResource.class) public class FtlJavaRuntimeResourceTest { @FTLManaged @@ -37,6 +42,14 @@ public class FtlJavaRuntimeResourceTest { @Inject BytesClient bytesClient; + @FTLManaged + @Inject + PostClient postClient; + + @FTLManaged + @Inject + BytesHTTPClient bytesHttpClient; + @Test public void testHelloEndpoint() { TestVerbServer.registerFakeVerb("echo", "echo", new Function() { @@ -61,6 +74,33 @@ public void testBytesSerialization() { Assertions.assertArrayEquals(new byte[] { 1, 2 }, bytesClient.call(new byte[] { 1, 2 })); } + @Test + public void testHttpPost() { + HttpRequest request = new HttpRequest() + .setMethod("POST") + .setPath("/test/post") + .setQuery(new HashMap<>()) + .setPathParameters(new HashMap<>()) + .setHeaders(new HashMap<>()) + .setBody(new Person("Stuart", "Douglas")); + HttpResponse response = postClient.call(request); + Assertions.assertEquals("Hello Stuart Douglas", response.getBody()); + } + + @Test + public void testHttpBytes() { + HttpRequest request = new HttpRequest() + .setMethod("POST") + .setPath("/test/bytes") + .setQuery(new HashMap<>()) + .setPathParameters(new HashMap<>()) + .setHeaders(new HashMap<>()) + .setBody("Stuart Douglas".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + HttpResponse response = bytesHttpClient.call(request); + Assertions.assertArrayEquals("Hello Stuart Douglas".getBytes(StandardCharsets.UTF_8), + Base64.getDecoder().decode(response.getBody())); + } + @VerbClientDefinition(name = "publish") interface PublishVerbClient extends VerbClientSink { } @@ -72,4 +112,12 @@ interface HelloClient extends VerbClient { @VerbClientDefinition(name = "bytes") interface BytesClient extends VerbClient { } + + @VerbClientDefinition(name = "post") + interface PostClient extends VerbClient, HttpResponse> { + } + + @VerbClientDefinition(name = "bytesHttp") + interface BytesHTTPClient extends VerbClient, HttpResponse> { + } } diff --git a/jvm-runtime/ftl-runtime/pom.xml b/jvm-runtime/ftl-runtime/pom.xml index c91073144..033b02af7 100644 --- a/jvm-runtime/ftl-runtime/pom.xml +++ b/jvm-runtime/ftl-runtime/pom.xml @@ -51,6 +51,11 @@ ftl-java-runtime ${project.version} + + xyz.block.ftl + ftl-java-runtime-deployment + ${project.version} + xyz.block.ftl ftl-kotlin-runtime