From c5c65fbfa10d55dc16c17472996835f5576da90c Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Sat, 17 Aug 2024 15:46:24 +1000 Subject: [PATCH] feat: HttpRequest path and query mapping fixes: #2230 tmp: JVM HTTP tests --- backend/controller/console/console_test.go | 15 +- .../console/testdata/go/console/console.go | 4 +- backend/controller/ingress/handler_test.go | 8 +- backend/controller/ingress/ingress.go | 6 +- .../ingress/ingress_integration_test.go | 18 +- backend/controller/ingress/request.go | 128 +++++++++----- backend/controller/ingress/request_test.go | 89 +++++++--- .../testdata/go/httpingress/httpingress.go | 65 ++++--- backend/schema/builtin.go | 6 +- backend/schema/schema_test.go | 20 ++- backend/schema/validate.go | 158 +++++++++++++++--- backend/schema/validate_test.go | 61 ++++--- docs/content/docs/reference/ingress.md | 64 ++++--- docs/content/docs/reference/types.md | 4 +- go-runtime/compile/testdata/go/one/one.go | 2 +- .../testdata/go/omitempty/omitempty.go | 4 +- .../testdata/go/typeregistry/typeregistry.go | 4 +- .../go/typeregistry/typeregistry_test.go | 9 +- go-runtime/schema/schema_test.go | 7 +- go-runtime/schema/testdata/one/one.go | 5 +- go-runtime/schema/testdata/two/two.go | 2 +- internal/lsp/hoveritems.go | 2 +- internal/lsp/markdown/completion/ingress.md | 9 +- .../block/ftl/deployment/FtlProcessor.java | 14 +- 24 files changed, 478 insertions(+), 226 deletions(-) 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..635c119947 100644 --- a/backend/controller/console/testdata/go/console/console.go +++ b/backend/controller/console/testdata/go/console/console.go @@ -17,10 +17,10 @@ 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), + Message: fmt.Sprintf("Hello, %s", req.Query.Message), }), }, nil } 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..27e067cf8b 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,10 @@ func BuildRequestBody(route *dal.IngressRoute, r *http.Request, sch *schema.Sche queryMap[key] = valuesAny } + finalQueryParams, err := mangleQueryParameters(queryMap, r.URL.Query(), request, sch) + if err != nil { + return nil, err + } headerMap := make(map[string]any) for key, values := range r.Header { valuesAny := make([]any, len(values)) @@ -69,15 +77,11 @@ 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) } requestMap, err = schema.TransformFromAliasedFields(request, sch, requestMap) @@ -102,15 +106,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 +126,76 @@ 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 + default: + } + // 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, nil + 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, nil + 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 + } + + if m, ok := paramsField.Type.(*schema.Map); ok { + if _, ok := m.Value.(*schema.Array); ok { + 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 +277,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 +286,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..c4ea169be2 100644 --- a/backend/schema/validate.go +++ b/backend/schema/validate.go @@ -3,6 +3,7 @@ package schema import ( "fmt" + "net/http" "reflect" "regexp" "slices" @@ -547,24 +548,53 @@ 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, md.Method == http.MethodGet) 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 !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)) + 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 dataType := reqInfo.pathParamSymbol.(type) { + case *Data: + if dataType.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 { + if _, ok := p.(*IngressPathParameter); ok { + 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 { + // We still allow map even with no path parameters + switch reqInfo.pathParamSymbol.(type) { + case *Unit, *Map: + default: + merr = append(merr, errorf(reqInfo.pathParamSymbol, "ingress verb %s: cannot use path parameter type %s, expected Unit or Map as ingress has no path parameters", n.Name, reqInfo.pathParamType)) } - - case *IngressPathLiteral: } + } case *MetadataCronJob: _, err := cron.Parse(md.Cron) @@ -613,20 +643,72 @@ 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)) + return + } + resp, ok := data.Get() + if !ok { + 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()) + + _, _, merr = validateParam(resp, "body", scopes, module, n, reqOrResp, r, validateBodyPayloadType) + return +} + +func validateIngressRequest(scopes Scopes, module *Module, n *Verb, reqOrResp string, r Type, getRequest bool) (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 { - merr = append(merr, errorf(r, "ingress verb %s: %s type %s must be builtin.HttpRequest", n.Name, reqOrResp, r)) + 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()) - fieldType = resp.FieldByName("body").Type + + var errs []error + if getRequest { + result.fieldType, result.body, errs = validateParam(resp, "body", scopes, module, n, reqOrResp, r, requireUnitPayloadType) + merr = append(merr, errs...) + } else { + 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 +725,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 +771,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 +779,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 +791,44 @@ func validatePayloadType(n Node, r Type, v *Verb, reqOrResp string) error { return nil } +func requireUnitPayloadType(n Node, r Type, v *Verb, reqOrResp string) error { + if _, ok := n.(*Unit); !ok { + return errorf(r, "ingress verb %s: GET request type %s must have a body of unit not %s", v.Name, r, n) + + } + 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..16af1ff2d0 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 - +ingress http GET /bytes - export verb string(HttpRequest) HttpResponse - +ingress http GET /string - export verb data(HttpRequest) HttpResponse - +ingress http GET /data + export verb bytes(HttpRequest) HttpResponse + +ingress http POST /bytes + export verb string(HttpRequest) HttpResponse + +ingress http POST /string + export verb data(HttpRequest) HttpResponse + +ingress http POST /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,19 +136,30 @@ 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: GET request type HttpRequest must have a body of unit 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: "GetRequestWithBody", + schema: ` + module one { + export verb bytes(HttpRequest) HttpResponse + +ingress http GET /bytes + } + `, + errs: []string{ + "3:24-24: ingress verb bytes: GET request type HttpRequest must have a body of unit not Bytes", }}, {name: "Array", schema: ` module one { data Data {} - export verb one(HttpRequest<[one.Data]>) HttpResponse<[one.Data], Empty> - +ingress http GET /one + export verb one(HttpRequest<[one.Data],Unit, Unit>) HttpResponse<[one.Data], Empty> + +ingress http POST /one } `, }, @@ -166,9 +179,9 @@ func TestValidate(t *testing.T) { schema: ` module one { data Data {} - export verb one(HttpRequest<[one.Data]>) HttpResponse<[one.Data], Empty> - +ingress http GET /one - +ingress http GET /two + export verb one(HttpRequest<[one.Data], Unit, Unit>) HttpResponse<[one.Data], Empty> + +ingress http POST /one + +ingress http POST /two } `, errs: []string{ @@ -195,7 +208,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/ingress.md b/docs/content/docs/reference/ingress.md index ef69ab4de7..1ee814cfed 100644 --- a/docs/content/docs/reference/ingress.md +++ b/docs/content/docs/reference/ingress.md @@ -18,9 +18,12 @@ Verbs annotated with `ftl:ingress` will be exposed via HTTP (`http` is the defau The following will be available at `http://localhost:8891/http/users/123/posts?postId=456`. ```go -type GetRequest struct { +type GetRequestPathParams struct { UserID string `json:"userId"` - PostID string `json:"postId"` +} + +type GetRequestQueryParams struct { + PostID string `json:"postId"` } type GetResponse struct { @@ -28,7 +31,17 @@ type GetResponse struct { } //ftl:ingress GET /http/users/{userId}/posts -func Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, ErrorResponse], error) { +func Get(ctx context.Context, req builtin.HttpRequest[ftl.Unit, GetRequestPathParams, GetRequestQueryParams]) (builtin.HttpResponse[GetResponse, ErrorResponse], error) { + // ... +} +``` + +Because the example above only has a single path parameter it can be simplified by just using a scalar such as `string` or `int64` as the path parameter type: + +```go + +//ftl:ingress GET /http/users/{userId}/posts +func Get(ctx context.Context, req builtin.HttpRequest[ftl.Unit, int64, GetRequestQueryParams]) (builtin.HttpResponse[GetResponse, ErrorResponse], error) { // ... } ``` @@ -44,45 +57,42 @@ Key points: ## Field mapping +The `HttpRequest` request object takes 3 type parameters, the body, the path parameters and the query parameters. + Given the following request verb: ```go -type GetRequest struct { + +type PostBody struct{ + Title string `json:"title"` + Content string `json:"content"` + Tag ftl.Option[string] `json:"tag"` +} +type PostPathParams struct { UserID string `json:"userId"` - Tag ftl.Option[string] `json:"tag"` PostID string `json:"postId"` } -type GetResponse struct { - Message string `json:"msg"` +type PostQueryParams struct { + Publish boolean `json:"publish"` } -//ftl:ingress http GET /users/{userId}/posts/{postId} -func Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, string], error) { +//ftl:ingress http PUT /users/{userId}/posts/{postId} +func Get(ctx context.Context, req builtin.HttpRequest[PostBody, PostPathParams, PostQueryParams]) (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, Tag: %s", req.Body.UserID, req.Body.PostID, req.Body.Tag.Default("none")), + Message: fmt.Sprintf("UserID: %s, PostID: %s, Tag: %s", req.pathParameters.UserID, req.pathParameters.PostID, req.Body.Tag.Default("none")), }), }, nil } ``` -`path`, `query`, and `body` parameters are automatically mapped to the `req` structure. - -For example, this curl request will map `userId` to `req.Body.UserID` and `postId` to `req.Body.PostID`, and `tag` to `req.Body.Tag`: - -```sh -curl -i http://localhost:8891/users/123/posts/456?tag=ftl -``` - -The response here will be: +The rules for how each element is mapped are slightly different, as they have a different structure: -```json -{ - "msg": "UserID: 123, PostID: 456, Tag: ftl" -} -``` +- The body is mapped directly to the body of the request, generally as a JSON object. Scalars are also supported, as well as []byte to get the raw body. If they type is `any` then it will be assumed to be JSON and mapped to the appropriate types based on the JSON structure. +- The path parameters can be mapped directly to an object with field names corresponding to the name of the path parameter. If there is only a single path parameter it can be injected directly as a scalar. They can also be injected as a `map[string]string`. +- The path parameters can also be mapped directly to an object with field names corresponding to the name of the path parameter. They can also be injected directly as a `map[string]string`, or `map[string][]string` for multiple values. #### Optional fields @@ -122,8 +132,8 @@ type B []string func (B) tag() {} -//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 } ``` @@ -131,7 +141,7 @@ func TypeEnum(ctx context.Context, req builtin.HttpRequest[SumType]) (builtin.Ht The following curl request will map the `SumType` name and value to the `req.Body`: ```sh -curl -X GET "http://localhost:8891/typeenum" \ +curl -X POST "http://localhost:8891/typeenum" \ -H "Content-Type: application/json" \ --data '{"name": "A", "value": "sample"}' ``` 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/hoveritems.go b/internal/lsp/hoveritems.go index bd6215c9c0..d5a984c8f2 100644 --- a/internal/lsp/hoveritems.go +++ b/internal/lsp/hoveritems.go @@ -4,7 +4,7 @@ package lsp var hoverMap = map[string]string{ "//ftl:cron": "## Cron\n\nA cron job is an Empty verb that will be called on a schedule. The syntax is described [here](https://pubs.opengroup.org/onlinepubs/9699919799.2018edition/utilities/crontab.html).\n\nYou can also use a shorthand syntax for the cron job, supporting seconds (`s`), minutes (`m`), hours (`h`), and specific days of the week (e.g. `Mon`).\n\n### Examples\n\nThe following function will be called hourly:\n\n```go\n//ftl:cron 0 * * * *\nfunc Hourly(ctx context.Context) error {\n // ...\n}\n```\n\nEvery 12 hours, starting at UTC midnight:\n\n```go\n//ftl:cron 12h\nfunc TwiceADay(ctx context.Context) error {\n // ...\n}\n```\n\nEvery Monday at UTC midnight:\n\n```go\n//ftl:cron Mon\nfunc Mondays(ctx context.Context) error {\n // ...\n}\n```\n\n", "//ftl:enum": "## Type enums (sum types)\n\n[Sum types](https://en.wikipedia.org/wiki/Tagged_union) are supported by FTL's type system, but aren't directly supported by Go. However they can be approximated with the use of [sealed interfaces](https://blog.chewxy.com/2018/03/18/golang-interfaces/). To declare a sum type in FTL use the comment directive `//ftl:enum`:\n\n```go\n//ftl:enum\ntype Animal interface { animal() }\n\ntype Cat struct {}\nfunc (Cat) animal() {}\n\ntype Dog struct {}\nfunc (Dog) animal() {}\n```\n## Value enums\n\nA value enum is an enumerated set of string or integer values.\n\n```go\n//ftl:enum\ntype Colour string\n\nconst (\n Red Colour = \"red\"\n Green Colour = \"green\"\n Blue Colour = \"blue\"\n)\n```\n", - "//ftl:ingress": "## HTTP Ingress\n\nVerbs 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`).\n\nThe following will be available at `http://localhost:8891/http/users/123/posts?postId=456`.\n\n```go\ntype GetRequest struct {\n\tUserID string `json:\"userId\"`\n\tPostID string `json:\"postId\"`\n}\n\ntype GetResponse struct {\n\tMessage string `json:\"msg\"`\n}\n\n//ftl:ingress GET /http/users/{userId}/posts\nfunc Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, ErrorResponse], error) {\n // ...\n}\n```\n\n> **NOTE!**\n> The `req` and `resp` types of HTTP `ingress` [verbs](../verbs) must be `builtin.HttpRequest` and `builtin.HttpResponse` respectively. These types provide the necessary fields for HTTP `ingress` (`headers`, `statusCode`, etc.)\n>\n> You will need to import `ftl/builtin`.\n\nKey points:\n\n- `ingress` verbs will be automatically exported by default.\n\n## Field mapping\n\nGiven the following request verb:\n\n```go\ntype GetRequest struct {\n\tUserID string `json:\"userId\"`\n\tTag ftl.Option[string] `json:\"tag\"`\n\tPostID string `json:\"postId\"`\n}\n\ntype GetResponse struct {\n\tMessage string `json:\"msg\"`\n}\n\n//ftl:ingress http GET /users/{userId}/posts/{postId}\nfunc Get(ctx context.Context, req builtin.HttpRequest[GetRequest]) (builtin.HttpResponse[GetResponse, string], error) {\n\treturn builtin.HttpResponse[GetResponse, string]{\n\t\tHeaders: map[string][]string{\"Get\": {\"Header from FTL\"}},\n\t\tBody: ftl.Some(GetResponse{\n\t\t\tMessage: fmt.Sprintf(\"UserID: %s, PostID: %s, Tag: %s\", req.Body.UserID, req.Body.PostID, req.Body.Tag.Default(\"none\")),\n\t\t}),\n\t}, nil\n}\n```\n\n`path`, `query`, and `body` parameters are automatically mapped to the `req` structure.\n\nFor example, this curl request will map `userId` to `req.Body.UserID` and `postId` to `req.Body.PostID`, and `tag` to `req.Body.Tag`:\n\n```sh\ncurl -i http://localhost:8891/users/123/posts/456?tag=ftl\n```\n\nThe response here will be:\n\n```json\n{\n \"msg\": \"UserID: 123, PostID: 456, Tag: ftl\"\n}\n```\n\n#### Optional fields\n\nOptional fields are represented by the `ftl.Option` type. The `Option` type is a wrapper around the actual type and can be `Some` or `None`. In the example above, the `Tag` field is optional.\n\n```sh\ncurl -i http://localhost:8891/users/123/posts/456\n```\n\nBecause the `tag` query parameter is not provided, the response will be:\n\n```json\n{\n \"msg\": \"UserID: 123, PostID: 456, Tag: none\"\n}\n```\n\n#### Casing\n\nField names use lowerCamelCase by default. You can override this by using the `json` tag.\n\n## SumTypes\n\nGiven the following request verb:\n\n```go\n//ftl:enum export\ntype SumType interface {\n\ttag()\n}\n\ntype A string\n\nfunc (A) tag() {}\n\ntype B []string\n\nfunc (B) tag() {}\n\n//ftl:ingress http GET /typeenum\nfunc TypeEnum(ctx context.Context, req builtin.HttpRequest[SumType]) (builtin.HttpResponse[SumType, string], error) {\n\treturn builtin.HttpResponse[SumType, string]{Body: ftl.Some(req.Body)}, nil\n}\n```\n\nThe following curl request will map the `SumType` name and value to the `req.Body`:\n\n```sh\ncurl -X GET \"http://localhost:8891/typeenum\" \\\n -H \"Content-Type: application/json\" \\\n --data '{\"name\": \"A\", \"value\": \"sample\"}'\n```\n\nThe response will be:\n\n```json\n{\n \"name\": \"A\",\n \"value\": \"sample\"\n}\n```\n\n## Encoding query params as JSON\n\nComplex query params can also be encoded as JSON using the `@json` query parameter. For example:\n\n> `{\"tag\":\"ftl\"}` url-encoded is `%7B%22tag%22%3A%22ftl%22%7D`\n\n```bash\ncurl -i http://localhost:8891/users/123/posts/456?@json=%7B%22tag%22%3A%22ftl%22%7D\n```\n", + "//ftl:ingress": "## HTTP Ingress\n\nVerbs 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`).\n\nThe following will be available at `http://localhost:8891/http/users/123/posts?postId=456`.\n\n```go\ntype GetRequestPathParams struct {\n\tUserID string `json:\"userId\"`\n}\n\ntype GetRequestQueryParams struct {\n PostID string `json:\"postId\"`\n}\n\ntype GetResponse struct {\n\tMessage string `json:\"msg\"`\n}\n\n//ftl:ingress GET /http/users/{userId}/posts\nfunc Get(ctx context.Context, req builtin.HttpRequest[ftl.Unit, GetRequestPathParams, GetRequestQueryParams]) (builtin.HttpResponse[GetResponse, ErrorResponse], error) {\n // ...\n}\n```\n\nBecause the example above only has a single path parameter it can be simplified by just using a scalar such as `string` or `int64` as the path parameter type:\n\n```go\n\n//ftl:ingress GET /http/users/{userId}/posts\nfunc Get(ctx context.Context, req builtin.HttpRequest[ftl.Unit, int64, GetRequestQueryParams]) (builtin.HttpResponse[GetResponse, ErrorResponse], error) {\n // ...\n}\n```\n\n> **NOTE!**\n> The `req` and `resp` types of HTTP `ingress` [verbs](../verbs) must be `builtin.HttpRequest` and `builtin.HttpResponse` respectively. These types provide the necessary fields for HTTP `ingress` (`headers`, `statusCode`, etc.)\n>\n> You will need to import `ftl/builtin`.\n\nKey points:\n\n- `ingress` verbs will be automatically exported by default.\n\n## Field mapping\n\nThe `HttpRequest` request object takes 3 type parameters, the body, the path parameters and the query parameters.\n\nGiven the following request verb:\n\n```go\n\ntype PostBody struct{\n Title string `json:\"title\"`\n\tContent string `json:\"content\"`\n Tag ftl.Option[string] `json:\"tag\"`\n}\ntype PostPathParams struct {\n\tUserID string `json:\"userId\"`\n\tPostID string `json:\"postId\"`\n}\n\ntype PostQueryParams struct {\n\tPublish boolean `json:\"publish\"`\n}\n\n//ftl:ingress http PUT /users/{userId}/posts/{postId}\nfunc Get(ctx context.Context, req builtin.HttpRequest[PostBody, PostPathParams, PostQueryParams]) (builtin.HttpResponse[GetResponse, string], error) {\n\treturn builtin.HttpResponse[GetResponse, string]{\n\t\tHeaders: map[string][]string{\"Get\": {\"Header from FTL\"}},\n\t\tBody: ftl.Some(GetResponse{\n\t\t\tMessage: fmt.Sprintf(\"UserID: %s, PostID: %s, Tag: %s\", req.pathParameters.UserID, req.pathParameters.PostID, req.Body.Tag.Default(\"none\")),\n\t\t}),\n\t}, nil\n}\n```\n\nThe rules for how each element is mapped are slightly different, as they have a different structure:\n\n- The body is mapped directly to the body of the request, generally as a JSON object. Scalars are also supported, as well as []byte to get the raw body. If they type is `any` then it will be assumed to be JSON and mapped to the appropriate types based on the JSON structure.\n- The path parameters can be mapped directly to an object with field names corresponding to the name of the path parameter. If there is only a single path parameter it can be injected directly as a scalar. They can also be injected as a `map[string]string`.\n- The path parameters can also be mapped directly to an object with field names corresponding to the name of the path parameter. They can also be injected directly as a `map[string]string`, or `map[string][]string` for multiple values.\n\n#### Optional fields\n\nOptional fields are represented by the `ftl.Option` type. The `Option` type is a wrapper around the actual type and can be `Some` or `None`. In the example above, the `Tag` field is optional.\n\n```sh\ncurl -i http://localhost:8891/users/123/posts/456\n```\n\nBecause the `tag` query parameter is not provided, the response will be:\n\n```json\n{\n \"msg\": \"UserID: 123, PostID: 456, Tag: none\"\n}\n```\n\n#### Casing\n\nField names use lowerCamelCase by default. You can override this by using the `json` tag.\n\n## SumTypes\n\nGiven the following request verb:\n\n```go\n//ftl:enum export\ntype SumType interface {\n\ttag()\n}\n\ntype A string\n\nfunc (A) tag() {}\n\ntype B []string\n\nfunc (B) tag() {}\n\n//ftl:ingress http POST /typeenum\nfunc TypeEnum(ctx context.Context, req builtin.HttpRequest[SumType, ftl.Unit, ftl.Unit]) (builtin.HttpResponse[SumType, string], error) {\n\treturn builtin.HttpResponse[SumType, string]{Body: ftl.Some(req.Body)}, nil\n}\n```\n\nThe following curl request will map the `SumType` name and value to the `req.Body`:\n\n```sh\ncurl -X POST \"http://localhost:8891/typeenum\" \\\n -H \"Content-Type: application/json\" \\\n --data '{\"name\": \"A\", \"value\": \"sample\"}'\n```\n\nThe response will be:\n\n```json\n{\n \"name\": \"A\",\n \"value\": \"sample\"\n}\n```\n\n## Encoding query params as JSON\n\nComplex query params can also be encoded as JSON using the `@json` query parameter. For example:\n\n> `{\"tag\":\"ftl\"}` url-encoded is `%7B%22tag%22%3A%22ftl%22%7D`\n\n```bash\ncurl -i http://localhost:8891/users/123/posts/456?@json=%7B%22tag%22%3A%22ftl%22%7D\n```\n", "//ftl:retry": "## Retries\n\nSome FTL features allow specifying a retry policy via a Go comment directive. Retries back off exponentially until the maximum is reached.\n\nThe directive has the following syntax:\n\n```go\n//ftl:retry [] [] [catch ]\n```\n\nFor example, the following function will retry up to 10 times, with a delay of 5s, 10s, 20s, 40s, 60s, 60s, etc.\n\n```go\n//ftl:retry 10 5s 1m\nfunc Process(ctx context.Context, in Invoice) error {\n // ...\n}\n```\n\n### PubSub\n\nSubscribers can have a retry policy. For example:\n```go\n//ftl:subscribe exampleSubscription\n//ftl:retry 5 1s catch recoverPaymentProcessing\nfunc ProcessPayment(ctx context.Context, payment Payment) error {\n ...\n}\n```\n\n### FSM\n\nRetries can be declared on the FSM or on individual transition verbs. Retries declared on a verb take precedence over ones declared on the FSM. For example:\n```go\n//ftl:retry 10 1s 10s\nvar fsm = ftl.FSM(\"fsm\",\n\tftl.Start(Start),\n\tftl.Transition(Start, End),\n)\n\n//ftl:verb\n//ftl:retry 1 1s 1s\nfunc Start(ctx context.Context, in Event) error {\n\t// Start uses its own retry policy\n}\n\n\n//ftl:verb\nfunc End(ctx context.Context, in Event) error {\n\t// End inherits the default retry policy from the FSM\n}\n```\n\n\n## Catching\nAfter all retries have failed, a catch verb can be used to safely recover.\n\nThese catch verbs have a request type of `builtin.CatchRequest` and no response type. If a catch verb returns an error, it will be retried until it succeeds so it is important to handle errors carefully.\n\n```go\n//ftl:retry 5 1s catch recoverPaymentProcessing\nfunc ProcessPayment(ctx context.Context, payment Payment) error {\n ...\n}\n\n//ftl:verb\nfunc RecoverPaymentProcessing(ctx context.Context, request builtin.CatchRequest[Payment]) error {\n // safely handle final failure of the payment\n}\n```\n\nFor FSMs, after a catch verb has been successfully called the FSM will moved to the failed state.", "//ftl:subscribe": "## PubSub\n\nFTL has first-class support for PubSub, modelled on the concepts of topics (where events are sent), subscriptions (a cursor over the topic), and subscribers (functions events are delivered to). Subscribers are, as you would expect, sinks. Each subscription is a cursor over the topic it is associated with. Each topic may have multiple subscriptions. Each subscription may have multiple subscribers, in which case events will be distributed among them.\n\nFirst, declare a new topic:\n\n```go\nvar Invoices = ftl.Topic[Invoice](\"invoices\")\n```\n\nThen declare each subscription on the topic:\n\n```go\nvar _ = ftl.Subscription(Invoices, \"emailInvoices\")\n```\n\nAnd finally define a Sink to consume from the subscription:\n\n```go\n//ftl:subscribe emailInvoices\nfunc SendInvoiceEmail(ctx context.Context, in Invoice) error {\n // ...\n}\n```\n\nEvents can be published to a topic like so:\n\n```go\nInvoices.Publish(ctx, Invoice{...})\n```\n\n> **NOTE!**\n> PubSub topics cannot be published to from outside the module that declared them, they can only be subscribed to. That is, if a topic is declared in module `A`, module `B` cannot publish to it.\n", "//ftl:typealias": "## Type aliases\n\nA type alias is an alternate name for an existing type. It can be declared like so:\n\n```go\n//ftl:typealias\ntype Alias Target\n```\nor\n```go\n//ftl:typealias\ntype Alias = Target\n```\n\neg.\n\n```go\n//ftl:typealias\ntype UserID string\n\n//ftl:typealias\ntype UserToken = string\n```\n", 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, diff --git a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java index 046995426b..c7005a3365 100644 --- a/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java +++ b/jvm-runtime/ftl-runtime/common/deployment/src/main/java/xyz/block/ftl/deployment/FtlProcessor.java @@ -342,6 +342,7 @@ public void registerVerbs(CombinedIndexBuildItem index, //TODO: process path properly MetadataIngress.Builder ingressBuilder = MetadataIngress.newBuilder() + .setType("http") .setMethod(endpoint.getResourceMethod().getHttpMethod()); for (var i : pathComponents) { ingressBuilder.addPath(i); @@ -352,6 +353,11 @@ public void registerVerbs(CombinedIndexBuildItem index, .build(); Type requestTypeParam = buildType(extractionContext, bodyParamType, true); Type responseTypeParam = buildType(extractionContext, endpoint.getMethodInfo().returnType(), true); + Type stringType = Type.newBuilder().setString(xyz.block.ftl.v1.schema.String.newBuilder().build()).build(); + Type pathParamType = Type.newBuilder() + .setMap(xyz.block.ftl.v1.schema.Map.newBuilder().setKey(stringType) + .setValue(stringType)) + .build(); moduleBuilder .addDecls(Decl.newBuilder().setVerb(xyz.block.ftl.v1.schema.Verb.newBuilder() .addMetadata(ingressMetadata) @@ -359,7 +365,13 @@ public void registerVerbs(CombinedIndexBuildItem index, .setExport(true) .setRequest(Type.newBuilder() .setRef(Ref.newBuilder().setModule(BUILTIN).setName(HttpRequest.class.getSimpleName()) - .addTypeParameters(requestTypeParam)) + .addTypeParameters(requestTypeParam) + .addTypeParameters(pathParamType) + .addTypeParameters(Type.newBuilder() + .setMap(xyz.block.ftl.v1.schema.Map.newBuilder().setKey(stringType) + .setValue(Type.newBuilder() + .setArray(Array.newBuilder().setElement(stringType))) + .build()))) .build()) .setResponse(Type.newBuilder() .setRef(Ref.newBuilder().setModule(BUILTIN).setName(HttpResponse.class.getSimpleName())