@teamkeel/functions-runtime 0.351.0 → 0.353.0
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/package.json +1 -1
- package/src/consts.js +1 -0
- package/src/database.js +1 -0
- package/src/handleJob.js +96 -0
- package/src/handleJob.test.js +271 -0
- package/src/handleRequest.js +3 -1
- package/src/index.js +2 -0
- package/src/tryExecuteFunction.js +2 -4
package/package.json
CHANGED
package/src/consts.js
CHANGED
package/src/database.js
CHANGED
package/src/handleJob.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
|
|
57
|
+
const result = await tryExecuteFunction(
|
|
58
|
+
{ request, ctx, permitted, db, actionType },
|
|
59
|
+
async () => {
|
|
60
|
+
// Return the job function to the containing tryExecuteFunction block
|
|
61
|
+
return jobFunction(ctx, request.params);
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return createJSONRPCSuccessResponse(request.id, null);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e instanceof Error) {
|
|
68
|
+
span.recordException(e);
|
|
69
|
+
span.setStatus({
|
|
70
|
+
code: opentelemetry.SpanStatusCode.ERROR,
|
|
71
|
+
message: e.message,
|
|
72
|
+
});
|
|
73
|
+
return errorToJSONRPCResponse(request, e);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const message = JSON.stringify(e);
|
|
77
|
+
|
|
78
|
+
span.setStatus({
|
|
79
|
+
code: opentelemetry.SpanStatusCode.ERROR,
|
|
80
|
+
message: message,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return createJSONRPCErrorResponse(
|
|
84
|
+
request.id,
|
|
85
|
+
RuntimeErrors.UnknownError,
|
|
86
|
+
message
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
handleJob,
|
|
95
|
+
RuntimeErrors,
|
|
96
|
+
};
|
|
@@ -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
|
+
});
|
package/src/handleRequest.js
CHANGED
|
@@ -59,9 +59,10 @@ async function handleRequest(request, config) {
|
|
|
59
59
|
|
|
60
60
|
const db = getDatabaseClient();
|
|
61
61
|
const customFunction = functions[request.method];
|
|
62
|
+
const actionType = actionTypes[request.method];
|
|
62
63
|
|
|
63
64
|
const result = await tryExecuteFunction(
|
|
64
|
-
{ request, ctx, permitted, db, permissionFns,
|
|
65
|
+
{ request, ctx, permitted, db, permissionFns, actionType },
|
|
65
66
|
async () => {
|
|
66
67
|
// Return the custom function to the containing tryExecuteFunction block
|
|
67
68
|
// Once the custom function is called, tryExecuteFunction will check the schema's permission rules to see if it can continue committing
|
|
@@ -106,6 +107,7 @@ async function handleRequest(request, config) {
|
|
|
106
107
|
code: opentelemetry.SpanStatusCode.ERROR,
|
|
107
108
|
message: message,
|
|
108
109
|
});
|
|
110
|
+
|
|
109
111
|
return createJSONRPCErrorResponse(
|
|
110
112
|
request.id,
|
|
111
113
|
RuntimeErrors.UnknownError,
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { ModelAPI } = require("./ModelAPI");
|
|
2
2
|
const { RequestHeaders } = require("./RequestHeaders");
|
|
3
3
|
const { handleRequest } = require("./handleRequest");
|
|
4
|
+
const { handleJob } = require("./handleJob");
|
|
4
5
|
const KSUID = require("ksuid");
|
|
5
6
|
const { useDatabase } = require("./database");
|
|
6
7
|
const {
|
|
@@ -14,6 +15,7 @@ module.exports = {
|
|
|
14
15
|
ModelAPI,
|
|
15
16
|
RequestHeaders,
|
|
16
17
|
handleRequest,
|
|
18
|
+
handleJob,
|
|
17
19
|
useDatabase,
|
|
18
20
|
Permissions,
|
|
19
21
|
PERMISSION_STATE,
|
|
@@ -10,18 +10,16 @@ const { PROTO_ACTION_TYPES } = require("./consts");
|
|
|
10
10
|
// tryExecuteFunction will create a new database transaction around a function call
|
|
11
11
|
// and handle any permissions checks. If a permission check fails, then an Error will be thrown and the catch block will be hit.
|
|
12
12
|
function tryExecuteFunction(
|
|
13
|
-
{ db, permitted, permissionFns,
|
|
13
|
+
{ db, permitted, permissionFns, actionType, request, ctx },
|
|
14
14
|
cb
|
|
15
15
|
) {
|
|
16
|
-
const actionType = actionTypes[request.method];
|
|
17
|
-
|
|
18
16
|
return withPermissions(permitted, async ({ getPermissionState }) => {
|
|
19
17
|
return withDatabase(db, actionType, async ({ transaction }) => {
|
|
20
18
|
const fnResult = await cb();
|
|
21
|
-
|
|
22
19
|
// api.permissions maintains an internal state of whether the current operation has been *explicitly* permitted/denied by the user in the course of their custom function, or if execution has already been permitted by a role based permission (evaluated in the main runtime).
|
|
23
20
|
// we need to check that the final state is permitted or unpermitted. if it's not, then it means that the user has taken no explicit action to permit/deny
|
|
24
21
|
// and therefore we default to checking the permissions defined in the schema automatically.
|
|
22
|
+
|
|
25
23
|
switch (getPermissionState()) {
|
|
26
24
|
case PERMISSION_STATE.PERMITTED:
|
|
27
25
|
return fnResult;
|