Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support setting http response status in functions #1304

Merged
merged 1 commit into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions functions/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions integration/testdata/functions_http/functions/withHeaders.ts
Original file line number Diff line number Diff line change
@@ -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 {};
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { WithQueryParams, permissions } from "@teamkeel/sdk";

export default WithQueryParams(async (ctx, inputs) => {
permissions.allow();

return inputs;
});
10 changes: 10 additions & 0 deletions integration/testdata/functions_http/functions/withStatus.ts
Original file line number Diff line number Diff line change
@@ -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;
});
7 changes: 7 additions & 0 deletions integration/testdata/functions_http/schema.keel
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
model TestModel {
actions {
read withQueryParams(Any) returns (Any)
read withHeaders(Any) returns (Any)
read withStatus(Any) returns (Any)
}
}
50 changes: 50 additions & 0 deletions integration/testdata/functions_http/tests.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
2 changes: 1 addition & 1 deletion node/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion node/codegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;")
Expand Down
2 changes: 1 addition & 1 deletion node/codegen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/functions-runtime/src/handleRequest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
56 changes: 49 additions & 7 deletions packages/functions-runtime/src/handleRequest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ test("when the custom function returns expected value", async () => {
actionTypes: {
createPost: PROTO_ACTION_TYPES.CREATE,
},
createContextAPI: () => {},
createContextAPI: () => {
return {
response: {
headers: new Headers(),
},
};
},
};

const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
Expand Down Expand Up @@ -52,7 +58,13 @@ test("when the custom function doesnt return a value", async () => {
actionTypes: {
createPost: PROTO_ACTION_TYPES.CREATE,
},
createContextAPI: () => {},
createContextAPI: () => {
return {
response: {
headers: new Headers(),
},
};
},
};

const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
Expand All @@ -75,7 +87,13 @@ test("when there is no matching function for the path", async () => {
actionTypes: {
createPost: PROTO_ACTION_TYPES.CREATE,
},
createContextAPI: () => {},
createContextAPI: () => {
return {
response: {
headers: new Headers(),
},
};
},
};

const rpcReq = createJSONRPCRequest("123", "unknown", { title: "a post" });
Expand All @@ -100,7 +118,13 @@ test("when there is an unexpected error in the custom function", async () => {
actionTypes: {
createPost: PROTO_ACTION_TYPES.CREATE,
},
createContextAPI: () => {},
createContextAPI: () => {
return {
response: {
headers: new Headers(),
},
};
},
};

const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
Expand Down Expand Up @@ -128,7 +152,13 @@ test("when a role based permission has already been granted by the main runtime"
createPost: PROTO_ACTION_TYPES.CREATE,
},
createModelAPI: () => {},
createContextAPI: () => {},
createContextAPI: () => {
return {
response: {
headers: new Headers(),
},
};
},
};

let rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
Expand Down Expand Up @@ -159,7 +189,13 @@ test("when there is an unexpected object thrown in the custom function", async (
actionTypes: {
createPost: PROTO_ACTION_TYPES.CREATE,
},
createContextAPI: () => {},
createContextAPI: () => {
return {
response: {
headers: new Headers(),
},
};
},
};

const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
Expand Down Expand Up @@ -244,7 +280,13 @@ describe("ModelAPI error handling", () => {
return deleted;
},
},
createContextAPI: () => ({}),
createContextAPI: () => {
return {
response: {
headers: new Headers(),
},
};
},
};
});

Expand Down
8 changes: 3 additions & 5 deletions packages/functions-runtime/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type ContextAPI = {

export type Response = {
headers: Headers;
status?: number;
};

export type PageInfo = {
Expand All @@ -69,11 +70,8 @@ export type PageInfo = {
count: number;
};

// Request headers query API
export type RequestHeaders = {
get(name: string): string;
has(name: string): boolean;
};
// Request headers cannot be mutated, so remove any methods that mutate
export type RequestHeaders = Omit<Headers, "append" | "delete" | "set">;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


export declare class Permissions {
constructor();
Expand Down
26 changes: 16 additions & 10 deletions runtime/actions/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package actions
import (
"context"
"fmt"
"net/http"

"github.com/teamkeel/keel/events"
"github.com/teamkeel/keel/functions"
Expand Down Expand Up @@ -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()

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion runtime/apis/graphql/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
}

Expand Down
Loading
Loading