@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/.env.test +2 -0
- package/README.md +3 -0
- package/compose.yaml +10 -0
- package/package.json +33 -0
- package/src/ModelAPI.js +229 -0
- package/src/ModelAPI.test.js +920 -0
- package/src/QueryBuilder.js +95 -0
- package/src/QueryContext.js +90 -0
- package/src/RequestHeaders.js +21 -0
- package/src/applyAdditionalQueryConstraints.js +22 -0
- package/src/applyJoins.js +65 -0
- package/src/applyWhereConditions.js +70 -0
- package/src/casing.js +30 -0
- package/src/casing.test.js +25 -0
- package/src/consts.js +13 -0
- package/src/database.js +163 -0
- package/src/errors.js +116 -0
- package/src/handleJob.js +100 -0
- package/src/handleJob.test.js +271 -0
- package/src/handleRequest.js +124 -0
- package/src/handleRequest.test.js +360 -0
- package/src/index.d.ts +86 -0
- package/src/index.js +27 -0
- package/src/permissions.js +78 -0
- package/src/permissions.test.js +120 -0
- package/src/tracing.js +135 -0
- package/src/tracing.test.js +119 -0
- package/src/tryExecuteFunction.js +74 -0
- package/vite.config.js +7 -0
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
|
+
};
|
package/src/handleJob.js
ADDED
|
@@ -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
|
+
};
|