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