@teamkeel/functions-runtime 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/errors.js ADDED
@@ -0,0 +1,116 @@
1
+ const { createJSONRPCErrorResponse } = require("json-rpc-2.0");
2
+ const { PermissionError } = require("./permissions");
3
+
4
+ const RuntimeErrors = {
5
+ // Catchall error type for unhandled execution errors during custom function
6
+ UnknownError: -32001,
7
+ // DatabaseError represents any error at pg level that isn't handled explicitly below
8
+ DatabaseError: -32002,
9
+ // No result returned from custom function by user
10
+ NoResultError: -32003,
11
+ // When trying to delete/update a non existent record in the db
12
+ RecordNotFoundError: -32004,
13
+ ForeignKeyConstraintError: -32005,
14
+ NotNullConstraintError: -32006,
15
+ UniqueConstraintError: -32007,
16
+ PermissionError: -32008,
17
+ };
18
+
19
+ // errorToJSONRPCResponse transforms a JavaScript Error instance (or derivative) into a valid JSONRPC response object to pass back to the Keel runtime.
20
+ function errorToJSONRPCResponse(request, e) {
21
+ if (!e.error) {
22
+ // it isnt wrapped
23
+
24
+ if (e instanceof PermissionError) {
25
+ return createJSONRPCErrorResponse(
26
+ request.id,
27
+ RuntimeErrors.PermissionError,
28
+ e.message
29
+ );
30
+ }
31
+
32
+ return createJSONRPCErrorResponse(
33
+ request.id,
34
+ RuntimeErrors.UnknownError,
35
+ e.message
36
+ );
37
+ }
38
+ // we want to switch on instanceof but there is no way to do that in js, so best to check the constructor class of the error
39
+ switch (e.error.constructor.name) {
40
+ // Any error thrown in the ModelAPI class is
41
+ // wrapped in a DatabaseError in order to differentiate 'our code' vs the user's own code.
42
+ case "NoResultError":
43
+ return createJSONRPCErrorResponse(
44
+ request.id,
45
+
46
+ // to be matched to https://github.com/teamkeel/keel/blob/e3115ffe381bfc371d4f45bbf96a15072a994ce5/runtime/actions/update.go#L54-L54
47
+ RuntimeErrors.RecordNotFoundError,
48
+ e.message
49
+ );
50
+ case "DatabaseError":
51
+ const { error: originalError } = e;
52
+
53
+ // if the originalError responds to 'code' then assume it has other pg error message keys
54
+ // todo: make this more ironclad.
55
+ // when using lib-pq, should match https://github.com/brianc/node-postgres/blob/master/packages/pg-protocol/src/parser.ts#L371-L386
56
+ if ("code" in originalError) {
57
+ const { code, detail, table } = originalError;
58
+
59
+ let rpcErrorCode, column, value;
60
+ const [col, val] = parseKeyMessage(originalError.detail);
61
+ column = col;
62
+ value = val;
63
+
64
+ switch (code) {
65
+ case "23502":
66
+ rpcErrorCode = RuntimeErrors.NotNullConstraintError;
67
+ column = originalError.column;
68
+ break;
69
+ case "23503":
70
+ rpcErrorCode = RuntimeErrors.ForeignKeyConstraintError;
71
+ break;
72
+ case "23505":
73
+ rpcErrorCode = RuntimeErrors.UniqueConstraintError;
74
+ break;
75
+ default:
76
+ rpcErrorCode = RuntimeErrors.DatabaseError;
77
+ break;
78
+ }
79
+
80
+ return createJSONRPCErrorResponse(request.id, rpcErrorCode, e.message, {
81
+ table,
82
+ column,
83
+ code,
84
+ detail,
85
+ value,
86
+ });
87
+ }
88
+
89
+ // we don't know what it is, but it's something else
90
+ return createJSONRPCErrorResponse(
91
+ request.id,
92
+ RuntimeErrors.DatabaseError,
93
+ e.message
94
+ );
95
+ default:
96
+ return createJSONRPCErrorResponse(
97
+ request.id,
98
+ RuntimeErrors.UnknownError,
99
+ e.message
100
+ );
101
+ }
102
+ }
103
+
104
+ // example data:
105
+ // Key (author_id)=(fake) is not present in table "author".
106
+ const keyMessagePattern = /\Key\s[(](.*)[)][=][(](.*)[)]/;
107
+ const parseKeyMessage = (msg) => {
108
+ const [, col, value] = keyMessagePattern.exec(msg) || [];
109
+
110
+ return [col, value];
111
+ };
112
+
113
+ module.exports = {
114
+ errorToJSONRPCResponse,
115
+ RuntimeErrors,
116
+ };
@@ -0,0 +1,100 @@
1
+ const {
2
+ createJSONRPCErrorResponse,
3
+ createJSONRPCSuccessResponse,
4
+ JSONRPCErrorCode,
5
+ } = require("json-rpc-2.0");
6
+ const { getDatabaseClient } = require("./database");
7
+ const { tryExecuteFunction } = require("./tryExecuteFunction");
8
+ const { errorToJSONRPCResponse, RuntimeErrors } = require("./errors");
9
+ const opentelemetry = require("@opentelemetry/api");
10
+ const { withSpan } = require("./tracing");
11
+ const { PROTO_ACTION_TYPES } = require("./consts");
12
+
13
+ // Generic handler function that is agnostic to runtime environment (local or lambda)
14
+ // to execute a job function based on the contents of a jsonrpc-2.0 payload object.
15
+ // To read more about jsonrpc request and response shapes, please read https://www.jsonrpc.org/specification
16
+ async function handleJob(request, config) {
17
+ // Try to extract trace context from caller
18
+ const activeContext = opentelemetry.propagation.extract(
19
+ opentelemetry.context.active(),
20
+ request.meta?.tracing
21
+ );
22
+
23
+ // Run the whole request with the extracted context
24
+ return opentelemetry.context.with(activeContext, () => {
25
+ // Wrapping span for the whole request
26
+ return withSpan(request.method, async (span) => {
27
+ try {
28
+ const { createJobContextAPI, jobs } = config;
29
+
30
+ if (!(request.method in jobs)) {
31
+ const message = `no corresponding job found for '${request.method}'`;
32
+ span.setStatus({
33
+ code: opentelemetry.SpanStatusCode.ERROR,
34
+ message: message,
35
+ });
36
+ return createJSONRPCErrorResponse(
37
+ request.id,
38
+ JSONRPCErrorCode.MethodNotFound,
39
+ message
40
+ );
41
+ }
42
+
43
+ // The ctx argument passed into the job function.
44
+ const ctx = createJobContextAPI({
45
+ meta: request.meta,
46
+ });
47
+
48
+ const permitted =
49
+ request.meta && request.meta.permissionState.status === "granted"
50
+ ? true
51
+ : null;
52
+
53
+ const db = getDatabaseClient();
54
+ const jobFunction = jobs[request.method];
55
+ const actionType = PROTO_ACTION_TYPES.JOB;
56
+ const permissionFns = new Object();
57
+
58
+ // Jobs will have no permission functions yet.
59
+ permissionFns[request.method] = [];
60
+
61
+ const result = await tryExecuteFunction(
62
+ { request, ctx, permissionFns, permitted, db, actionType },
63
+ async () => {
64
+ // Return the job function to the containing tryExecuteFunction block
65
+ return jobFunction(ctx, request.params);
66
+ }
67
+ );
68
+
69
+ return createJSONRPCSuccessResponse(request.id, null);
70
+ } catch (e) {
71
+ if (e instanceof Error) {
72
+ span.recordException(e);
73
+ span.setStatus({
74
+ code: opentelemetry.SpanStatusCode.ERROR,
75
+ message: e.message,
76
+ });
77
+ return errorToJSONRPCResponse(request, e);
78
+ }
79
+
80
+ const message = JSON.stringify(e);
81
+
82
+ span.setStatus({
83
+ code: opentelemetry.SpanStatusCode.ERROR,
84
+ message: message,
85
+ });
86
+
87
+ return createJSONRPCErrorResponse(
88
+ request.id,
89
+ RuntimeErrors.UnknownError,
90
+ message
91
+ );
92
+ }
93
+ });
94
+ });
95
+ }
96
+
97
+ module.exports = {
98
+ handleJob,
99
+ RuntimeErrors,
100
+ };
@@ -0,0 +1,271 @@
1
+ import { createJSONRPCRequest, JSONRPCErrorCode } from "json-rpc-2.0";
2
+ import { sql } from "kysely";
3
+ import { handleJob, RuntimeErrors } from "./handleJob";
4
+ import { test, expect, beforeEach, describe } from "vitest";
5
+ import { ModelAPI } from "./ModelAPI";
6
+ import { useDatabase } from "./database";
7
+ const { Permissions } = require("./permissions");
8
+ import KSUID from "ksuid";
9
+
10
+ test("when the job returns nothing as expected", async () => {
11
+ const config = {
12
+ jobs: {
13
+ myJob: async (ctx, inputs) => {},
14
+ },
15
+ createJobContextAPI: () => {},
16
+ permissions: {},
17
+ };
18
+
19
+ const rpcReq = createJSONRPCRequest("123", "myJob", { title: "a post" });
20
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
21
+
22
+ expect(await handleJob(rpcReq, config)).toEqual({
23
+ id: "123",
24
+ jsonrpc: "2.0",
25
+ result: null,
26
+ });
27
+ });
28
+
29
+ test("when there is an unexpected error in the job", async () => {
30
+ const config = {
31
+ jobs: {
32
+ myJob: async (ctx, inputs) => {
33
+ throw new Error("oopsie daisy");
34
+ },
35
+ },
36
+ createJobContextAPI: () => {},
37
+ };
38
+
39
+ const rpcReq = createJSONRPCRequest("123", "myJob", { title: "a post" });
40
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
41
+
42
+ expect(await handleJob(rpcReq, config)).toEqual({
43
+ id: "123",
44
+ jsonrpc: "2.0",
45
+ error: {
46
+ code: RuntimeErrors.UnknownError,
47
+ message: "oopsie daisy",
48
+ },
49
+ });
50
+ });
51
+
52
+ test("when there is an unexpected object thrown in the job", async () => {
53
+ const config = {
54
+ jobs: {
55
+ myJob: async (ctx, inputs) => {
56
+ throw { err: "oopsie daisy" };
57
+ },
58
+ },
59
+ createJobContextAPI: () => {},
60
+ };
61
+
62
+ const rpcReq = createJSONRPCRequest("123", "myJob", { title: "a post" });
63
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
64
+
65
+ expect(await handleJob(rpcReq, config)).toEqual({
66
+ id: "123",
67
+ jsonrpc: "2.0",
68
+ error: {
69
+ code: RuntimeErrors.UnknownError,
70
+ message: '{"err":"oopsie daisy"}',
71
+ },
72
+ });
73
+ });
74
+
75
+ test("when there is no matching job for the path", async () => {
76
+ const config = {
77
+ jobs: {
78
+ myJob: async (ctx, inputs) => {},
79
+ },
80
+ createJobContextAPI: () => {},
81
+ };
82
+
83
+ const rpcReq = createJSONRPCRequest("123", "unknown", { title: "a post" });
84
+
85
+ expect(await handleJob(rpcReq, config)).toEqual({
86
+ id: "123",
87
+ jsonrpc: "2.0",
88
+ error: {
89
+ code: JSONRPCErrorCode.MethodNotFound,
90
+ message: "no corresponding job found for 'unknown'",
91
+ },
92
+ });
93
+ });
94
+
95
+ // The following tests assert on the various
96
+ // jsonrpc responses that *should* happen when a user
97
+ // writes a job that inadvertently causes a pg constraint error to occur inside of our ModelAPI class instance.
98
+ describe("ModelAPI error handling", () => {
99
+ let functionConfig;
100
+ let db;
101
+
102
+ beforeEach(async () => {
103
+ process.env.KEEL_DB_CONN_TYPE = "pg";
104
+ process.env.KEEL_DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-runtime`;
105
+
106
+ db = useDatabase();
107
+
108
+ await sql`
109
+ DROP TABLE IF EXISTS post;
110
+ DROP TABLE IF EXISTS author;
111
+
112
+ CREATE TABLE author(
113
+ "id" text PRIMARY KEY,
114
+ "name" text NOT NULL
115
+ );
116
+
117
+ CREATE TABLE post(
118
+ "id" text PRIMARY KEY,
119
+ "title" text NOT NULL UNIQUE,
120
+ "author_id" text NOT NULL REFERENCES author(id)
121
+ );
122
+ `.execute(db);
123
+
124
+ await sql`
125
+ INSERT INTO author (id, name) VALUES ('123', 'Bob')
126
+ `.execute(db);
127
+
128
+ const models = {
129
+ post: new ModelAPI("post", undefined, {
130
+ post: {
131
+ author: {
132
+ relationshipType: "belongsTo",
133
+ foreignKey: "author_id",
134
+ referencesTable: "person",
135
+ },
136
+ },
137
+ }),
138
+ };
139
+
140
+ functionConfig = {
141
+ jobs: {
142
+ createPost: async (ctx, inputs) => {
143
+ const post = await models.post.create({
144
+ id: KSUID.randomSync().string,
145
+ ...inputs,
146
+ });
147
+ return post;
148
+ },
149
+ deletePost: async (ctx, inputs) => {
150
+ const deleted = await models.post.delete(inputs);
151
+ return deleted;
152
+ },
153
+ },
154
+ createJobContextAPI: () => ({}),
155
+ };
156
+ });
157
+
158
+ test("when kysely returns a no result error", async () => {
159
+ // a kysely NoResultError is thrown when attempting to delete/update a non existent record.
160
+ const rpcReq = createJSONRPCRequest("123", "deletePost", {
161
+ id: "non-existent-id",
162
+ });
163
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
164
+
165
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
166
+ id: "123",
167
+ jsonrpc: "2.0",
168
+ error: {
169
+ code: RuntimeErrors.RecordNotFoundError,
170
+ message: "no result",
171
+ },
172
+ });
173
+ });
174
+
175
+ test("when there is a not null constraint error", async () => {
176
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: null });
177
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
178
+
179
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
180
+ id: "123",
181
+ jsonrpc: "2.0",
182
+ error: {
183
+ code: RuntimeErrors.NotNullConstraintError,
184
+ message: 'null value in column "title" violates not-null constraint',
185
+ data: {
186
+ code: "23502",
187
+ column: "title",
188
+ detail: expect.stringContaining("Failing row contains"),
189
+ table: "post",
190
+ },
191
+ },
192
+ });
193
+ });
194
+
195
+ test("when there is a uniqueness constraint error", async () => {
196
+ await sql`
197
+ INSERT INTO post (id, title, author_id) values(${
198
+ KSUID.randomSync().string
199
+ }, 'hello', '123')
200
+ `.execute(db);
201
+
202
+ const rpcReq = createJSONRPCRequest("123", "createPost", {
203
+ title: "hello",
204
+ author_id: "something",
205
+ });
206
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
207
+
208
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
209
+ id: "123",
210
+ jsonrpc: "2.0",
211
+ error: {
212
+ code: RuntimeErrors.UniqueConstraintError,
213
+ message:
214
+ 'duplicate key value violates unique constraint "post_title_key"',
215
+ data: {
216
+ code: "23505",
217
+ column: "title",
218
+ detail: "Key (title)=(hello) already exists.",
219
+ table: "post",
220
+ value: "hello",
221
+ },
222
+ },
223
+ });
224
+ });
225
+
226
+ test("when there is a null value in a foreign key column", async () => {
227
+ const rpcReq = createJSONRPCRequest("123", "createPost", { title: "123" });
228
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
229
+
230
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
231
+ id: "123",
232
+ jsonrpc: "2.0",
233
+ error: {
234
+ code: RuntimeErrors.NotNullConstraintError,
235
+ message:
236
+ 'null value in column "author_id" violates not-null constraint',
237
+ data: {
238
+ code: "23502",
239
+ column: "author_id",
240
+ detail: expect.stringContaining("Failing row contains"),
241
+ table: "post",
242
+ },
243
+ },
244
+ });
245
+ });
246
+
247
+ test("when there is a foreign key constraint violation", async () => {
248
+ const rpcReq = createJSONRPCRequest("123", "createPost", {
249
+ title: "123",
250
+ author_id: "fake",
251
+ });
252
+ rpcReq.meta = { permissionState: { status: "granted", reason: "role" } };
253
+
254
+ expect(await handleJob(rpcReq, functionConfig)).toEqual({
255
+ id: "123",
256
+ jsonrpc: "2.0",
257
+ error: {
258
+ code: RuntimeErrors.ForeignKeyConstraintError,
259
+ message:
260
+ 'insert or update on table "post" violates foreign key constraint "post_author_id_fkey"',
261
+ data: {
262
+ code: "23503",
263
+ column: "author_id",
264
+ detail: 'Key (author_id)=(fake) is not present in table "author".',
265
+ table: "post",
266
+ value: "fake",
267
+ },
268
+ },
269
+ });
270
+ });
271
+ });
@@ -0,0 +1,124 @@
1
+ const {
2
+ createJSONRPCErrorResponse,
3
+ createJSONRPCSuccessResponse,
4
+ JSONRPCErrorCode,
5
+ } = require("json-rpc-2.0");
6
+ const { getDatabaseClient } = require("./database");
7
+ const { tryExecuteFunction } = require("./tryExecuteFunction");
8
+ const { errorToJSONRPCResponse, RuntimeErrors } = require("./errors");
9
+ const opentelemetry = require("@opentelemetry/api");
10
+ const { withSpan } = require("./tracing");
11
+
12
+ // Generic handler function that is agnostic to runtime environment (local or lambda)
13
+ // to execute a custom function based on the contents of a jsonrpc-2.0 payload object.
14
+ // To read more about jsonrpc request and response shapes, please read https://www.jsonrpc.org/specification
15
+ async function handleRequest(request, config) {
16
+ // Try to extract trace context from caller
17
+ const activeContext = opentelemetry.propagation.extract(
18
+ opentelemetry.context.active(),
19
+ request.meta?.tracing
20
+ );
21
+
22
+ // Run the whole request with the extracted context
23
+ return opentelemetry.context.with(activeContext, () => {
24
+ // Wrapping span for the whole request
25
+ return withSpan(request.method, async (span) => {
26
+ try {
27
+ const { createContextAPI, functions, permissionFns, actionTypes } =
28
+ config;
29
+
30
+ if (!(request.method in functions)) {
31
+ const message = `no corresponding function found for '${request.method}'`;
32
+ span.setStatus({
33
+ code: opentelemetry.SpanStatusCode.ERROR,
34
+ message: message,
35
+ });
36
+ return createJSONRPCErrorResponse(
37
+ request.id,
38
+ JSONRPCErrorCode.MethodNotFound,
39
+ message
40
+ );
41
+ }
42
+
43
+ // headers reference passed to custom function where object data can be modified
44
+ const headers = new Headers();
45
+
46
+ // The ctx argument passed into the custom function.
47
+ const ctx = createContextAPI({
48
+ responseHeaders: headers,
49
+ meta: request.meta,
50
+ });
51
+
52
+ // The Go runtime does *some* permissions checks up front before the request reaches
53
+ // this method, so we pass in a permissionState object on the request.meta object that
54
+ // indicates whether a call to a custom function has already been authorised
55
+ const permitted =
56
+ request.meta && request.meta.permissionState.status === "granted"
57
+ ? true
58
+ : null;
59
+
60
+ const db = getDatabaseClient();
61
+ const customFunction = functions[request.method];
62
+ const actionType = actionTypes[request.method];
63
+
64
+ const result = await tryExecuteFunction(
65
+ { request, ctx, permitted, db, permissionFns, actionType },
66
+ async () => {
67
+ // Return the custom function to the containing tryExecuteFunction block
68
+ // Once the custom function is called, tryExecuteFunction will check the schema's permission rules to see if it can continue committing
69
+ // the transaction to the db. If a permission rule is violated, any changes made inside the transaction are rolled back.
70
+ return customFunction(ctx, request.params);
71
+ }
72
+ );
73
+
74
+ // Sometimes a custom function may be coded in such a way that nothing is returned from it.
75
+ // We see this as an error so handle accordingly.
76
+ if (result === undefined) {
77
+ // no result returned from custom function
78
+ return createJSONRPCErrorResponse(
79
+ request.id,
80
+ RuntimeErrors.NoResultError,
81
+ `no result returned from function '${request.method}'`
82
+ );
83
+ }
84
+
85
+ const response = createJSONRPCSuccessResponse(request.id, result);
86
+
87
+ const responseHeaders = {};
88
+ for (const pair of headers.entries()) {
89
+ responseHeaders[pair[0]] = pair[1].split(", ");
90
+ }
91
+ response.meta = { headers: responseHeaders };
92
+
93
+ return response;
94
+ } catch (e) {
95
+ if (e instanceof Error) {
96
+ span.recordException(e);
97
+ span.setStatus({
98
+ code: opentelemetry.SpanStatusCode.ERROR,
99
+ message: e.message,
100
+ });
101
+ return errorToJSONRPCResponse(request, e);
102
+ }
103
+
104
+ const message = JSON.stringify(e);
105
+
106
+ span.setStatus({
107
+ code: opentelemetry.SpanStatusCode.ERROR,
108
+ message: message,
109
+ });
110
+
111
+ return createJSONRPCErrorResponse(
112
+ request.id,
113
+ RuntimeErrors.UnknownError,
114
+ message
115
+ );
116
+ }
117
+ });
118
+ });
119
+ }
120
+
121
+ module.exports = {
122
+ handleRequest,
123
+ RuntimeErrors,
124
+ };