From 8ba4cd491537c2e61e2be1ef99047f76080c296c Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 5 Feb 2024 17:38:49 -0700 Subject: [PATCH] feat: add support for string and Unit request and response types --- backend/controller/controller.go | 10 +- backend/controller/ingress/ingress.go | 331 +++--------------- backend/controller/ingress/ingress_test.go | 18 +- backend/controller/ingress/request.go | 272 ++++++++++++++ backend/controller/ingress/response.go | 60 ++++ integration/integration_test.go | 17 +- integration/testdata/go/httpingress/echo.go | 35 +- .../testdata/kotlin/httpingress/Echo.kt | 22 +- .../ftl/schemaextractor/ExtractSchemaRule.kt | 1 + 9 files changed, 445 insertions(+), 321 deletions(-) create mode 100644 backend/controller/ingress/request.go create mode 100644 backend/controller/ingress/response.go diff --git a/backend/controller/controller.go b/backend/controller/controller.go index 68186c39cd..fc439cf31b 100644 --- a/backend/controller/controller.go +++ b/backend/controller/controller.go @@ -204,7 +204,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - body, err := ingress.ValidateAndExtractRequestBody(route, r, sch) + body, err := ingress.BuildRequestBody(route, r, sch) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -248,7 +248,13 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - ingress.SetDefaultContentType(response.Headers) + verbResponse := verb.Response.(*schema.DataRef) //nolint:forcetypeassert + err = ingress.ValidateContentType(verbResponse, sch, response.Headers) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + responseBody, err = ingress.ResponseBodyForVerb(sch, verb, response.Body, response.Headers) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/backend/controller/ingress/ingress.go b/backend/controller/ingress/ingress.go index 88e1a9b346..4dc6479392 100644 --- a/backend/controller/ingress/ingress.go +++ b/backend/controller/ingress/ingress.go @@ -3,12 +3,8 @@ package ingress import ( "encoding/base64" "encoding/json" - "errors" "fmt" - "io" "math/rand" - "net/http" - "net/url" "reflect" "strconv" "strings" @@ -62,136 +58,67 @@ func matchSegments(pattern, urlPath string, onMatch func(segment, value string)) return true } -func SetDefaultContentType(headers map[string][]string) { - if _, hasContentType := headers["Content-Type"]; !hasContentType { - headers["Content-Type"] = []string{"application/json"} - } +var contentTypeMap = map[reflect.Type]string{ + reflect.TypeOf(&schema.Bytes{}): "application/octet-stream", + reflect.TypeOf(&schema.String{}): "text/plain; charset=utf-8", + reflect.TypeOf(&schema.Int{}): "text/plain; charset=utf-8", + reflect.TypeOf(&schema.Float{}): "text/plain; charset=utf-8", + reflect.TypeOf(&schema.Bool{}): "text/plain; charset=utf-8", + reflect.TypeOf(&schema.DataRef{}): "application/json; charset=utf-8", + reflect.TypeOf(&schema.Map{}): "application/json; charset=utf-8", + reflect.TypeOf(&schema.Array{}): "application/json; charset=utf-8", + reflect.TypeOf(&schema.Unit{}): "", } -func ValidateCallBody(body []byte, verbRef *schema.VerbRef, sch *schema.Schema) error { - verb := sch.ResolveVerbRef(verbRef) - if verb == nil { - return fmt.Errorf("unknown verb %s", verbRef) - } - - var requestMap map[string]any - err := json.Unmarshal(body, &requestMap) +func ValidateContentType(dataRef *schema.DataRef, sch *schema.Schema, headers map[string][]string) error { + bodyField, err := getBodyField(dataRef, sch) if err != nil { - return fmt.Errorf("HTTP request body is not valid JSON: %w", err) - } - - return validateValue(verb.Request, []string{verb.Request.String()}, requestMap, sch) -} - -// ValidateAndExtractRequestBody extracts the HttpRequest body from an HTTP request. -func ValidateAndExtractRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Schema) ([]byte, error) { - verb := sch.ResolveVerbRef(&schema.VerbRef{Name: route.Verb, Module: route.Module}) - if verb == nil { - return nil, fmt.Errorf("unknown verb %s", route.Verb) - } - - request, ok := verb.Request.(*schema.DataRef) - if !ok { - return nil, fmt.Errorf("verb %s input must be a data structure", verb.Name) + return err } - var body []byte - - var requestMap map[string]any - - if metadata, ok := verb.GetMetadataIngress().Get(); ok && metadata.Type == "http" { - pathParameters := map[string]string{} - matchSegments(route.Path, r.URL.Path, func(segment, value string) { - pathParameters[segment] = value - }) - - httpRequestBody, err := extractHTTPRequestBody(route, r, request, sch) + _, hasContentType := headers["Content-Type"] + if !hasContentType { + defaultType, err := getDefaultContentType(bodyField) if err != nil { - return nil, err + return err } - - requestMap = map[string]any{} - requestMap["method"] = r.Method - requestMap["path"] = r.URL.Path - requestMap["pathParameters"] = pathParameters - requestMap["query"] = r.URL.Query() - requestMap["headers"] = r.Header - requestMap["body"] = httpRequestBody - } else { - var err error - requestMap, err = buildRequestMap(route, r, request, sch) - if err != nil { - return nil, err + if defaultType == "" { + return nil // No content type is required } + headers["Content-Type"] = []string{defaultType} } - requestMap, err := transformFromAliasedFields(request, sch, requestMap) - if err != nil { - return nil, err - } - - err = validateRequestMap(request, []string{request.String()}, requestMap, sch) - if err != nil { - return nil, err - } + contentType := headers["Content-Type"][0] + expectedType, _ := getDefaultContentType(bodyField) - body, err = json.Marshal(requestMap) - if err != nil { - return nil, err + // Check if the expected content type matches the actual content type, considering special cases + if expectedType != "" && !strings.HasPrefix(contentType, expectedType) { + return fmt.Errorf("expected %s content type, got %s", expectedType, contentType) } - return body, nil + return nil } -func ResponseBodyForVerb(sch *schema.Schema, verb *schema.Verb, body []byte, headers map[string][]string) ([]byte, error) { - if contentType, hasContentType := headers["Content-Type"]; hasContentType { - if strings.HasPrefix(contentType[0], "text/") { - var textContent string - if err := json.Unmarshal(body, &textContent); err != nil { - return nil, err - } - return []byte(textContent), nil - } +func getDefaultContentType(bodyField *schema.Field) (string, error) { + if defaultType, exists := contentTypeMap[reflect.TypeOf(bodyField.Type)]; exists { + return defaultType, nil } + return "", fmt.Errorf("content type for %T not defined", bodyField.Type) +} - responseRef, ok := verb.Response.(*schema.DataRef) - if !ok { - return body, nil +func ValidateCallBody(body []byte, verbRef *schema.VerbRef, sch *schema.Schema) error { + verb := sch.ResolveVerbRef(verbRef) + if verb == nil { + return fmt.Errorf("unknown verb %s", verbRef) } - bodyField, err := getBodyField(responseRef, sch) + var requestMap map[string]any + err := json.Unmarshal(body, &requestMap) if err != nil { - return nil, err + return fmt.Errorf("HTTP request body is not valid JSON: %w", err) } - switch bodyType := bodyField.Type.(type) { - case *schema.DataRef: - var responseMap map[string]any - err := json.Unmarshal(body, &responseMap) - if err != nil { - return nil, fmt.Errorf("HTTP response body is not valid JSON: %w", err) - } - - aliasedResponseMap, err := transformToAliasedFields(bodyType, sch, responseMap) - if err != nil { - return nil, err - } - return json.Marshal(aliasedResponseMap) - - case *schema.Bytes: - var base64String string - if err := json.Unmarshal(body, &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, fmt.Errorf("failed to decode base64 response body: %w", err) - } - return decodedBody, nil - - default: - return body, nil - } + return validateValue(verb.Request, []string{verb.Request.String()}, requestMap, sch) } func getBodyField(dataRef *schema.DataRef, sch *schema.Schema) (*schema.Field, error) { @@ -214,99 +141,6 @@ func getBodyField(dataRef *schema.DataRef, sch *schema.Schema) (*schema.Field, e return bodyField, nil } -func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, dataRef *schema.DataRef, sch *schema.Schema) (any, error) { - bodyField, err := getBodyField(dataRef, sch) - if err != nil { - return nil, err - } - - switch bodyType := bodyField.Type.(type) { - case *schema.DataRef: - bodyMap, err := buildRequestMap(route, r, bodyType, sch) - if err != nil { - return nil, err - } - return bodyMap, nil - - case *schema.Bytes: - defer r.Body.Close() - bodyData, err := io.ReadAll(r.Body) - if err != nil { - return nil, fmt.Errorf("error reading request body: %w", err) - } - return bodyData, nil - - default: - return nil, fmt.Errorf("unsupported HttpRequest.Body type %T", bodyField.Type) - } -} - -func buildRequestMap(route *dal.IngressRoute, r *http.Request, dataRef *schema.DataRef, sch *schema.Schema) (map[string]any, error) { - requestMap := map[string]any{} - matchSegments(route.Path, r.URL.Path, func(segment, value string) { - requestMap[segment] = value - }) - - switch r.Method { - case http.MethodPost, http.MethodPut: - var bodyMap map[string]any - err := json.NewDecoder(r.Body).Decode(&bodyMap) - if err != nil { - return nil, fmt.Errorf("HTTP request body is not valid JSON: %w", err) - } - - // Merge bodyMap into params - for k, v := range bodyMap { - requestMap[k] = v - } - default: - data, err := sch.ResolveDataRefMonomorphised(dataRef) - if err != nil { - return nil, err - } - - queryMap, err := parseQueryParams(r.URL.Query(), data) - if err != nil { - return nil, fmt.Errorf("HTTP query params are not valid: %w", err) - } - - for key, value := range queryMap { - requestMap[key] = value - } - } - - return requestMap, nil -} - -func validateRequestMap(dataRef *schema.DataRef, path path, request map[string]any, sch *schema.Schema) error { - data, err := sch.ResolveDataRefMonomorphised(dataRef) - if err != nil { - return err - } - - var errs []error - for _, field := range data.Fields { - fieldPath := append(path, "."+field.Name) //nolint:gocritic - - _, isOptional := field.Type.(*schema.Optional) - value, haveValue := request[field.Name] - if !isOptional && !haveValue { - errs = append(errs, fmt.Errorf("%s is required", fieldPath)) - continue - } - - if haveValue { - err := validateValue(field.Type, fieldPath, value, sch) - if err != nil { - errs = append(errs, err) - } - } - - } - - return errors.Join(errs...) -} - func validateValue(fieldType schema.Type, path path, value any, sch *schema.Schema) error { var typeMatches bool switch fieldType := fieldType.(type) { @@ -432,90 +266,3 @@ func validateValue(fieldType schema.Type, path path, value any, sch *schema.Sche } return nil } - -func parseQueryParams(values url.Values, data *schema.Data) (map[string]any, error) { - if jsonStr, ok := values["@json"]; ok { - if len(values) > 1 { - return nil, fmt.Errorf("only '@json' parameter is allowed, but other parameters were found") - } - if len(jsonStr) > 1 { - return nil, fmt.Errorf("'@json' parameter must be provided exactly once") - } - - return decodeQueryJSON(jsonStr[0]) - } - - queryMap := make(map[string]any) - for key, value := range values { - if hasInvalidQueryChars(key) { - return nil, fmt.Errorf("complex key %q is not supported, use '@json=' instead", key) - } - - var field *schema.Field - for _, f := range data.Fields { - if (f.Alias != "" && f.Alias == key) || f.Name == key { - field = f - } - for _, typeParam := range data.TypeParameters { - if typeParam.String() == key { - field = &schema.Field{ - Name: key, - Type: typeParam, - } - } - } - } - - if field == nil { - queryMap[key] = value - continue - } - - switch field.Type.(type) { - case *schema.Bytes, *schema.Map, *schema.Optional, *schema.Time, - *schema.Unit, *schema.DataRef, *schema.Any, *schema.TypeParameter: - - case *schema.Int, *schema.Float, *schema.String, *schema.Bool: - if len(value) > 1 { - return nil, fmt.Errorf("multiple values for %q are not supported", key) - } - if hasInvalidQueryChars(value[0]) { - return nil, fmt.Errorf("complex value %q is not supported, use '@json=' instead", value[0]) - } - queryMap[key] = value[0] - - case *schema.Array: - for _, v := range value { - if hasInvalidQueryChars(v) { - return nil, fmt.Errorf("complex value %q is not supported, use '@json=' instead", v) - } - } - queryMap[key] = value - - default: - panic(fmt.Sprintf("unsupported type %T for query parameter field %q", field.Type, key)) - } - } - - return queryMap, nil -} - -func decodeQueryJSON(query string) (map[string]any, error) { - decodedJSONStr, err := url.QueryUnescape(query) - if err != nil { - return nil, fmt.Errorf("failed to decode '@json' query parameter: %w", err) - } - - // Unmarshal the JSON string into a map - var resultMap map[string]any - err = json.Unmarshal([]byte(decodedJSONStr), &resultMap) - if err != nil { - return nil, fmt.Errorf("failed to parse '@json' query parameter: %w", err) - } - - return resultMap, nil -} - -func hasInvalidQueryChars(s string) bool { - return strings.ContainsAny(s, "{}[]|\\^`") -} diff --git a/backend/controller/ingress/ingress_test.go b/backend/controller/ingress/ingress_test.go index b7a7117c99..3112bf7cd4 100644 --- a/backend/controller/ingress/ingress_test.go +++ b/backend/controller/ingress/ingress_test.go @@ -141,16 +141,6 @@ func TestParseQueryJson(t *testing.T) { } } -func TestSetDefaultContentType(t *testing.T) { - headers := map[string][]string{} - SetDefaultContentType(headers) - assert.Equal(t, map[string][]string{"Content-Type": {"application/json"}}, headers) - - headers = map[string][]string{"Content-Type": {"text/html"}} - SetDefaultContentType(headers) - assert.Equal(t, map[string][]string{"Content-Type": {"text/html"}}, headers) -} - func TestResponseBodyForVerb(t *testing.T) { jsonVerb := &schema.Verb{ Name: "Json", @@ -162,10 +152,10 @@ func TestResponseBodyForVerb(t *testing.T) { }, }}, } - bytesVerb := &schema.Verb{ - Name: "Json", + stringVerb := &schema.Verb{ + Name: "String", Response: &schema.DataRef{Module: "builtin", Name: "HttpResponse", TypeParameters: []schema.Type{ - &schema.Bytes{}, + &schema.String{}, }}, } sch := &schema.Schema{ @@ -208,7 +198,7 @@ func TestResponseBodyForVerb(t *testing.T) { }, { name: "text/html", - verb: bytesVerb, + verb: stringVerb, headers: map[string][]string{"Content-Type": {"text/html"}}, body: []byte(`"Hello, World!"`), expectedBody: []byte("Hello, World!"), diff --git a/backend/controller/ingress/request.go b/backend/controller/ingress/request.go new file mode 100644 index 0000000000..c1cd858520 --- /dev/null +++ b/backend/controller/ingress/request.go @@ -0,0 +1,272 @@ +package ingress + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/TBD54566975/ftl/backend/controller/dal" + "github.com/TBD54566975/ftl/backend/schema" +) + +// BuildRequestBody extracts the HttpRequest body from an HTTP request. +func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Schema) ([]byte, error) { + verb := sch.ResolveVerbRef(&schema.VerbRef{Name: route.Verb, Module: route.Module}) + if verb == nil { + return nil, fmt.Errorf("unknown verb %s", route.Verb) + } + + request, ok := verb.Request.(*schema.DataRef) + if !ok { + return nil, fmt.Errorf("verb %s input must be a data structure", verb.Name) + } + + var body []byte + + var requestMap map[string]any + + if metadata, ok := verb.GetMetadataIngress().Get(); ok && metadata.Type == "http" { + pathParameters := map[string]string{} + matchSegments(route.Path, r.URL.Path, func(segment, value string) { + pathParameters[segment] = value + }) + + httpRequestBody, err := extractHTTPRequestBody(route, r, request, sch) + if err != nil { + return nil, err + } + + requestMap = map[string]any{} + requestMap["method"] = r.Method + requestMap["path"] = r.URL.Path + requestMap["pathParameters"] = pathParameters + requestMap["query"] = r.URL.Query() + requestMap["headers"] = r.Header + requestMap["body"] = httpRequestBody + } else { + var err error + requestMap, err = buildRequestMap(route, r, request, sch) + if err != nil { + return nil, err + } + } + + requestMap, err := transformFromAliasedFields(request, sch, requestMap) + if err != nil { + return nil, err + } + + err = validateRequestMap(request, []string{request.String()}, requestMap, sch) + if err != nil { + return nil, err + } + + body, err = json.Marshal(requestMap) + if err != nil { + return nil, err + } + + return body, nil +} + +func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, dataRef *schema.DataRef, sch *schema.Schema) (any, error) { + bodyField, err := getBodyField(dataRef, sch) + if err != nil { + return nil, err + } + + switch bodyType := bodyField.Type.(type) { + case *schema.DataRef: + bodyMap, err := buildRequestMap(route, r, bodyType, sch) + if err != nil { + return nil, err + } + return bodyMap, nil + + case *schema.Bytes: + bodyData, err := readRequestBody(r) + if err != nil { + return nil, err + } + return bodyData, nil + + case *schema.String: + bodyData, err := readRequestBody(r) + if err != nil { + return nil, err + } + return string(bodyData), nil + + case *schema.Unit: + return map[string]any{}, nil + + default: + return nil, fmt.Errorf("unsupported HttpRequest.Body type %T", bodyField.Type) + } +} + +func readRequestBody(r *http.Request) ([]byte, error) { + defer r.Body.Close() + bodyData, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("error reading request body: %w", err) + } + return bodyData, nil +} + +func buildRequestMap(route *dal.IngressRoute, r *http.Request, dataRef *schema.DataRef, sch *schema.Schema) (map[string]any, error) { + requestMap := map[string]any{} + matchSegments(route.Path, r.URL.Path, func(segment, value string) { + requestMap[segment] = value + }) + + switch r.Method { + case http.MethodPost, http.MethodPut: + var bodyMap map[string]any + err := json.NewDecoder(r.Body).Decode(&bodyMap) + if err != nil { + return nil, fmt.Errorf("HTTP request body is not valid JSON: %w", err) + } + + // Merge bodyMap into params + for k, v := range bodyMap { + requestMap[k] = v + } + default: + data, err := sch.ResolveDataRefMonomorphised(dataRef) + if err != nil { + return nil, err + } + + queryMap, err := parseQueryParams(r.URL.Query(), data) + if err != nil { + return nil, fmt.Errorf("HTTP query params are not valid: %w", err) + } + + for key, value := range queryMap { + requestMap[key] = value + } + } + + return requestMap, nil +} + +func validateRequestMap(dataRef *schema.DataRef, path path, request map[string]any, sch *schema.Schema) error { + data, err := sch.ResolveDataRefMonomorphised(dataRef) + if err != nil { + return err + } + + var errs []error + for _, field := range data.Fields { + fieldPath := append(path, "."+field.Name) //nolint:gocritic + + _, isOptional := field.Type.(*schema.Optional) + value, haveValue := request[field.Name] + if !isOptional && !haveValue { + errs = append(errs, fmt.Errorf("%s is required", fieldPath)) + continue + } + + if haveValue { + err := validateValue(field.Type, fieldPath, value, sch) + if err != nil { + errs = append(errs, err) + } + } + + } + + return errors.Join(errs...) +} + +func parseQueryParams(values url.Values, data *schema.Data) (map[string]any, error) { + if jsonStr, ok := values["@json"]; ok { + if len(values) > 1 { + return nil, fmt.Errorf("only '@json' parameter is allowed, but other parameters were found") + } + if len(jsonStr) > 1 { + return nil, fmt.Errorf("'@json' parameter must be provided exactly once") + } + + return decodeQueryJSON(jsonStr[0]) + } + + queryMap := make(map[string]any) + for key, value := range values { + if hasInvalidQueryChars(key) { + return nil, fmt.Errorf("complex key %q is not supported, use '@json=' instead", key) + } + + var field *schema.Field + for _, f := range data.Fields { + if (f.Alias != "" && f.Alias == key) || f.Name == key { + field = f + } + for _, typeParam := range data.TypeParameters { + if typeParam.String() == key { + field = &schema.Field{ + Name: key, + Type: typeParam, + } + } + } + } + + if field == nil { + queryMap[key] = value + continue + } + + switch field.Type.(type) { + case *schema.Bytes, *schema.Map, *schema.Optional, *schema.Time, + *schema.Unit, *schema.DataRef, *schema.Any, *schema.TypeParameter: + + case *schema.Int, *schema.Float, *schema.String, *schema.Bool: + if len(value) > 1 { + return nil, fmt.Errorf("multiple values for %q are not supported", key) + } + if hasInvalidQueryChars(value[0]) { + return nil, fmt.Errorf("complex value %q is not supported, use '@json=' instead", value[0]) + } + queryMap[key] = value[0] + + case *schema.Array: + for _, v := range value { + if hasInvalidQueryChars(v) { + return nil, fmt.Errorf("complex value %q is not supported, use '@json=' instead", v) + } + } + queryMap[key] = value + + default: + panic(fmt.Sprintf("unsupported type %T for query parameter field %q", field.Type, key)) + } + } + + return queryMap, nil +} + +func decodeQueryJSON(query string) (map[string]any, error) { + decodedJSONStr, err := url.QueryUnescape(query) + if err != nil { + return nil, fmt.Errorf("failed to decode '@json' query parameter: %w", err) + } + + // Unmarshal the JSON string into a map + var resultMap map[string]any + err = json.Unmarshal([]byte(decodedJSONStr), &resultMap) + if err != nil { + return nil, fmt.Errorf("failed to parse '@json' query parameter: %w", err) + } + + return resultMap, nil +} + +func hasInvalidQueryChars(s string) bool { + return strings.ContainsAny(s, "{}[]|\\^`") +} diff --git a/backend/controller/ingress/response.go b/backend/controller/ingress/response.go new file mode 100644 index 0000000000..dcf0f2b5f6 --- /dev/null +++ b/backend/controller/ingress/response.go @@ -0,0 +1,60 @@ +package ingress + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/TBD54566975/ftl/backend/schema" +) + +func ResponseBodyForVerb(sch *schema.Schema, verb *schema.Verb, body []byte, headers map[string][]string) ([]byte, error) { + responseRef, ok := verb.Response.(*schema.DataRef) + if !ok { + return body, nil + } + + bodyField, err := getBodyField(responseRef, sch) + if err != nil { + return nil, err + } + + switch bodyType := bodyField.Type.(type) { + case *schema.DataRef: + var responseMap map[string]any + err := json.Unmarshal(body, &responseMap) + if err != nil { + return nil, fmt.Errorf("HTTP response body is not valid JSON: %w", err) + } + + aliasedResponseMap, err := transformToAliasedFields(bodyType, sch, responseMap) + if err != nil { + return nil, err + } + return json.Marshal(aliasedResponseMap) + + case *schema.Bytes: + var base64String string + if err := json.Unmarshal(body, &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, fmt.Errorf("failed to decode base64 response body: %w", err) + } + return decodedBody, nil + + case *schema.String: + var responseString string + if err := json.Unmarshal(body, &responseString); err != nil { + return nil, fmt.Errorf("HTTP response body is not valid string: %w", err) + } + return []byte(responseString), nil + + case *schema.Unit: + return []byte{}, nil + + default: + return body, nil + } +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 4b7146cfbb..10fadb2b37 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -69,6 +69,7 @@ func TestHttpIngress(t *testing.T) { httpCall(rd, http.MethodGet, "/users/123/posts/456", jsonData(t, obj{}), func(t testing.TB, resp *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"]) message, ok := resp.body["msg"].(string) assert.True(t, ok, "msg is not a string") @@ -94,12 +95,12 @@ func TestHttpIngress(t *testing.T) { httpCall(rd, http.MethodPut, "/users/123", jsonData(t, obj{"postID": "346"}), func(t testing.TB, resp *httpResponse) { assert.Equal(t, 200, resp.status) assert.Equal(t, []string{"Header from FTL"}, resp.headers["Put"]) - assert.Equal(t, map[string]any{}, resp.body) + assert.Equal(t, nil, resp.body) }), httpCall(rd, http.MethodDelete, "/users/123", jsonData(t, obj{}), func(t testing.TB, resp *httpResponse) { assert.Equal(t, 200, resp.status) assert.Equal(t, []string{"Header from FTL"}, resp.headers["Delete"]) - assert.Equal(t, map[string]any{}, resp.body) + assert.Equal(t, nil, resp.body) }), httpCall(rd, http.MethodGet, "/html", jsonData(t, obj{}), func(t testing.TB, resp *httpResponse) { @@ -113,6 +114,18 @@ func TestHttpIngress(t *testing.T) { assert.Equal(t, []string{"application/octet-stream"}, resp.headers["Content-Type"]) assert.Equal(t, []byte("Hello, World!"), resp.bodyBytes) }), + + httpCall(rd, http.MethodGet, "/empty", nil, func(t testing.TB, resp *httpResponse) { + assert.Equal(t, 200, resp.status) + assert.Equal(t, nil, resp.headers["Content-Type"]) + assert.Equal(t, nil, resp.bodyBytes) + }), + + httpCall(rd, http.MethodGet, "/string", []byte("Hello, World!"), func(t testing.TB, resp *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) + }), }}, } }) diff --git a/integration/testdata/go/httpingress/echo.go b/integration/testdata/go/httpingress/echo.go index 6ef55a24d5..6d552214b2 100644 --- a/integration/testdata/go/httpingress/echo.go +++ b/integration/testdata/go/httpingress/echo.go @@ -6,6 +6,8 @@ import ( "fmt" "ftl/builtin" + + ftl "github.com/TBD54566975/ftl/go-runtime/sdk" // Import the FTL SDK. ) type GetRequest struct { @@ -64,11 +66,11 @@ type PutResponse struct{} //ftl:verb //ftl:ingress http PUT /users/{userID} -func Put(ctx context.Context, req builtin.HttpRequest[PutRequest]) (builtin.HttpResponse[PutResponse], error) { - return builtin.HttpResponse[PutResponse]{ +func Put(ctx context.Context, req builtin.HttpRequest[PutRequest]) (builtin.HttpResponse[ftl.Unit], error) { + return builtin.HttpResponse[ftl.Unit]{ Status: 200, Headers: map[string][]string{"Put": {"Header from FTL"}}, - Body: PutResponse{}, + Body: ftl.Unit{}, }, nil } @@ -80,11 +82,11 @@ type DeleteResponse struct{} //ftl:verb //ftl:ingress http DELETE /users/{userId} -func Delete(ctx context.Context, req builtin.HttpRequest[DeleteRequest]) (builtin.HttpResponse[DeleteResponse], error) { - return builtin.HttpResponse[DeleteResponse]{ +func Delete(ctx context.Context, req builtin.HttpRequest[DeleteRequest]) (builtin.HttpResponse[ftl.Unit], error) { + return builtin.HttpResponse[ftl.Unit]{ Status: 200, Headers: map[string][]string{"Delete": {"Header from FTL"}}, - Body: DeleteResponse{}, + Body: ftl.Unit{}, }, nil } @@ -94,7 +96,6 @@ type HtmlRequest struct{} //ftl:ingress http GET /html func Html(ctx context.Context, req builtin.HttpRequest[HtmlRequest]) (builtin.HttpResponse[string], error) { return builtin.HttpResponse[string]{ - Status: 200, Headers: map[string][]string{"Content-Type": {"text/html; charset=utf-8"}}, Body: "

HTML Page From FTL 🚀!

", }, nil @@ -104,8 +105,22 @@ func Html(ctx context.Context, req builtin.HttpRequest[HtmlRequest]) (builtin.Ht //ftl:ingress http POST /bytes func Bytes(ctx context.Context, req builtin.HttpRequest[[]byte]) (builtin.HttpResponse[[]byte], error) { return builtin.HttpResponse[[]byte]{ - Status: 200, - Headers: map[string][]string{"Content-Type": {"application/octet-stream"}}, - Body: req.Body, + Body: req.Body, + }, nil +} + +//ftl:verb +//ftl:ingress http GET /empty +func Empty(ctx context.Context, req builtin.HttpRequest[ftl.Unit]) (builtin.HttpResponse[ftl.Unit], error) { + return builtin.HttpResponse[ftl.Unit]{ + Body: ftl.Unit{}, + }, nil +} + +//ftl:verb +//ftl:ingress http GET /string +func String(ctx context.Context, req builtin.HttpRequest[string]) (builtin.HttpResponse[string], error) { + return builtin.HttpResponse[string]{ + Body: req.Body, }, nil } diff --git a/integration/testdata/kotlin/httpingress/Echo.kt b/integration/testdata/kotlin/httpingress/Echo.kt index cf25396c83..80b3c3fde6 100644 --- a/integration/testdata/kotlin/httpingress/Echo.kt +++ b/integration/testdata/kotlin/httpingress/Echo.kt @@ -44,8 +44,8 @@ typealias PutResponse = Unit data class DeleteRequest( @Alias("userId") val userID: String, ) - typealias DeleteResponse = Unit + typealias HtmlRequest = Unit class Echo { @@ -112,4 +112,24 @@ class Echo { body = req.body, ) } + + @Verb + @Ingress(Method.GET, "/empty", HTTP) + fun empty(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("Empty" to arrayListOf("Header from FTL")), + body = Unit + ) + } + + @Verb + @Ingress(Method.GET, "/string", HTTP) + fun string(context: Context, req: HttpRequest): HttpResponse { + return HttpResponse( + status = 200, + headers = mapOf("String" to arrayListOf("Header from FTL")), + body = req.body + ) + } } diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt index 6a378e4400..447dfa662d 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt @@ -426,6 +426,7 @@ class SchemaExtractor( OffsetDateTime::class.qualifiedName -> Type(time = xyz.block.ftl.v1.schema.Time()) ByteArray::class.qualifiedName -> Type(bytes = xyz.block.ftl.v1.schema.Bytes()) Any::class.qualifiedName -> Type(any = xyz.block.ftl.v1.schema.Any()) + Unit::class.qualifiedName -> Type(unit = xyz.block.ftl.v1.schema.Unit()) Map::class.qualifiedName -> { return Type( map = xyz.block.ftl.v1.schema.Map(