diff --git a/functions/functions.go b/functions/functions.go index 46b7f0458..285190960 100644 --- a/functions/functions.go +++ b/functions/functions.go @@ -70,6 +70,7 @@ type FunctionsRuntimeResponse struct { type FunctionsRuntimeMeta struct { Headers map[string][]string `json:"headers"` + Status int `json:"status"` } // FunctionsRuntimeError follows the error object specification @@ -91,7 +92,7 @@ func WithFunctionsTransport(ctx context.Context, transport Transport) context.Co } // CallFunction will invoke the custom function on the runtime node server. -func CallFunction(ctx context.Context, actionName string, body any, permissionState *common.PermissionState) (any, map[string][]string, error) { +func CallFunction(ctx context.Context, actionName string, body any, permissionState *common.PermissionState) (any, *FunctionsRuntimeMeta, error) { span := trace.SpanFromContext(ctx) transport, ok := ctx.Value(contextKey).(Transport) @@ -156,7 +157,7 @@ func CallFunction(ctx context.Context, actionName string, body any, permissionSt return nil, nil, toRuntimeError(resp.Error) } - return resp.Result, resp.Meta.Headers, nil + return resp.Result, resp.Meta, nil } // CallJob will invoke the job function on the runtime node server. diff --git a/integration/testdata/functions_http/functions/withHeaders.ts b/integration/testdata/functions_http/functions/withHeaders.ts new file mode 100755 index 000000000..78e4a25fe --- /dev/null +++ b/integration/testdata/functions_http/functions/withHeaders.ts @@ -0,0 +1,10 @@ +import { WithHeaders, permissions } from "@teamkeel/sdk"; + +export default WithHeaders(async (ctx, inputs) => { + permissions.allow(); + + const value = ctx.headers.get("X-MyRequestHeader"); + ctx.response.headers.set("X-MyResponseHeader", value || ""); + + return {}; +}); diff --git a/integration/testdata/functions_http/functions/withQueryParams.ts b/integration/testdata/functions_http/functions/withQueryParams.ts new file mode 100755 index 000000000..43c1cd8dd --- /dev/null +++ b/integration/testdata/functions_http/functions/withQueryParams.ts @@ -0,0 +1,7 @@ +import { WithQueryParams, permissions } from "@teamkeel/sdk"; + +export default WithQueryParams(async (ctx, inputs) => { + permissions.allow(); + + return inputs; +}); diff --git a/integration/testdata/functions_http/functions/withStatus.ts b/integration/testdata/functions_http/functions/withStatus.ts new file mode 100755 index 000000000..019bbcca7 --- /dev/null +++ b/integration/testdata/functions_http/functions/withStatus.ts @@ -0,0 +1,10 @@ +import { WithStatus, permissions } from "@teamkeel/sdk"; +export default WithStatus(async (ctx, inputs) => { + permissions.allow(); + + const { response } = ctx; + response.headers.set("Location", "https://some.url"); + response.status = 301; + + return null; +}); diff --git a/integration/testdata/functions_http/schema.keel b/integration/testdata/functions_http/schema.keel new file mode 100644 index 000000000..716fd41fd --- /dev/null +++ b/integration/testdata/functions_http/schema.keel @@ -0,0 +1,7 @@ +model TestModel { + actions { + read withQueryParams(Any) returns (Any) + read withHeaders(Any) returns (Any) + read withStatus(Any) returns (Any) + } +} diff --git a/integration/testdata/functions_http/tests.test.ts b/integration/testdata/functions_http/tests.test.ts new file mode 100644 index 000000000..89d3180cf --- /dev/null +++ b/integration/testdata/functions_http/tests.test.ts @@ -0,0 +1,50 @@ +import { test, expect } from "vitest"; + +test("headers", async () => { + const response = await fetch( + process.env.KEEL_TESTING_ACTIONS_API_URL + "/withHeaders", + { + body: "{}", + method: "POST", + headers: { + "Content-Type": "application/json", + "X-MyRequestHeader": "my-header-value", + }, + } + ); + + expect(response.status).toEqual(200); + expect(response.headers.get("X-MyResponseHeader")).toEqual("my-header-value"); +}); + +test("status", async () => { + const response = await fetch( + process.env.KEEL_TESTING_ACTIONS_API_URL + "/withStatus", + { + body: "{}", + method: "POST", + headers: { + "Content-Type": "application/json", + }, + redirect: "manual", + } + ); + + expect(response.status).toEqual(301); + expect(response.headers.get("Location")).toEqual("https://some.url"); +}); + +test("query params", async () => { + const response = await fetch( + process.env.KEEL_TESTING_ACTIONS_API_URL + + "/withQueryParams?a=1&b=foo&c=true" + ); + + const body = await response.json(); + + expect(body).toEqual({ + a: "1", + b: "foo", + c: "true", + }); +}); diff --git a/node/bootstrap.go b/node/bootstrap.go index 031760003..e99ed70a9 100644 --- a/node/bootstrap.go +++ b/node/bootstrap.go @@ -158,7 +158,7 @@ func Bootstrap(dir string, opts ...BootstrapOption) error { options.logger("Creating tsconfig.json") tsConfig := map[string]any{ "compilerOptions": map[string]any{ - "lib": []string{"ES2016"}, + "lib": []string{"ES2016", "DOM"}, "target": "ES2016", "esModuleInterop": true, "moduleResolution": "node", diff --git a/node/codegen.go b/node/codegen.go index 1735829d3..59df2b9a0 100644 --- a/node/codegen.go +++ b/node/codegen.go @@ -609,7 +609,7 @@ func writeAPIDeclarations(w *codegen.Writer, schema *proto.Schema) { func writeAPIFactory(w *codegen.Writer, schema *proto.Schema) { w.Writeln("function createContextAPI({ responseHeaders, meta }) {") w.Indent() - w.Writeln("const headers = new runtime.RequestHeaders(meta.headers);") + w.Writeln("const headers = new Headers(meta.headers);") w.Writeln("const response = { headers: responseHeaders }") w.Writeln("const now = () => { return new Date(); };") w.Writeln("const { identity } = meta;") diff --git a/node/codegen_test.go b/node/codegen_test.go index caf392771..88b8a37fa 100644 --- a/node/codegen_test.go +++ b/node/codegen_test.go @@ -366,7 +366,7 @@ job BatchPosts { func TestWriteAPIFactory(t *testing.T) { expected := ` function createContextAPI({ responseHeaders, meta }) { - const headers = new runtime.RequestHeaders(meta.headers); + const headers = new Headers(meta.headers); const response = { headers: responseHeaders } const now = () => { return new Date(); }; const { identity } = meta; diff --git a/packages/functions-runtime/src/RequestHeaders.js b/packages/functions-runtime/src/RequestHeaders.js index cad9c67a1..78eb7d90f 100644 --- a/packages/functions-runtime/src/RequestHeaders.js +++ b/packages/functions-runtime/src/RequestHeaders.js @@ -7,12 +7,12 @@ class RequestHeaders { this._headers = new Headers(requestHeaders); } - get(key) { - return this._headers.get(key); + get() { + return this._headers.get(...arguments); } - has(key) { - return this._headers.has(key); + has() { + return this._headers.has(...arguments); } } diff --git a/packages/functions-runtime/src/handleRequest.js b/packages/functions-runtime/src/handleRequest.js index 24af678d1..da07fe4e2 100644 --- a/packages/functions-runtime/src/handleRequest.js +++ b/packages/functions-runtime/src/handleRequest.js @@ -88,7 +88,10 @@ async function handleRequest(request, config) { for (const pair of headers.entries()) { responseHeaders[pair[0]] = pair[1].split(", "); } - response.meta = { headers: responseHeaders }; + response.meta = { + headers: responseHeaders, + status: ctx.response.status, + }; return response; } catch (e) { diff --git a/packages/functions-runtime/src/index.d.ts b/packages/functions-runtime/src/index.d.ts index 6c9baf369..069ee091d 100644 --- a/packages/functions-runtime/src/index.d.ts +++ b/packages/functions-runtime/src/index.d.ts @@ -59,6 +59,7 @@ export type ContextAPI = { export type Response = { headers: Headers; + status?: number; }; export type PageInfo = { @@ -70,10 +71,7 @@ export type PageInfo = { }; // Request headers query API -export type RequestHeaders = { - get(name: string): string; - has(name: string): boolean; -}; +export type RequestHeaders = Omit; export declare class Permissions { constructor(); diff --git a/runtime/actions/scope.go b/runtime/actions/scope.go index a387679a7..71624cfa0 100644 --- a/runtime/actions/scope.go +++ b/runtime/actions/scope.go @@ -3,6 +3,7 @@ package actions import ( "context" "fmt" + "net/http" "github.com/teamkeel/keel/events" "github.com/teamkeel/keel/functions" @@ -82,7 +83,7 @@ func NewJobScope( } } -func Execute(scope *Scope, inputs any) (result any, headers map[string][]string, err error) { +func Execute(scope *Scope, inputs any) (result any, meta *common.ResponseMetadata, err error) { ctx, span := tracer.Start(scope.Context, scope.Action.Name) defer span.End() @@ -99,7 +100,7 @@ func Execute(scope *Scope, inputs any) (result any, headers map[string][]string, switch scope.Action.Implementation { case proto.ActionImplementation_ACTION_IMPLEMENTATION_CUSTOM: - result, headers, err = executeCustomFunction(scope, inputs) + result, meta, err = executeCustomFunction(scope, inputs) case proto.ActionImplementation_ACTION_IMPLEMENTATION_RUNTIME: if !inputWasAMap { if inputs == nil { @@ -108,7 +109,7 @@ func Execute(scope *Scope, inputs any) (result any, headers map[string][]string, return nil, nil, fmt.Errorf("inputs %v were not in correct format", inputs) } } - result, headers, err = executeRuntimeAction(scope, inputsAsMap) + result, meta, err = executeRuntimeAction(scope, inputsAsMap) case proto.ActionImplementation_ACTION_IMPLEMENTATION_AUTO: if !inputWasAMap { if inputs == nil { @@ -117,7 +118,7 @@ func Execute(scope *Scope, inputs any) (result any, headers map[string][]string, return nil, nil, fmt.Errorf("inputs %v were not in correct format", inputs) } } - result, headers, err = executeAutoAction(scope, inputsAsMap) + result, meta, err = executeAutoAction(scope, inputsAsMap) default: return nil, nil, fmt.Errorf("unhandled unknown action %s of type %s", scope.Action.Name, scope.Action.Implementation) } @@ -134,7 +135,7 @@ func Execute(scope *Scope, inputs any) (result any, headers map[string][]string, return } -func executeCustomFunction(scope *Scope, inputs any) (any, map[string][]string, error) { +func executeCustomFunction(scope *Scope, inputs any) (any, *common.ResponseMetadata, error) { permissions := proto.PermissionsForAction(scope.Schema, scope.Action) canAuthoriseEarly, authorised, err := TryResolveAuthorisationEarly(scope, permissions) @@ -151,7 +152,7 @@ func executeCustomFunction(scope *Scope, inputs any) (any, map[string][]string, } } - resp, headers, err := functions.CallFunction( + resp, meta, err := functions.CallFunction( scope.Context, scope.Action.Name, inputs, @@ -162,6 +163,11 @@ func executeCustomFunction(scope *Scope, inputs any) (any, map[string][]string, return nil, nil, err } + m := &common.ResponseMetadata{ + Headers: http.Header(meta.Headers), + Status: meta.Status, + } + // For now a custom list function just returns a list of records, but the API's // all return an objects containing results and pagination info. So we need // to "wrap" the results here. @@ -181,13 +187,13 @@ func executeCustomFunction(scope *Scope, inputs any) (any, map[string][]string, "startCursor": "", "endCursor": "", }, - }, headers, nil + }, m, nil } - return resp, headers, err + return resp, m, err } -func executeRuntimeAction(scope *Scope, inputs map[string]any) (any, map[string][]string, error) { +func executeRuntimeAction(scope *Scope, inputs map[string]any) (any, *common.ResponseMetadata, error) { switch scope.Action.Name { case authenticateActionName: result, err := Authenticate(scope, inputs) @@ -203,7 +209,7 @@ func executeRuntimeAction(scope *Scope, inputs map[string]any) (any, map[string] } } -func executeAutoAction(scope *Scope, inputs map[string]any) (any, map[string][]string, error) { +func executeAutoAction(scope *Scope, inputs map[string]any) (any, *common.ResponseMetadata, error) { permissions := proto.PermissionsForAction(scope.Schema, scope.Action) // Attempt to resolve permissions early; i.e. before row-based database querying. diff --git a/runtime/apis/graphql/graphql.go b/runtime/apis/graphql/graphql.go index 5496511ac..32b380a6c 100644 --- a/runtime/apis/graphql/graphql.go +++ b/runtime/apis/graphql/graphql.go @@ -148,7 +148,9 @@ func NewHandler(s *proto.Schema, api *proto.Api) common.HandlerFunc { span.SetStatus(codes.Error, strings.Join(messages, ", ")) } - return common.NewJsonResponse(http.StatusOK, result, headers) + return common.NewJsonResponse(http.StatusOK, result, &common.ResponseMetadata{ + Headers: headers, + }) } } diff --git a/runtime/apis/graphql/resolvers.go b/runtime/apis/graphql/resolvers.go index 9cd80bda0..6e1554b43 100644 --- a/runtime/apis/graphql/resolvers.go +++ b/runtime/apis/graphql/resolvers.go @@ -16,7 +16,7 @@ func ActionFunc(schema *proto.Schema, action *proto.Action) func(p graphql.Resol scope := actions.NewScope(p.Context, action, schema) input := p.Args["input"] - res, headers, err := actions.Execute(scope, input) + res, meta, err := actions.Execute(scope, input) if err != nil { var runtimeErr common.RuntimeError if !errors.As(err, &runtimeErr) { @@ -31,8 +31,11 @@ func ActionFunc(schema *proto.Schema, action *proto.Action) func(p graphql.Resol rootValue := p.Info.RootValue.(map[string]interface{}) headersValue := rootValue["headers"].(map[string][]string) - for k, v := range headers { - headersValue[k] = v + + if meta != nil { + for k, v := range meta.Headers { + headersValue[k] = v + } } if action.Type == proto.ActionType_ACTION_TYPE_LIST { diff --git a/runtime/apis/httpjson/httpjson.go b/runtime/apis/httpjson/httpjson.go index 9f8aad0e3..9a7f14c12 100644 --- a/runtime/apis/httpjson/httpjson.go +++ b/runtime/apis/httpjson/httpjson.go @@ -100,12 +100,12 @@ func NewHandler(p *proto.Schema, api *proto.Api) common.HandlerFunc { scope := actions.NewScope(ctx, action, p) - response, headers, err := actions.Execute(scope, inputs) + response, meta, err := actions.Execute(scope, inputs) if err != nil { return NewErrorResponse(ctx, err, nil) } - return common.NewJsonResponse(http.StatusOK, response, headers) + return common.NewJsonResponse(http.StatusOK, response, meta) } } diff --git a/runtime/apis/jsonrpc/jsonrpc.go b/runtime/apis/jsonrpc/jsonrpc.go index a060ed325..a4934f181 100644 --- a/runtime/apis/jsonrpc/jsonrpc.go +++ b/runtime/apis/jsonrpc/jsonrpc.go @@ -76,12 +76,12 @@ func NewHandler(p *proto.Schema, api *proto.Api) common.HandlerFunc { scope := actions.NewScope(ctx, action, p) - response, headers, err := actions.Execute(scope, inputs) + response, meta, err := actions.Execute(scope, inputs) if err != nil { return NewErrorResponse(ctx, &req.ID, err) } - return NewSuccessResponse(ctx, req.ID, response, headers) + return NewSuccessResponse(ctx, req.ID, response, meta) } } @@ -114,12 +114,12 @@ type JsonRpcError struct { Detail any `json:"detail,omitempty"` } -func NewSuccessResponse(ctx context.Context, requestId string, response any, headers map[string][]string) common.Response { +func NewSuccessResponse(ctx context.Context, requestId string, response any, meta *common.ResponseMetadata) common.Response { return common.NewJsonResponse(http.StatusOK, JsonRpcSuccessResponse{ JsonRpc: "2.0", ID: requestId, Result: response, - }, headers) + }, meta) } func NewErrorResponse(ctx context.Context, requestId *string, err error) common.Response { diff --git a/runtime/common/common.go b/runtime/common/common.go index f5f98f325..f1b6eaba4 100644 --- a/runtime/common/common.go +++ b/runtime/common/common.go @@ -17,13 +17,28 @@ type Response struct { Headers map[string][]string } -func NewJsonResponse(status int, body any, headers map[string][]string) Response { +type ResponseMetadata struct { + Headers http.Header + Status int +} + +func NewJsonResponse(status int, body any, meta *ResponseMetadata) Response { b, _ := json.Marshal(body) - return Response{ - Status: status, - Body: b, - Headers: headers, + + r := Response{ + Status: status, + Body: b, } + + if meta != nil { + r.Headers = meta.Headers + + if meta.Status != 0 { + r.Status = meta.Status + } + } + + return r } type HandlerFunc func(r *http.Request) Response