Skip to content

Commit

Permalink
feat: add array support to http ingress
Browse files Browse the repository at this point in the history
  • Loading branch information
wesbillman committed Feb 12, 2024
1 parent 0a151d8 commit 551e814
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 58 deletions.
22 changes: 22 additions & 0 deletions backend/controller/ingress/ingress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,25 @@ func TestResponseBodyForVerb(t *testing.T) {
})
}
}

func TestValueForData(t *testing.T) {
tests := []struct {
typ schema.Type
data []byte
result any
}{
{&schema.String{}, []byte("test"), "test"},
{&schema.Int{}, []byte("1234"), 1234},
{&schema.Float{}, []byte("12.34"), 12.34},
{&schema.Bool{}, []byte("true"), true},
{&schema.Array{Element: &schema.String{}}, []byte(`["test1", "test2"]`), []any{"test1", "test2"}},
{&schema.Map{Key: &schema.String{}, Value: &schema.String{}}, []byte(`{"key1": "value1", "key2": "value2"}`), obj{"key1": "value1", "key2": "value2"}},
{&schema.DataRef{Module: "test", Name: "Test"}, []byte(`{"intValue": 10.0}`), obj{"intValue": 10.0}},
}

for _, test := range tests {
result, err := valueForData(test.typ, test.data)
assert.NoError(t, err)
assert.Equal(t, test.result, result)
}
}
75 changes: 47 additions & 28 deletions backend/controller/ingress/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,58 +80,77 @@ func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, dataRef *s
return nil, err
}

switch bodyType := bodyField.Type.(type) {
if dataRef, ok := bodyField.Type.(*schema.DataRef); ok {
return buildRequestMap(route, r, dataRef, sch)
}

bodyData, err := readRequestBody(r)
if err != nil {
return nil, err
}

return valueForData(bodyField.Type, bodyData)
}

func valueForData(typ schema.Type, data []byte) (any, error) {
switch typ.(type) {
case *schema.DataRef:
bodyMap, err := buildRequestMap(route, r, bodyType, sch)
var bodyMap map[string]any
err := json.Unmarshal(data, &bodyMap)
if err != nil {
return nil, err
return nil, fmt.Errorf("HTTP request body is not valid JSON: %w", err)
}
return bodyMap, nil

case *schema.Bytes:
bodyData, err := readRequestBody(r)
case *schema.Array:
var rawData []json.RawMessage
err := json.Unmarshal(data, &rawData)
if err != nil {
return nil, err
return nil, fmt.Errorf("HTTP request body is not valid JSON array: %w", err)
}
return bodyData, nil

case *schema.String:
bodyData, err := readRequestBody(r)
if err != nil {
return nil, err
arrayData := make([]any, len(rawData))
for i, rawElement := range rawData {
var parsedElement any
err := json.Unmarshal(rawElement, &parsedElement)
if err != nil {
return nil, fmt.Errorf("failed to parse array element: %w", err)
}
arrayData[i] = parsedElement
}
return string(bodyData), nil

case *schema.Int:
bodyData, err := readRequestBody(r)
return arrayData, nil

case *schema.Map:
var bodyMap map[string]any
err := json.Unmarshal(data, &bodyMap)
if err != nil {
return nil, err
return nil, fmt.Errorf("HTTP request body is not valid JSON: %w", err)
}
return bodyMap, nil

case *schema.Bytes:
return data, nil

case *schema.String:
return string(data), nil

intVal, err := strconv.ParseInt(string(bodyData), 10, 64)
case *schema.Int:
intVal, err := strconv.ParseInt(string(data), 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse integer from request body: %w", err)
}
return intVal, nil

case *schema.Float:
bodyData, err := readRequestBody(r)
if err != nil {
return nil, err
}

floatVal, err := strconv.ParseFloat(string(bodyData), 64)
floatVal, err := strconv.ParseFloat(string(data), 64)
if err != nil {
return nil, fmt.Errorf("failed to parse float from request body: %w", err)
}
return floatVal, nil

case *schema.Bool:
bodyData, err := readRequestBody(r)
if err != nil {
return nil, err
}
boolVal, err := strconv.ParseBool(string(bodyData))
boolVal, err := strconv.ParseBool(string(data))
if err != nil {
return nil, fmt.Errorf("failed to parse boolean from request body: %w", err)
}
Expand All @@ -141,7 +160,7 @@ func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, dataRef *s
return map[string]any{}, nil

default:
return nil, fmt.Errorf("unsupported HttpRequest.Body type %T", bodyField.Type)
return nil, fmt.Errorf("unsupported data type %T", typ)
}
}

Expand Down
90 changes: 66 additions & 24 deletions backend/controller/ingress/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,65 +63,107 @@ func ResponseForVerb(sch *schema.Schema, verb *schema.Verb, response HTTPRespons
}
}

switch bodyType := fieldType.(type) {
outBody, err := bodyForType(fieldType, sch, body)
return outBody, headers, err
}

func bodyForType(typ schema.Type, sch *schema.Schema, data []byte) ([]byte, error) {
switch t := typ.(type) {
case *schema.DataRef:
var responseMap map[string]any
err := json.Unmarshal(body, &responseMap)
err := json.Unmarshal(data, &responseMap)
if err != nil {
return nil, nil, fmt.Errorf("HTTP response body is not valid JSON: %w", err)
return nil, fmt.Errorf("HTTP response body is not valid JSON: %w", err)
}

aliasedResponseMap, err := transformToAliasedFields(bodyType, sch, responseMap)
aliasedResponseMap, err := transformToAliasedFields(t, sch, responseMap)
if err != nil {
return nil, nil, err
return nil, err
}
outBody, err := json.Marshal(aliasedResponseMap)
return outBody, headers, err
return outBody, err

case *schema.Array:
var responseArray []json.RawMessage
err := json.Unmarshal(data, &responseArray)
if err != nil {
return nil, fmt.Errorf("HTTP response body is not valid JSON array: %w", err)
}

transformedArrayData := make([]any, len(responseArray))
for i, rawElement := range responseArray {
var transformedElement any
err := json.Unmarshal(rawElement, &transformedElement)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal array element: %w", err)
}

// Transform the array element to its aliased form.
if dataRef, ok := t.Element.(*schema.DataRef); ok {
if elementMap, ok := transformedElement.(map[string]any); ok {
aliasedElement, err := transformToAliasedFields(dataRef, sch, elementMap)
if err != nil {
return nil, err
}
transformedElement = aliasedElement
}
}

transformedArrayData[i] = transformedElement
}

// Marshal the transformed array back to JSON.
outBody, err := json.Marshal(transformedArrayData)
if err != nil {
return nil, fmt.Errorf("failed to marshal transformed array data: %w", err)
}

return outBody, nil

case *schema.Bytes:
var base64String string
if err := json.Unmarshal(body, &base64String); err != nil {
return nil, nil, fmt.Errorf("HTTP response body is not valid base64: %w", err)
if err := json.Unmarshal(data, &base64String); err != nil {
return nil, fmt.Errorf("HTTP response body is not valid base64: %w", err)
}
decodedBody, err := base64.StdEncoding.DecodeString(base64String)
if err != nil {
return nil, nil, fmt.Errorf("failed to decode base64 response body: %w", err)
return nil, fmt.Errorf("failed to decode base64 response body: %w", err)
}
return decodedBody, headers, nil
return decodedBody, nil

case *schema.String:
var responseString string
if err := json.Unmarshal(body, &responseString); err != nil {
return nil, nil, fmt.Errorf("HTTP response body is not a valid string: %w", err)
if err := json.Unmarshal(data, &responseString); err != nil {
return nil, fmt.Errorf("HTTP response body is not a valid string: %w", err)
}
return []byte(responseString), headers, nil
return []byte(responseString), nil

case *schema.Int:
var responseInt int
if err := json.Unmarshal(body, &responseInt); err != nil {
return nil, nil, fmt.Errorf("HTTP response body is not a valid int: %w", err)
if err := json.Unmarshal(data, &responseInt); err != nil {
return nil, fmt.Errorf("HTTP response body is not a valid int: %w", err)
}
return []byte(strconv.Itoa(responseInt)), headers, nil
return []byte(strconv.Itoa(responseInt)), nil

case *schema.Float:
var responseFloat float64
if err := json.Unmarshal(body, &responseFloat); err != nil {
return nil, nil, fmt.Errorf("HTTP response body is not a valid float: %w", err)
if err := json.Unmarshal(data, &responseFloat); err != nil {
return nil, fmt.Errorf("HTTP response body is not a valid float: %w", err)
}
return []byte(strconv.FormatFloat(responseFloat, 'f', -1, 64)), headers, nil
return []byte(strconv.FormatFloat(responseFloat, 'f', -1, 64)), nil

case *schema.Bool:
var responseBool bool
if err := json.Unmarshal(body, &responseBool); err != nil {
return nil, nil, fmt.Errorf("HTTP response body is not a valid bool: %w", err)
if err := json.Unmarshal(data, &responseBool); err != nil {
return nil, fmt.Errorf("HTTP response body is not a valid bool: %w", err)
}
return []byte(strconv.FormatBool(responseBool)), headers, nil
return []byte(strconv.FormatBool(responseBool)), nil

case *schema.Unit:
return []byte{}, headers, nil
return []byte{}, nil

default:
return body, headers, nil
return data, nil
}
}

Expand Down
2 changes: 1 addition & 1 deletion go-runtime/encoding/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func encodeValue(v reflect.Value, w *bytes.Buffer) error {
return encodeBool(v, w)

default:
panic(fmt.Sprintf("unsupported typefoo: %s", v.Type()))
panic(fmt.Sprintf("unsupported type: %s", v.Type()))
}
}

Expand Down
1 change: 1 addition & 0 deletions go-runtime/encoding/encoding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func TestMarshal(t *testing.T) {
{name: "Bool", input: struct{ Bool bool }{true}, expected: `{"bool":true}`},
{name: "Nil", input: struct{ Nil *int }{nil}, expected: `{"nil":null}`},
{name: "Slice", input: struct{ Slice []int }{[]int{1, 2, 3}}, expected: `{"slice":[1,2,3]}`},
{name: "SliceOfStrings", input: struct{ Slice []string }{[]string{"hello", "world"}}, expected: `{"slice":["hello","world"]}`},
{name: "Map", input: struct{ Map map[string]int }{map[string]int{"foo": 42}}, expected: `{"map":{"foo":42}}`},
{name: "Option", input: struct{ Option sdk.Option[int] }{sdk.Some(42)}, expected: `{"option":42}`},
{name: "OptionPtr", input: struct{ Option *sdk.Option[int] }{&somePtr}, expected: `{"option":42}`},
Expand Down
1 change: 1 addition & 0 deletions go-runtime/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func Handle[Req, Resp any](verb func(ctx context.Context, req Req) (Resp, error)
if err != nil {
return nil, err
}

return respdata, nil
},
}
Expand Down
14 changes: 12 additions & 2 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,20 @@ func TestHttpIngress(t *testing.T) {
assert.Equal(t, []byte("true"), resp.bodyBytes)
}),
httpCall(rd, http.MethodGet, "/error", nil, func(t testing.TB, resp *httpResponse) {
assert.Equal(t, 500, resp.status)
assert.Equal(t, []string{"text/plain; charset=utf-8"}, resp.headers["Content-Type"])
assert.Equal(t, []byte("Error from FTL"), resp.bodyBytes)
assert.Equal(t, 500, resp.status)
}),
httpCall(rd, http.MethodGet, "/array/string", jsonData(t, []string{"hello", "world"}), func(t testing.TB, resp *httpResponse) {
assert.Equal(t, 200, resp.status)
assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.headers["Content-Type"])
assert.Equal(t, jsonData(t, []string{"hello", "world"}), resp.bodyBytes)
}),
// httpCall(rd, http.MethodPost, "/array/data", jsonData(t, []obj{{"item": "a"}, {"item": "b"}}), func(t testing.TB, resp *httpResponse) {
// assert.Equal(t, 200, resp.status)
// assert.Equal(t, []string{"application/json; charset=utf-8"}, resp.headers["Content-Type"])
// assert.Equal(t, jsonData(t, []obj{{"item": "a"}, {"item": "b"}}), resp.bodyBytes)
// }),
}},
}
})
Expand Down Expand Up @@ -407,7 +417,7 @@ type httpResponse struct {
bodyBytes []byte
}

func jsonData(t testing.TB, body obj) []byte {
func jsonData(t testing.TB, body interface{}) []byte {
b, err := json.Marshal(body)
assert.NoError(t, err)
return b
Expand Down
20 changes: 20 additions & 0 deletions integration/testdata/go/httpingress/echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,23 @@ func Error(ctx context.Context, req builtin.HttpRequest[ftl.Unit]) (builtin.Http
Error: ftl.Some("Error from FTL"),
}, nil
}

//ftl:verb
//ftl:ingress http GET /array/string
func ArrayString(ctx context.Context, req builtin.HttpRequest[[]string]) (builtin.HttpResponse[[]string, string], error) {
return builtin.HttpResponse[[]string, string]{
Body: ftl.Some(req.Body),
}, nil
}

type ArrayType struct {
Item string `alias:"item"`
}

//ftl:verb
//ftl:ingress http POST /array/data
func ArrayData(ctx context.Context, req builtin.HttpRequest[[]ArrayType]) (builtin.HttpResponse[[]ArrayType, string], error) {
return builtin.HttpResponse[[]ArrayType, string]{
Body: ftl.Some(req.Body),
}, nil
}
26 changes: 23 additions & 3 deletions integration/testdata/kotlin/httpingress/Echo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@ data class DeleteRequest(
@Alias("userId") val userID: String,
)

data class ArrayType(
@Alias("item") val item: String,
)

@Verb
@HttpIngress(
Method.GET, "/users/{userID}/posts/{postID}"
)
@HttpIngress(Method.GET, "/users/{userID}/posts/{postID}")
fun `get`(context: Context, req: HttpRequest<GetRequest>): HttpResponse<GetResponse, String> {
return HttpResponse(
status = 200,
Expand Down Expand Up @@ -169,3 +170,22 @@ fun error(context: Context, req: HttpRequest<Unit>): HttpResponse<Boolean, Strin
)
}

@Verb
@HttpIngress(Method.GET, "/array/string")
fun arrayString(context: Context, req: HttpRequest<List<String>>): HttpResponse<List<String>, String> {
return HttpResponse(
status = 200,
headers = mapOf("ArrayString" to arrayListOf("Header from FTL")),
body = req.body
)
}

@Verb
@HttpIngress(Method.POST, "/array/data")
fun arrayData(context: Context, req: HttpRequest<List<ArrayType>>): HttpResponse<List<ArrayType>, String> {
return HttpResponse(
status = 200,
headers = mapOf("ArrayData" to arrayListOf("Header from FTL")),
body = req.body
)
}

0 comments on commit 551e814

Please sign in to comment.