diff --git a/backend/controller/console/console_test.go b/backend/controller/console/console_test.go index 344320dc9c..4485f3c982 100644 --- a/backend/controller/console/console_test.go +++ b/backend/controller/console/console_test.go @@ -3,8 +3,9 @@ package console import ( "testing" - "github.com/TBD54566975/ftl/backend/schema" "github.com/alecthomas/assert/v2" + + "github.com/TBD54566975/ftl/backend/schema" ) func TestVerbSchemaString(t *testing.T) { @@ -15,7 +16,7 @@ func TestVerbSchemaString(t *testing.T) { } ingressVerb := &schema.Verb{ Name: "Ingress", - Request: &schema.Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []schema.Type{&schema.String{}}}, + Request: &schema.Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []schema.Type{&schema.String{}, &schema.Unit{}, &schema.Unit{}}}, Response: &schema.Ref{Module: "builtin", Name: "HttpResponse", TypeParameters: []schema.Type{&schema.String{}, &schema.String{}}}, Metadata: []schema.Metadata{ &schema.MetadataIngress{Type: "http", Method: "GET", Path: []schema.IngressPathComponent{&schema.IngressPathLiteral{Text: "test"}}}, @@ -107,7 +108,7 @@ verb Echo(foo.EchoRequest) foo.EchoResponse` func TestVerbSchemaStringIngress(t *testing.T) { verb := &schema.Verb{ Name: "Ingress", - Request: &schema.Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []schema.Type{&schema.Ref{Module: "foo", Name: "FooRequest"}}}, + Request: &schema.Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []schema.Type{&schema.Ref{Module: "foo", Name: "FooRequest"}, &schema.Unit{}, &schema.Unit{}}}, Response: &schema.Ref{Module: "builtin", Name: "HttpResponse", TypeParameters: []schema.Type{&schema.Ref{Module: "foo", Name: "FooResponse"}, &schema.String{}}}, Metadata: []schema.Metadata{ &schema.MetadataIngress{Type: "http", Method: "GET", Path: []schema.IngressPathComponent{&schema.IngressPathLiteral{Text: "foo"}}}, @@ -135,11 +136,11 @@ func TestVerbSchemaStringIngress(t *testing.T) { } expected := `// HTTP request structure used for HTTP ingress verbs. -export data HttpRequest { +export data HttpRequest { method String path String - pathParameters {String: String} - query {String: [String]} + pathParameters Path + query Query headers {String: [String]} body Body } @@ -161,7 +162,7 @@ data FooResponse { Message String } -verb Ingress(builtin.HttpRequest) builtin.HttpResponse +verb Ingress(builtin.HttpRequest) builtin.HttpResponse +ingress http GET /foo` schemaString, err := verbSchemaString(sch, verb) diff --git a/backend/controller/console/testdata/go/console/console.go b/backend/controller/console/testdata/go/console/console.go index 1292620250..e6e67238b4 100644 --- a/backend/controller/console/testdata/go/console/console.go +++ b/backend/controller/console/testdata/go/console/console.go @@ -17,7 +17,7 @@ type Response struct { } //ftl:ingress http GET /test -func Get(ctx context.Context, req builtin.HttpRequest[External]) (builtin.HttpResponse[Response, string], error) { +func Get(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, External]) (builtin.HttpResponse[Response, string], error) { return builtin.HttpResponse[Response, string]{ Body: ftl.Some(Response{ Message: fmt.Sprintf("Hello, %s", req.Body.Message), diff --git a/backend/controller/ingress/handler_test.go b/backend/controller/ingress/handler_test.go index 7251e88860..319c6c7d52 100644 --- a/backend/controller/ingress/handler_test.go +++ b/backend/controller/ingress/handler_test.go @@ -45,16 +45,16 @@ func TestIngress(t *testing.T) { foo String } - export verb getAlias(HttpRequest) HttpResponse + export verb getAlias(HttpRequest) HttpResponse +ingress http GET /getAlias - export verb getPath(HttpRequest) HttpResponse + export verb getPath(HttpRequest) HttpResponse +ingress http GET /getPath/{username} - export verb postMissingTypes(HttpRequest) HttpResponse + export verb postMissingTypes(HttpRequest) HttpResponse +ingress http POST /postMissingTypes - export verb postJsonPayload(HttpRequest) HttpResponse + export verb postJsonPayload(HttpRequest) HttpResponse +ingress http POST /postJsonPayload } `) diff --git a/backend/controller/ingress/ingress.go b/backend/controller/ingress/ingress.go index bfc7e6dc0e..8c761fa93d 100644 --- a/backend/controller/ingress/ingress.go +++ b/backend/controller/ingress/ingress.go @@ -67,21 +67,21 @@ func ValidateCallBody(body []byte, verb *schema.Verb, sch *schema.Schema) error return nil } -func getBodyField(ref *schema.Ref, sch *schema.Schema) (*schema.Field, error) { +func getField(name string, ref *schema.Ref, sch *schema.Schema) (*schema.Field, error) { data, err := sch.ResolveMonomorphised(ref) if err != nil { return nil, err } var bodyField *schema.Field for _, field := range data.Fields { - if field.Name == "body" { + if field.Name == name { bodyField = field break } } if bodyField == nil { - return nil, fmt.Errorf("verb %s must have a 'body' field", ref.Name) + return nil, fmt.Errorf("verb %s must have a '%s' field", ref.Name, name) } return bodyField, nil diff --git a/backend/controller/ingress/ingress_integration_test.go b/backend/controller/ingress/ingress_integration_test.go index 52a1fabde5..52767e6371 100644 --- a/backend/controller/ingress/ingress_integration_test.go +++ b/backend/controller/ingress/ingress_integration_test.go @@ -17,7 +17,7 @@ func TestHttpIngress(t *testing.T) { in.Run(t, in.CopyModule("httpingress"), in.Deploy("httpingress"), - in.HttpCall(http.MethodGet, "/users/123/posts/456", nil, in.JsonData(t, in.Obj{}), func(t testing.TB, resp *in.HTTPResponse) { + 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"]) @@ -82,23 +82,23 @@ func TestHttpIngress(t *testing.T) { assert.Equal(t, nil, resp.BodyBytes) }), - in.HttpCall(http.MethodGet, "/string", nil, []byte("Hello, World!"), func(t testing.TB, resp *in.HTTPResponse) { + 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.HttpCall(http.MethodGet, "/int", nil, []byte("1234"), func(t testing.TB, resp *in.HTTPResponse) { + 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.MethodGet, "/float", nil, []byte("1234.56789"), func(t testing.TB, resp *in.HTTPResponse) { + 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.MethodGet, "/bool", nil, []byte("true"), func(t testing.TB, resp *in.HTTPResponse) { + 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) @@ -108,7 +108,7 @@ func TestHttpIngress(t *testing.T) { 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.MethodGet, "/array/string", nil, in.JsonData(t, []string{"hello", "world"}), func(t testing.TB, resp *in.HTTPResponse) { + 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) @@ -118,7 +118,7 @@ func TestHttpIngress(t *testing.T) { 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.MethodGet, "/typeenum", nil, in.JsonData(t, in.Obj{"name": "A", "value": "hello"}), func(t testing.TB, resp *in.HTTPResponse) { + 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) @@ -134,12 +134,12 @@ func TestHttpIngress(t *testing.T) { assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Methods"]) assert.Equal(t, nil, resp.Headers["Access-Control-Allow-Headers"]) }), - in.HttpCall(http.MethodGet, "/external", nil, in.JsonData(t, in.Obj{"message": "hello"}), func(t testing.TB, resp *in.HTTPResponse) { + 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.MethodGet, "/external2", nil, in.JsonData(t, in.Obj{"message": "hello"}), func(t testing.TB, resp *in.HTTPResponse) { + 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) diff --git a/backend/controller/ingress/request.go b/backend/controller/ingress/request.go index 0885c35c7b..aa316039b4 100644 --- a/backend/controller/ingress/request.go +++ b/backend/controller/ingress/request.go @@ -34,12 +34,16 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche var requestMap map[string]any if metadata, ok := verb.GetMetadataIngress().Get(); ok && metadata.Type == "http" { - pathParameters := map[string]any{} + pathParametersMap := map[string]string{} matchSegments(route.Path, r.URL.Path, func(segment, value string) { - pathParameters[segment] = value + pathParametersMap[segment] = value }) + pathParameters, err := manglePathParameters(pathParametersMap, request, sch) + if err != nil { + return nil, err + } - httpRequestBody, err := extractHTTPRequestBody(route, r, request, sch) + httpRequestBody, err := extractHTTPRequestBody(r, request, sch) if err != nil { return nil, err } @@ -56,6 +60,7 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche queryMap[key] = valuesAny } + finalQueryParams, err := mangleQueryParameters(queryMap, r.URL.Query(), request, sch) headerMap := make(map[string]any) for key, values := range r.Header { valuesAny := make([]any, len(values)) @@ -69,15 +74,16 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche requestMap["method"] = r.Method requestMap["path"] = r.URL.Path requestMap["pathParameters"] = pathParameters - requestMap["query"] = queryMap + requestMap["query"] = finalQueryParams requestMap["headers"] = headerMap requestMap["body"] = httpRequestBody } else { - var err error - requestMap, err = buildRequestMap(route, r, request, sch) - if err != nil { - return nil, err - } + return nil, fmt.Errorf("no HTTP ingress metadata for verb %s", verb.Name) + //var err error + //requestMap, err = buildRequestMap(route, r, request, sch) + //if err != nil { + // return nil, err + //} } requestMap, err = schema.TransformFromAliasedFields(request, sch, requestMap) @@ -102,15 +108,15 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche return body, nil } -func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, ref *schema.Ref, sch *schema.Schema) (any, error) { - bodyField, err := getBodyField(ref, sch) +func extractHTTPRequestBody(r *http.Request, ref *schema.Ref, sch *schema.Schema) (any, error) { + bodyField, err := getField("body", ref, sch) if err != nil { return nil, err } if ref, ok := bodyField.Type.(*schema.Ref); ok { if err := sch.ResolveToType(ref, &schema.Data{}); err == nil { - return buildRequestMap(route, r, ref, sch) + return buildRequestMap(r) } } @@ -122,6 +128,81 @@ func extractHTTPRequestBody(route *dal.IngressRoute, r *http.Request, ref *schem return valueForData(bodyField.Type, bodyData) } +// Takes the map of path parameters and transforms them into the appropriate type +func manglePathParameters(params map[string]string, ref *schema.Ref, sch *schema.Schema) (any, error) { + + paramsField, err := getField("pathParameters", ref, sch) + if err != nil { + return nil, err + } + + switch paramsField.Type.(type) { + case *schema.Ref, *schema.Map: + ret := map[string]any{} + for k, v := range params { + ret[k] = v + } + return ret, nil + + } + // This is a scalar, there should only be a single param + // This is validated by the schema, we don't need extra validation here + for _, val := range params { + + switch paramsField.Type.(type) { + case *schema.String: + return val, nil + case *schema.Int: + parsed, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse int from path parameter: %w", err) + } + return parsed, err + case *schema.Float: + float, err := strconv.ParseFloat(val, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse float from path parameter: %w", err) + } + return float, err + case *schema.Bool: + // TODO: is anything else considered truthy? + return val == "true", nil + default: + return nil, fmt.Errorf("unsupported path parameter type %T", paramsField.Type) + } + } + // Empty map + return map[string]any{}, nil +} + +// Takes the map of path parameters and transforms them into the appropriate type +func mangleQueryParameters(params map[string]any, underlying map[string][]string, ref *schema.Ref, sch *schema.Schema) (any, error) { + + paramsField, err := getField("query", ref, sch) + if err != nil { + return nil, err + } + + switch paramsField.Type.(type) { + + // If the type is Map it might be a map of lists for multi values params + case *schema.Map: + m := paramsField.Type.(*schema.Map) + switch m.Value.(type) { + case *schema.Array: + return params, nil + } + } + // We need to turn them into straight strings + newParams := map[string]any{} + for k, v := range underlying { + if len(v) > 0 { + newParams[k] = v[0] + } + } + return newParams, nil +} + func valueForData(typ schema.Type, data []byte) (any, error) { switch typ.(type) { case *schema.Ref: @@ -203,12 +284,7 @@ func readRequestBody(r *http.Request) ([]byte, error) { return bodyData, nil } -func buildRequestMap(route *dal.IngressRoute, r *http.Request, ref *schema.Ref, sch *schema.Schema) (map[string]any, error) { - requestMap := map[string]any{} - matchSegments(route.Path, r.URL.Path, func(segment, value string) { - requestMap[segment] = value - }) - +func buildRequestMap(r *http.Request) (map[string]any, error) { switch r.Method { case http.MethodPost, http.MethodPut: var bodyMap map[string]any @@ -217,29 +293,10 @@ func buildRequestMap(route *dal.IngressRoute, r *http.Request, ref *schema.Ref, 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 - } + return bodyMap, nil default: - symbol, err := sch.ResolveRequestResponseType(ref) - if err != nil { - return nil, err - } - - if data, ok := symbol.(*schema.Data); ok { - 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 nil, nil } - - return requestMap, nil } func parseQueryParams(values url.Values, data *schema.Data) (map[string]any, error) { diff --git a/backend/controller/ingress/request_test.go b/backend/controller/ingress/request_test.go index dec12964ef..af3363f275 100644 --- a/backend/controller/ingress/request_test.go +++ b/backend/controller/ingress/request_test.go @@ -41,13 +41,13 @@ type PostJSONPayload struct { } // HTTPRequest mirrors builtin.HttpRequest. -type HTTPRequest[Body any] struct { +type HTTPRequest[Body any, Path any, Query any] struct { Body Body Headers map[string][]string `json:"headers,omitempty"` Method string Path string - PathParameters map[string]string `json:"pathParameters,omitempty"` - Query map[string][]string `json:"query,omitempty"` + PathParameters Path `json:"pathParameters,omitempty"` + Query Query `json:"query,omitempty"` } func TestBuildRequestBody(t *testing.T) { @@ -77,20 +77,29 @@ func TestBuildRequestBody(t *testing.T) { foo String } - export verb getAlias(HttpRequest) HttpResponse + export verb getAlias(HttpRequest) HttpResponse +ingress http GET /getAlias - export verb getPath(HttpRequest) HttpResponse + export verb getPath(HttpRequest) HttpResponse +ingress http GET /getPath/{username} - export verb optionalQuery(HttpRequest) HttpResponse + export verb optionalQuery(HttpRequest) HttpResponse +ingress http GET /optionalQuery - export verb postMissingTypes(HttpRequest) HttpResponse + export verb postMissingTypes(HttpRequest) HttpResponse +ingress http POST /postMissingTypes - export verb postJsonPayload(HttpRequest) HttpResponse + export verb postJsonPayload(HttpRequest) HttpResponse +ingress http POST /postJsonPayload + + export verb getById(HttpRequest) HttpResponse + +ingress http GET /getbyid/{id} + + export verb mapQuery(HttpRequest) HttpResponse + +ingress http GET /mapQuery + + export verb multiMapQuery(HttpRequest) HttpResponse + +ingress http GET /multiMapQuery } `) assert.NoError(t, err) @@ -119,13 +128,10 @@ func TestBuildRequestBody(t *testing.T) { query: map[string][]string{ "alias": {"value"}, }, - expected: HTTPRequest[AliasRequest]{ + expected: HTTPRequest[ftl.Unit, map[string]string, AliasRequest]{ Method: "GET", Path: "/getAlias", - Query: map[string][]string{ - "alias": {"value"}, - }, - Body: AliasRequest{ + Query: AliasRequest{ Aliased: "value", }, }, @@ -135,7 +141,7 @@ func TestBuildRequestBody(t *testing.T) { method: "POST", path: "/postMissingTypes", routePath: "/postMissingTypes", - expected: HTTPRequest[MissingTypes]{ + expected: HTTPRequest[MissingTypes, map[string]string, map[string][]string]{ Method: "POST", Path: "/postMissingTypes", Body: MissingTypes{}, @@ -147,7 +153,7 @@ func TestBuildRequestBody(t *testing.T) { path: "/postJsonPayload", routePath: "/postJsonPayload", body: obj{"foo": "bar"}, - expected: HTTPRequest[PostJSONPayload]{ + expected: HTTPRequest[PostJSONPayload, map[string]string, map[string][]string]{ Method: "POST", Path: "/postJsonPayload", Body: PostJSONPayload{Foo: "bar"}, @@ -161,13 +167,10 @@ func TestBuildRequestBody(t *testing.T) { query: map[string][]string{ "foo": {"bar"}, }, - expected: HTTPRequest[QueryParameterRequest]{ + expected: HTTPRequest[map[string]string, map[string][]string, QueryParameterRequest]{ Method: "GET", Path: "/optionalQuery", - Query: map[string][]string{ - "foo": {"bar"}, - }, - Body: QueryParameterRequest{ + Query: QueryParameterRequest{ Foo: ftl.Some("bar"), }, }, @@ -177,17 +180,53 @@ func TestBuildRequestBody(t *testing.T) { method: "GET", path: "/getPath/bob", routePath: "/getPath/{username}", - expected: HTTPRequest[PathParameterRequest]{ + expected: HTTPRequest[ftl.Unit, PathParameterRequest, map[string][]string]{ Method: "GET", Path: "/getPath/bob", - PathParameters: map[string]string{ - "username": "bob", - }, - Body: PathParameterRequest{ + PathParameters: PathParameterRequest{ Username: "bob", }, }, }, + {name: "GetById", + verb: "getById", + method: "GET", + path: "/getbyid/100", + routePath: "/getbyid/{id}", + expected: HTTPRequest[ftl.Unit, int, ftl.Unit]{ + Method: "GET", + Path: "/getbyid/100", + PathParameters: 100, + }, + }, + {name: "MapQuery", + verb: "mapQuery", + method: "GET", + path: "/mapQuery", + routePath: "/mapQuery", + query: map[string][]string{ + "alias": {"value"}, + }, + expected: HTTPRequest[ftl.Unit, ftl.Unit, map[string]string]{ + Method: "GET", + Path: "/mapQuery", + Query: map[string]string{"alias": "value"}, + }, + }, + {name: "MultiMapQuery", + verb: "multiMapQuery", + method: "GET", + path: "/multiMapQuery", + routePath: "/multiMapQuery", + query: map[string][]string{ + "alias": {"value"}, + }, + expected: HTTPRequest[ftl.Unit, ftl.Unit, map[string][]string]{ + Method: "GET", + Path: "/multiMapQuery", + Query: map[string][]string{"alias": []string{"value"}}, + }, + }, } { t.Run(test.name, func(t *testing.T) { if test.body == nil { diff --git a/backend/controller/ingress/testdata/go/httpingress/httpingress.go b/backend/controller/ingress/testdata/go/httpingress/httpingress.go index c35806cda4..f6e4a92323 100644 --- a/backend/controller/ingress/testdata/go/httpingress/httpingress.go +++ b/backend/controller/ingress/testdata/go/httpingress/httpingress.go @@ -38,11 +38,11 @@ type B []string func (B) tag() {} //ftl:ingress http GET /users/{userId}/posts/{postId} -func Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, string], error) { +func Get(ctx context.Context, req builtin.HttpRequest[ftl.Unit, GetRequest, ftl.Unit]) (builtin.HttpResponse[GetResponse, string], error) { return builtin.HttpResponse[GetResponse, string]{ Headers: map[string][]string{"Get": {"Header from FTL"}}, Body: ftl.Some(GetResponse{ - Message: fmt.Sprintf("UserID: %s, PostID: %s", req.Body.UserID, req.Body.PostID), + Message: fmt.Sprintf("UserID: %s, PostID: %s", req.PathParameters.UserID, req.PathParameters.PostID), Nested: Nested{ GoodStuff: "This is good stuff", }, @@ -60,7 +60,7 @@ type PostResponse struct { } //ftl:ingress http POST /users -func Post(ctx context.Context, req builtin.HttpRequest[PostRequest]) (builtin.HttpResponse[PostResponse, string], error) { +func Post(ctx context.Context, req builtin.HttpRequest[PostRequest, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[PostResponse, string], error) { return builtin.HttpResponse[PostResponse, string]{ Status: 201, Headers: map[string][]string{"Post": {"Header from FTL"}}, @@ -69,17 +69,16 @@ func Post(ctx context.Context, req builtin.HttpRequest[PostRequest]) (builtin.Ht } type PutRequest struct { - UserID string `json:"userId"` PostID string `json:"postId"` } type PutResponse struct{} //ftl:ingress http PUT /users/{userId} -func Put(ctx context.Context, req builtin.HttpRequest[PutRequest]) (builtin.HttpResponse[builtin.Empty, string], error) { - return builtin.HttpResponse[builtin.Empty, string]{ +func Put(ctx context.Context, req builtin.HttpRequest[PutRequest, string, ftl.Unit]) (builtin.HttpResponse[PutResponse, string], error) { + return builtin.HttpResponse[PutResponse, string]{ Headers: map[string][]string{"Put": {"Header from FTL"}}, - Body: ftl.Some(builtin.Empty{}), + Body: ftl.Some(PutResponse{}), }, nil } @@ -90,7 +89,7 @@ type DeleteRequest struct { type DeleteResponse struct{} //ftl:ingress http DELETE /users/{userId} -func Delete(ctx context.Context, req builtin.HttpRequest[DeleteRequest]) (builtin.HttpResponse[builtin.Empty, string], error) { +func Delete(ctx context.Context, req builtin.HttpRequest[ftl.Unit, DeleteRequest, ftl.Unit]) (builtin.HttpResponse[builtin.Empty, string], error) { return builtin.HttpResponse[builtin.Empty, string]{ Status: 200, Headers: map[string][]string{"Delete": {"Header from FTL"}}, @@ -103,16 +102,14 @@ type QueryParamRequest struct { } //ftl:ingress http GET /queryparams -func Query(ctx context.Context, req builtin.HttpRequest[QueryParamRequest]) (builtin.HttpResponse[string, string], error) { +func Query(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, QueryParamRequest]) (builtin.HttpResponse[string, string], error) { return builtin.HttpResponse[string, string]{ - Body: ftl.Some(req.Body.Foo.Default("No value")), + Body: ftl.Some(req.Query.Foo.Default("No value")), }, nil } -type HtmlRequest struct{} - //ftl:ingress http GET /html -func Html(ctx context.Context, req builtin.HttpRequest[HtmlRequest]) (builtin.HttpResponse[string, string], error) { +func Html(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[string, string], error) { return builtin.HttpResponse[string, string]{ Headers: map[string][]string{"Content-Type": {"text/html; charset=utf-8"}}, Body: ftl.Some("

HTML Page From FTL 🚀!

"), @@ -120,45 +117,45 @@ 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, string], error) { +func Bytes(ctx context.Context, req builtin.HttpRequest[[]byte, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[[]byte, string], error) { return builtin.HttpResponse[[]byte, string]{Body: ftl.Some(req.Body)}, nil } //ftl:ingress http GET /empty -func Empty(ctx context.Context, req builtin.HttpRequest[ftl.Unit]) (builtin.HttpResponse[ftl.Unit, string], error) { +func Empty(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[ftl.Unit, string], error) { return builtin.HttpResponse[ftl.Unit, string]{Body: ftl.Some(ftl.Unit{})}, nil } -//ftl:ingress http GET /string -func String(ctx context.Context, req builtin.HttpRequest[string]) (builtin.HttpResponse[string, string], error) { +//ftl:ingress http POST /string +func String(ctx context.Context, req builtin.HttpRequest[string, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[string, string], error) { return builtin.HttpResponse[string, string]{Body: ftl.Some(req.Body)}, nil } -//ftl:ingress http GET /int -func Int(ctx context.Context, req builtin.HttpRequest[int]) (builtin.HttpResponse[int, string], error) { +//ftl:ingress http POST /int +func Int(ctx context.Context, req builtin.HttpRequest[int, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[int, string], error) { return builtin.HttpResponse[int, string]{Body: ftl.Some(req.Body)}, nil } -//ftl:ingress http GET /float -func Float(ctx context.Context, req builtin.HttpRequest[float64]) (builtin.HttpResponse[float64, string], error) { +//ftl:ingress http POST /float +func Float(ctx context.Context, req builtin.HttpRequest[float64, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[float64, string], error) { return builtin.HttpResponse[float64, string]{Body: ftl.Some(req.Body)}, nil } -//ftl:ingress http GET /bool -func Bool(ctx context.Context, req builtin.HttpRequest[bool]) (builtin.HttpResponse[bool, string], error) { +//ftl:ingress http POST /bool +func Bool(ctx context.Context, req builtin.HttpRequest[bool, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[bool, string], error) { return builtin.HttpResponse[bool, string]{Body: ftl.Some(req.Body)}, nil } //ftl:ingress http GET /error -func Error(ctx context.Context, req builtin.HttpRequest[ftl.Unit]) (builtin.HttpResponse[ftl.Unit, string], error) { +func Error(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[ftl.Unit, string], error) { return builtin.HttpResponse[ftl.Unit, string]{ Status: 500, Error: ftl.Some("Error from FTL"), }, nil } -//ftl:ingress http GET /array/string -func ArrayString(ctx context.Context, req builtin.HttpRequest[[]string]) (builtin.HttpResponse[[]string, string], error) { +//ftl:ingress http POST /array/string +func ArrayString(ctx context.Context, req builtin.HttpRequest[[]string, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[[]string, string], error) { return builtin.HttpResponse[[]string, string]{ Body: ftl.Some(req.Body), }, nil @@ -169,14 +166,14 @@ type ArrayType struct { } //ftl:ingress http POST /array/data -func ArrayData(ctx context.Context, req builtin.HttpRequest[[]ArrayType]) (builtin.HttpResponse[[]ArrayType, string], error) { +func ArrayData(ctx context.Context, req builtin.HttpRequest[[]ArrayType, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[[]ArrayType, string], error) { return builtin.HttpResponse[[]ArrayType, string]{ Body: ftl.Some(req.Body), }, nil } -//ftl:ingress http GET /typeenum -func TypeEnum(ctx context.Context, req builtin.HttpRequest[SumType]) (builtin.HttpResponse[SumType, string], error) { +//ftl:ingress http POST /typeenum +func TypeEnum(ctx context.Context, req builtin.HttpRequest[SumType, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[SumType, string], error) { return builtin.HttpResponse[SumType, string]{Body: ftl.Some(req.Body)}, nil } @@ -184,21 +181,21 @@ func TypeEnum(ctx context.Context, req builtin.HttpRequest[SumType]) (builtin.Ht type NewTypeAlias lib.NonFTLType -//ftl:ingress http GET /external -func External(ctx context.Context, req builtin.HttpRequest[NewTypeAlias]) (builtin.HttpResponse[NewTypeAlias, string], error) { +//ftl:ingress http POST /external +func External(ctx context.Context, req builtin.HttpRequest[NewTypeAlias, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[NewTypeAlias, string], error) { return builtin.HttpResponse[NewTypeAlias, string]{Body: ftl.Some(req.Body)}, nil } type DirectTypeAlias = lib.NonFTLType -//ftl:ingress http GET /external2 -func External2(ctx context.Context, req builtin.HttpRequest[DirectTypeAlias]) (builtin.HttpResponse[DirectTypeAlias, string], error) { +//ftl:ingress http POST /external2 +func External2(ctx context.Context, req builtin.HttpRequest[DirectTypeAlias, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[DirectTypeAlias, string], error) { return builtin.HttpResponse[DirectTypeAlias, string]{Body: ftl.Some(req.Body)}, nil } //ftl:ingress http POST /lenient //ftl:encoding lenient -func Lenient(ctx context.Context, req builtin.HttpRequest[PostRequest]) (builtin.HttpResponse[PostResponse, string], error) { +func Lenient(ctx context.Context, req builtin.HttpRequest[PostRequest, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[PostResponse, string], error) { return builtin.HttpResponse[PostResponse, string]{ Status: 201, Headers: map[string][]string{"Post": {"Header from FTL"}}, diff --git a/backend/schema/builtin.go b/backend/schema/builtin.go index 956a6f79f4..132cae0d69 100644 --- a/backend/schema/builtin.go +++ b/backend/schema/builtin.go @@ -7,11 +7,11 @@ const BuiltinsSource = ` // Built-in types for FTL. builtin module builtin { // HTTP request structure used for HTTP ingress verbs. - export data HttpRequest { + export data HttpRequest { method String path String - pathParameters {String: String} - query {String: [String]} + pathParameters Path + query Query headers {String: [String]} body Body } diff --git a/backend/schema/schema_test.go b/backend/schema/schema_test.go index 26cf338129..672fc1a215 100644 --- a/backend/schema/schema_test.go +++ b/backend/schema/schema_test.go @@ -50,7 +50,7 @@ module todo { +calls todo.destroy +database calls todo.testdb - export verb destroy(builtin.HttpRequest) builtin.HttpResponse + export verb destroy(builtin.HttpRequest) builtin.HttpResponse +ingress http GET /todo/destroy/{name} verb mondays(Unit) Unit @@ -192,7 +192,9 @@ Module Ref Verb Ref + Unit Ref + Unit Ref Ref String @@ -299,14 +301,14 @@ func TestParsing(t *testing.T) { input: `module int { data String { name String } verb verb(String) String }`, errors: []string{"1:14-14: data name \"String\" is a reserved word"}}, {name: "BuiltinRef", - input: `module test { verb myIngress(HttpRequest) HttpResponse }`, + input: `module test { verb myIngress(HttpRequest) HttpResponse }`, expected: &Schema{ Modules: []*Module{{ Name: "test", Decls: []Decl{ &Verb{ Name: "myIngress", - Request: &Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&String{}}}, + Request: &Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&String{}, &Unit{}, &Unit{}}}, Response: &Ref{Module: "builtin", Name: "HttpResponse", TypeParameters: []Type{&String{}, &String{}}}, }, }, @@ -324,7 +326,7 @@ func TestParsing(t *testing.T) { message String } - export verb echo(builtin.HttpRequest) builtin.HttpResponse + export verb echo(builtin.HttpRequest) builtin.HttpResponse +ingress http GET /echo +calls time.time @@ -338,7 +340,7 @@ func TestParsing(t *testing.T) { time Time } - export verb time(builtin.HttpRequest) builtin.HttpResponse + export verb time(builtin.HttpRequest) builtin.HttpResponse +ingress http GET /time } `, @@ -351,7 +353,7 @@ func TestParsing(t *testing.T) { &Verb{ Name: "echo", Export: true, - Request: &Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&Ref{Module: "echo", Name: "EchoRequest"}}}, + Request: &Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&Ref{Module: "echo", Name: "EchoRequest"}, &Unit{}, &Unit{}}}, Response: &Ref{Module: "builtin", Name: "HttpResponse", TypeParameters: []Type{&Ref{Module: "echo", Name: "EchoResponse"}, &String{}}}, Metadata: []Metadata{ &MetadataIngress{Type: "http", Method: "GET", Path: []IngressPathComponent{&IngressPathLiteral{Text: "echo"}}}, @@ -367,7 +369,7 @@ func TestParsing(t *testing.T) { &Verb{ Name: "time", Export: true, - Request: &Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&Ref{Module: "time", Name: "TimeRequest"}}}, + Request: &Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&Ref{Module: "time", Name: "TimeRequest"}, &Unit{}, &Unit{}}}, Response: &Ref{Module: "builtin", Name: "HttpResponse", TypeParameters: []Type{&Ref{Module: "time", Name: "TimeResponse"}, &String{}}}, Metadata: []Metadata{ &MetadataIngress{Type: "http", Method: "GET", Path: []IngressPathComponent{&IngressPathLiteral{Text: "time"}}}, @@ -833,7 +835,7 @@ module todo { } export verb create(todo.CreateRequest) todo.CreateResponse +calls todo.destroy +database calls todo.testdb - export verb destroy(builtin.HttpRequest) builtin.HttpResponse + export verb destroy(builtin.HttpRequest) builtin.HttpResponse +ingress http GET /todo/destroy/{name} verb scheduled(Unit) Unit +cron */10 * * 1-10,11-31 * * * @@ -944,7 +946,7 @@ var testSchema = MustValidate(&Schema{ }}, &Verb{Name: "destroy", Export: true, - Request: &Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&Ref{Module: "todo", Name: "DestroyRequest"}}}, + Request: &Ref{Module: "builtin", Name: "HttpRequest", TypeParameters: []Type{&Unit{}, &Ref{Module: "todo", Name: "DestroyRequest"}, &Unit{}}}, Response: &Ref{Module: "builtin", Name: "HttpResponse", TypeParameters: []Type{&Ref{Module: "todo", Name: "DestroyResponse"}, &String{}}}, Metadata: []Metadata{ &MetadataIngress{ diff --git a/backend/schema/validate.go b/backend/schema/validate.go index 3d22e978e7..a8d7636a40 100644 --- a/backend/schema/validate.go +++ b/backend/schema/validate.go @@ -547,24 +547,52 @@ func validateVerbMetadata(scopes Scopes, module *Module, n *Verb) (merr []error) switch md := md.(type) { case *MetadataIngress: - reqBodyType, reqBody, errs := validateIngressRequestOrResponse(scopes, module, n, "request", n.Request) + reqInfo, errs := validateIngressRequest(scopes, module, n, "request", n.Request) merr = append(merr, errs...) - _, _, errs = validateIngressRequestOrResponse(scopes, module, n, "response", n.Response) + errs = validateIngressResponse(scopes, module, n, "response", n.Response) merr = append(merr, errs...) - // Validate path - for _, path := range md.Path { - switch path := path.(type) { - case *IngressPathParameter: - reqBodyData, ok := reqBody.(*Data) + if reqInfo.pathParamSymbol != nil { + // If this is nil it has already failed validation + + hasParameters := false + // Validate path + for _, path := range md.Path { + switch path := path.(type) { + case *IngressPathParameter: + hasParameters = true + switch reqInfo.pathParamSymbol.(type) { + case *Data: + if reqInfo.pathParamSymbol.(*Data).FieldByName(path.Name) == nil { + merr = append(merr, errorf(path, "ingress verb %s: request pathParameter type %s does not contain a field corresponding to the parameter %q", n.Name, reqInfo.pathParamType, path.Name)) + } + case *Map: + // No validation for map, it is always fine + case *String, *Int, *Bool, *Float: + // Only valid for a single path parameter + count := 0 + for _, p := range md.Path { + switch p.(type) { + case *IngressPathParameter: + count++ + } + } + if count != 1 { + merr = append(merr, errorf(path, "ingress verb %s: cannot use path parameter %q with request type %s as it has multiple path parameters, expected Data or Map type", n.Name, path.Name, reqInfo.pathParamType)) + } + default: + merr = append(merr, errorf(path, "ingress verb %s: cannot use path parameter %q with request type %s, expected Data or Map type", n.Name, path.Name, reqInfo.pathParamType)) + } + case *IngressPathLiteral: + } + } + if !hasParameters { + _, ok := reqInfo.pathParamSymbol.(*Unit) if !ok { - merr = append(merr, errorf(path, "ingress verb %s: cannot use path parameter %q with request type %s, expected Data type", n.Name, path.Name, reqBodyType)) - } else if reqBodyData.FieldByName(path.Name) == nil { - merr = append(merr, errorf(path, "ingress verb %s: request type %s does not contain a field corresponding to the parameter %q", n.Name, reqBodyType, path.Name)) + merr = append(merr, errorf(reqInfo.pathParamSymbol, "ingress verb %s: cannot use path parameter type %s, expected Unit as ingress has no path parameters", n.Name, reqInfo.pathParamType)) } - - case *IngressPathLiteral: } + } case *MetadataCronJob: _, err := cron.Parse(md.Cron) @@ -613,7 +641,16 @@ func validateVerbMetadata(scopes Scopes, module *Module, n *Verb) (merr []error) return } -func validateIngressRequestOrResponse(scopes Scopes, module *Module, n *Verb, reqOrResp string, r Type) (fieldType Type, body Symbol, merr []error) { +type HttpRequestExtractedTypes struct { + fieldType Type + body Symbol + pathParamType Type + pathParamSymbol Symbol + queryParamType Type + queryParamSymbol Symbol +} + +func validateIngressResponse(scopes Scopes, module *Module, n *Verb, reqOrResp string, r Type) (merr []error) { data, err := resolveValidIngressReqResp(scopes, reqOrResp, optional.None[*ModuleDecl](), r, nil) if err != nil { merr = append(merr, errorf(r, "ingress verb %s: %s type %s: %v", n.Name, reqOrResp, r, err)) @@ -621,12 +658,50 @@ func validateIngressRequestOrResponse(scopes Scopes, module *Module, n *Verb, re } resp, ok := data.Get() if !ok { - merr = append(merr, errorf(r, "ingress verb %s: %s type %s must be builtin.HttpRequest", n.Name, reqOrResp, r)) + merr = append(merr, errorf(r, "ingress verb %s: %s type %s must be builtin.HttpResponse", n.Name, reqOrResp, r)) return } scopes = scopes.PushScope(resp.Scope()) - fieldType = resp.FieldByName("body").Type + + _, _, merr = validateParam(resp, "body", scopes, module, n, reqOrResp, r, validateBodyPayloadType) + return +} + +func validateIngressRequest(scopes Scopes, module *Module, n *Verb, reqOrResp string, r Type) (result HttpRequestExtractedTypes, merr []error) { + data, err := resolveValidIngressReqResp(scopes, reqOrResp, optional.None[*ModuleDecl](), r, nil) + if err != nil { + merr = append(merr, errorf(r, "ingress verb %s: %s type %s: %v", n.Name, reqOrResp, r, err)) + return + } + resp, ok := data.Get() + isRequest := reqOrResp == "request" + if !ok { + if isRequest { + merr = append(merr, errorf(r, "ingress verb %s: %s type %s must be builtin.HttpRequest", n.Name, reqOrResp, r)) + } else { + merr = append(merr, errorf(r, "ingress verb %s: %s type %s must be builtin.HttpResponse", n.Name, reqOrResp, r)) + } + return + } + + scopes = scopes.PushScope(resp.Scope()) + + var errs []error + result.fieldType, result.body, errs = validateParam(resp, "body", scopes, module, n, reqOrResp, r, validateBodyPayloadType) + merr = append(merr, errs...) + if isRequest { + result.pathParamType, result.pathParamSymbol, errs = validateParam(resp, "pathParameters", scopes, module, n, reqOrResp, r, validatePathParamsPayloadType) + merr = append(merr, errs...) + + result.queryParamType, result.queryParamSymbol, errs = validateParam(resp, "query", scopes, module, n, reqOrResp, r, validateQueryParamsPayloadType) + merr = append(merr, errs...) + } + return +} + +func validateParam(resp *Data, paramName string, scopes Scopes, module *Module, n *Verb, reqOrResp string, r Type, validationFunc func(Node, Type, *Verb, string) error) (fieldType Type, body Symbol, merr []error) { + fieldType = resp.FieldByName(paramName).Type if opt, ok := fieldType.(*Optional); ok { fieldType = opt.Type } @@ -643,7 +718,7 @@ func validateIngressRequestOrResponse(scopes Scopes, module *Module, n *Verb, re return } body = bodySym.Symbol - err = validatePayloadType(bodySym.Symbol, r, n, reqOrResp) + err := validationFunc(bodySym.Symbol, r, n, reqOrResp) if err != nil { merr = append(merr, err) } @@ -689,7 +764,7 @@ func resolveValidIngressReqResp(scopes Scopes, reqOrResp string, moduleDecl opti } } -func validatePayloadType(n Node, r Type, v *Verb, reqOrResp string) error { +func validateBodyPayloadType(n Node, r Type, v *Verb, reqOrResp string) error { switch t := n.(type) { case *Bytes, *String, *Data, *Unit, *Float, *Int, *Bool, *Map, *Array: // Valid HTTP response payload types. case *TypeAlias: @@ -697,7 +772,7 @@ func validatePayloadType(n Node, r Type, v *Verb, reqOrResp string) error { if len(t.Metadata) > 0 { return nil } - return validatePayloadType(t.Type, r, v, reqOrResp) + return validateBodyPayloadType(t.Type, r, v, reqOrResp) case *Enum: // Type enums are valid but value enums are not. if t.IsValueEnum() { @@ -709,6 +784,36 @@ func validatePayloadType(n Node, r Type, v *Verb, reqOrResp string) error { return nil } +func validatePathParamsPayloadType(n Node, r Type, v *Verb, reqOrResp string) error { + switch t := n.(type) { + case *String, *Data, *Unit, *Float, *Int, *Bool, *Map: // Valid HTTP param payload types. + case *TypeAlias: + // allow aliases of external types + if len(t.Metadata) > 0 { + return nil + } + return validatePathParamsPayloadType(t.Type, r, v, reqOrResp) + default: + return errorf(r, "ingress verb %s: %s type %s must have a param of data structure, unit or map not %s", v.Name, reqOrResp, r, n) + } + return nil +} + +func validateQueryParamsPayloadType(n Node, r Type, v *Verb, reqOrResp string) error { + switch t := n.(type) { + case *Data, *Unit, *Map: // Valid HTTP query payload types. + case *TypeAlias: + // allow aliases of external types + if len(t.Metadata) > 0 { + return nil + } + return validateQueryParamsPayloadType(t.Type, r, v, reqOrResp) + default: + return errorf(r, "ingress verb %s: %s type %s must have a param of data structure, unit or map not %s", v.Name, reqOrResp, r, n) + } + return nil +} + func validateVerbSubscriptions(module *Module, v *Verb, md *MetadataSubscriber, scopes Scopes, schema optional.Option[*Schema]) (merr []error) { merr = []error{} var subscription *Subscription diff --git a/backend/schema/validate_test.go b/backend/schema/validate_test.go index fd8215bfda..5110ad8029 100644 --- a/backend/schema/validate_test.go +++ b/backend/schema/validate_test.go @@ -92,7 +92,7 @@ func TestValidate(t *testing.T) { {name: "ValidIngressRequestType", schema: ` module one { - export verb a(HttpRequest) HttpResponse + export verb a(HttpRequest) HttpResponse +ingress http GET /a } `}, @@ -105,26 +105,28 @@ func TestValidate(t *testing.T) { `, errs: []string{ "3:20-20: ingress verb a: request type Empty must be builtin.HttpRequest", - "3:27-27: ingress verb a: response type Empty must be builtin.HttpRequest", + "3:27-27: ingress verb a: response type Empty must be builtin.HttpResponse", }}, {name: "IngressBodyTypes", schema: ` module one { - export verb bytes(HttpRequest) HttpResponse + export verb bytes(HttpRequest) HttpResponse +ingress http GET /bytes - export verb string(HttpRequest) HttpResponse + export verb string(HttpRequest) HttpResponse +ingress http GET /string - export verb data(HttpRequest) HttpResponse + export verb data(HttpRequest) HttpResponse +ingress http GET /data // Invalid types. - export verb any(HttpRequest) HttpResponse + export verb any(HttpRequest) HttpResponse +ingress http GET /any - export verb path(HttpRequest) HttpResponse + export verb path(HttpRequest) HttpResponse +ingress http GET /path/{invalid} - export verb pathMissing(HttpRequest) HttpResponse + export verb pathInvalid(HttpRequest) HttpResponse + +ingress http GET /path/{invalid}/{extra} + export verb pathMissing(HttpRequest) HttpResponse +ingress http GET /path/{missing} - export verb pathFound(HttpRequest) HttpResponse + export verb pathFound(HttpRequest) HttpResponse +ingress http GET /path/{parameter} // Data comment @@ -134,18 +136,19 @@ func TestValidate(t *testing.T) { } `, errs: []string{ - "11:22-22: ingress verb any: request type HttpRequest must have a body of bytes, string, data structure, unit, float, int, bool, map, or array not Any", - "11:40-40: ingress verb any: response type HttpResponse must have a body of bytes, string, data structure, unit, float, int, bool, map, or array not Any", - "14:31-31: ingress verb path: cannot use path parameter \"invalid\" with request type String, expected Data type", - "16:31-31: ingress verb pathMissing: request type one.Path does not contain a field corresponding to the parameter \"missing\"", - "16:7-7: duplicate http ingress GET /path/{} for 17:6:\"pathFound\" and 15:6:\"pathMissing\"", - "18:7-7: duplicate http ingress GET /path/{} for 13:6:\"path\" and 17:6:\"pathFound\"", + "11:22-22: ingress verb any: request type HttpRequest must have a body of bytes, string, data structure, unit, float, int, bool, map, or array not Any", + "11:52-52: ingress verb any: response type HttpResponse must have a body of bytes, string, data structure, unit, float, int, bool, map, or array not Any", + "16:31-31: ingress verb pathInvalid: cannot use path parameter \"invalid\" with request type String as it has multiple path parameters, expected Data or Map type", + "16:41-41: ingress verb pathInvalid: cannot use path parameter \"extra\" with request type String as it has multiple path parameters, expected Data or Map type", + "18:31-31: ingress verb pathMissing: request pathParameter type one.Path does not contain a field corresponding to the parameter \"missing\"", + "18:7-7: duplicate http ingress GET /path/{} for 19:6:\"pathFound\" and 17:6:\"pathMissing\"", + "20:7-7: duplicate http ingress GET /path/{} for 13:6:\"path\" and 19:6:\"pathFound\"", }}, {name: "Array", schema: ` module one { data Data {} - export verb one(HttpRequest<[one.Data]>) HttpResponse<[one.Data], Empty> + export verb one(HttpRequest<[one.Data],Unit, Unit>) HttpResponse<[one.Data], Empty> +ingress http GET /one } `, @@ -166,7 +169,7 @@ func TestValidate(t *testing.T) { schema: ` module one { data Data {} - export verb one(HttpRequest<[one.Data]>) HttpResponse<[one.Data], Empty> + export verb one(HttpRequest<[one.Data], Unit, Unit>) HttpResponse<[one.Data], Empty> +ingress http GET /one +ingress http GET /two } @@ -195,7 +198,7 @@ func TestValidate(t *testing.T) { export data Data {} } module one { - export verb a(HttpRequest) HttpResponse + export verb a(HttpRequest) HttpResponse +ingress http GET /a } `, diff --git a/docs/content/docs/reference/types.md b/docs/content/docs/reference/types.md index 1971df7585..1c7236bcd2 100644 --- a/docs/content/docs/reference/types.md +++ b/docs/content/docs/reference/types.md @@ -128,7 +128,7 @@ The `Unit` type is similar to the `void` type in other languages. It is used to ```go //ftl:ingress GET /unit -func Unit(ctx context.Context, req builtin.HttpRequest[TimeRequest]) (builtin.HttpResponse[ftl.Unit, string], error) { +func Unit(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, TimeRequest]) (builtin.HttpResponse[ftl.Unit, string], error) { return builtin.HttpResponse[ftl.Unit, string]{Body: ftl.Some(ftl.Unit{})}, nil } ``` @@ -149,7 +149,7 @@ Content-Length: 0 FTL provides a set of builtin types that are automatically available in all FTL runtimes. These types are: -- `builtin.HttpRequest[Body]` - Represents an HTTP request with a body of type `Body`. +- `builtin.HttpRequest[Body, PathParams, QueryParams]` - Represents an HTTP request with a body of type `Body`, path parameter type of `PathParams` and a query parameter type of `QueryParams`. - `builtin.HttpResponse[Body, Error]` - Represents an HTTP response with a body of type `Body` and an error of type `Error`. - `builtin.Empty` - Represents an empty type. This equates to an empty structure `{}`. - `builtin.CatchRequest` - Represents a request structure for catch verbs. diff --git a/go-runtime/compile/testdata/go/one/one.go b/go-runtime/compile/testdata/go/one/one.go index 9195a76633..2e07d9466d 100644 --- a/go-runtime/compile/testdata/go/one/one.go +++ b/go-runtime/compile/testdata/go/one/one.go @@ -164,7 +164,7 @@ func Nothing(ctx context.Context) error { } //ftl:ingress http GET /get -func Http(ctx context.Context, req builtin.HttpRequest[Req]) (builtin.HttpResponse[Resp, ftl.Unit], error) { +func Http(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, Req]) (builtin.HttpResponse[Resp, ftl.Unit], error) { return builtin.HttpResponse[Resp, ftl.Unit]{}, nil } diff --git a/go-runtime/encoding/testdata/go/omitempty/omitempty.go b/go-runtime/encoding/testdata/go/omitempty/omitempty.go index 5685c54f95..1db508fd8f 100644 --- a/go-runtime/encoding/testdata/go/omitempty/omitempty.go +++ b/go-runtime/encoding/testdata/go/omitempty/omitempty.go @@ -8,15 +8,13 @@ import ( "github.com/TBD54566975/ftl/go-runtime/ftl" // Import the FTL SDK. ) -type Request struct{} - type Response struct { Error string `json:"error,omitempty"` // Should be omitted from marshaled JSON MustSet string `json:"mustset"` // Should marshal to `"mustset":""` } //ftl:ingress http GET /get -func Get(ctx context.Context, req builtin.HttpRequest[Request]) (builtin.HttpResponse[Response, string], error) { +func Get(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[Response, string], error) { return builtin.HttpResponse[Response, string]{ Headers: map[string][]string{"Get": {"Header from FTL"}}, Body: ftl.Some[Response](Response{}), diff --git a/go-runtime/ftl/testdata/go/typeregistry/typeregistry.go b/go-runtime/ftl/testdata/go/typeregistry/typeregistry.go index f0d6dfd5b0..1c85053029 100644 --- a/go-runtime/ftl/testdata/go/typeregistry/typeregistry.go +++ b/go-runtime/ftl/testdata/go/typeregistry/typeregistry.go @@ -2,7 +2,9 @@ package typeregistry import ( "context" + "ftl/builtin" + "ftl/typeregistry/subpackage" "github.com/TBD54566975/ftl/go-runtime/ftl" // Import the FTL SDK. @@ -17,7 +19,7 @@ type EchoResponse struct { } //ftl:ingress POST /echo -func Echo(ctx context.Context, req builtin.HttpRequest[EchoRequest]) (builtin.HttpResponse[EchoResponse, string], error) { +func Echo(ctx context.Context, req builtin.HttpRequest[EchoRequest, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[EchoResponse, string], error) { return builtin.HttpResponse[EchoResponse, string]{ Body: ftl.Some(EchoResponse{Strings: req.Body.Strings}), }, nil diff --git a/go-runtime/ftl/testdata/go/typeregistry/typeregistry_test.go b/go-runtime/ftl/testdata/go/typeregistry/typeregistry_test.go index 0d7878ff90..48ebdbf7c7 100644 --- a/go-runtime/ftl/testdata/go/typeregistry/typeregistry_test.go +++ b/go-runtime/ftl/testdata/go/typeregistry/typeregistry_test.go @@ -1,14 +1,17 @@ package typeregistry import ( + "testing" + "ftl/builtin" + "ftl/typeregistry/subpackage" - "testing" + + "github.com/alecthomas/assert/v2" "github.com/TBD54566975/ftl/go-runtime/encoding" "github.com/TBD54566975/ftl/go-runtime/ftl" "github.com/TBD54566975/ftl/go-runtime/ftl/ftltest" - "github.com/alecthomas/assert/v2" ) func TestIngress(t *testing.T) { @@ -34,7 +37,7 @@ func TestIngress(t *testing.T) { for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { - resp, err := ftl.Call(ctx, Echo, builtin.HttpRequest[EchoRequest]{ + resp, err := ftl.Call(ctx, Echo, builtin.HttpRequest[EchoRequest, ftl.Unit, ftl.Unit]{ Body: EchoRequest{Strings: test.Input}, }) assert.NoError(t, err) diff --git a/go-runtime/schema/schema_test.go b/go-runtime/schema/schema_test.go index 4bf10ab750..72b4fa0f59 100644 --- a/go-runtime/schema/schema_test.go +++ b/go-runtime/schema/schema_test.go @@ -8,10 +8,11 @@ import ( "strings" "testing" - "github.com/TBD54566975/ftl/go-runtime/schema/common" "github.com/alecthomas/assert/v2" "github.com/alecthomas/participle/v2/lexer" + "github.com/TBD54566975/ftl/go-runtime/schema/common" + "github.com/TBD54566975/ftl/backend/schema" "github.com/TBD54566975/ftl/internal/errors" "github.com/TBD54566975/ftl/internal/exec" @@ -162,7 +163,7 @@ func TestExtractModuleSchema(t *testing.T) { verb batchStringToTime([String]) [Time] - export verb http(builtin.HttpRequest) builtin.HttpResponse + export verb http(builtin.HttpRequest) builtin.HttpResponse +ingress http GET /get export verb nothing(Unit) Unit @@ -276,7 +277,7 @@ func TestExtractModuleSchemaTwo(t *testing.T) { export verb callsTwoAndThree(two.Payload) two.Payload +calls two.three, two.two - export verb ingress(builtin.HttpRequest) builtin.HttpResponse + export verb ingress(builtin.HttpRequest) builtin.HttpResponse +ingress http POST /users +encoding json lenient diff --git a/go-runtime/schema/testdata/one/one.go b/go-runtime/schema/testdata/one/one.go index 9195a76633..56f02c0116 100644 --- a/go-runtime/schema/testdata/one/one.go +++ b/go-runtime/schema/testdata/one/one.go @@ -4,9 +4,10 @@ import ( "context" "time" - "ftl/builtin" "ftl/two" + "ftl/builtin" + "github.com/TBD54566975/ftl/go-runtime/ftl" ) @@ -164,7 +165,7 @@ func Nothing(ctx context.Context) error { } //ftl:ingress http GET /get -func Http(ctx context.Context, req builtin.HttpRequest[Req]) (builtin.HttpResponse[Resp, ftl.Unit], error) { +func Http(ctx context.Context, req builtin.HttpRequest[ftl.Unit, ftl.Unit, Req]) (builtin.HttpResponse[Resp, ftl.Unit], error) { return builtin.HttpResponse[Resp, ftl.Unit]{}, nil } diff --git a/go-runtime/schema/testdata/two/two.go b/go-runtime/schema/testdata/two/two.go index 108b6b4a74..9abf46a715 100644 --- a/go-runtime/schema/testdata/two/two.go +++ b/go-runtime/schema/testdata/two/two.go @@ -148,7 +148,7 @@ type PostResponse struct { //ftl:ingress http POST /users //ftl:encoding lenient -func Ingress(ctx context.Context, req builtin.HttpRequest[PostRequest]) (builtin.HttpResponse[PostResponse, string], error) { +func Ingress(ctx context.Context, req builtin.HttpRequest[PostRequest, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[PostResponse, string], error) { return builtin.HttpResponse[PostResponse, string]{ Status: 201, Headers: map[string][]string{"Post": {"Header from FTL"}}, diff --git a/internal/lsp/markdown/completion/ingress.md b/internal/lsp/markdown/completion/ingress.md index f7b24af54e..7b84430ee3 100644 --- a/internal/lsp/markdown/completion/ingress.md +++ b/internal/lsp/markdown/completion/ingress.md @@ -3,8 +3,11 @@ Declare an ingress function. Verbs annotated with `ftl:ingress` will be exposed via HTTP (http is the default ingress type). These endpoints will then be available on one of our default ingress ports (local development defaults to http://localhost:8891). ```go -type GetRequest struct { +type GetPathParams struct { UserID string `json:"userId"` +} + +type GetQueryParams struct { PostID string `json:"postId"` } @@ -13,7 +16,7 @@ type GetResponse struct { } //ftl:ingress GET /http/users/{userId}/posts -func Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, string], error) { +func Get(ctx context.Context, req builtin.HttpRequest[ftl.Unit, GetPathParams, GetQueryParams]) (builtin.HttpResponse[GetResponse, string], error) { return builtin.HttpResponse[GetResponse, string]{ Status: 200, Body: ftl.Some(GetResponse{}), @@ -31,7 +34,7 @@ type ${1:Func}Response struct { } //ftl:ingress ${2:GET} ${3:/url/path} -func ${1:Func}(ctx context.Context, req builtin.HttpRequest[${1:Func}Request]) (builtin.HttpResponse[${1:Func}Response, string], error) { +func ${1:Func}(ctx context.Context, req builtin.HttpRequest[ftl.Unit, flt.Unit, ${1:Func}Request]) (builtin.HttpResponse[${1:Func}Response, string], error) { ${4:// TODO: Implement} return builtin.HttpResponse[${1:Func}Response, string]{ Status: 200,