envio 3.14.0 → 3.14.1-resolvers.2
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/evm.schema.json +8 -1
- package/fuel.schema.json +7 -0
- package/index.d.ts +258 -5
- package/index.js +10 -0
- package/package.json +6 -6
- package/src/Bin.res +48 -0
- package/src/Bin.res.mjs +34 -1
- package/src/Config.res +12 -2
- package/src/Config.res.mjs +7 -0
- package/src/Env.res +141 -1
- package/src/Env.res.mjs +133 -1
- package/src/HandlerLoader.res +7 -13
- package/src/HandlerLoader.res.mjs +7 -14
- package/src/HandlerTypeCheck.res +207 -0
- package/src/HandlerTypeCheck.res.mjs +262 -0
- package/src/bindings/NodeJs.res +31 -0
- package/src/bindings/NodeJs.res.mjs +3 -0
- package/src/resolvers/ResolverProcess.res +805 -0
- package/src/resolvers/ResolverProcess.res.mjs +662 -0
- package/src/resolvers/ResolverQuery.res +313 -0
- package/src/resolvers/ResolverQuery.res.mjs +220 -0
- package/src/resolvers/collisions.js +108 -0
- package/src/resolvers/db.js +363 -0
- package/src/resolvers/dispatch.js +259 -0
- package/src/resolvers/errors.js +30 -0
- package/src/resolvers/graphqlSelection.js +292 -0
- package/src/resolvers/hasuraAction.js +84 -0
- package/src/resolvers/hasuraApply.js +333 -0
- package/src/resolvers/hasuraMetadata.js +137 -0
- package/src/resolvers/index.js +123 -0
- package/src/resolvers/manifest.js +376 -0
- package/src/resolvers/server.js +304 -0
- package/src/sources/EvmRpcClient.res +2 -0
- package/src/sources/RpcSource.res +17 -4
- package/src/sources/RpcSource.res.mjs +23 -5
- package/src/sources/SourceManager.res +63 -40
- package/src/sources/SourceManager.res.mjs +32 -20
- package/src/tui/Tui.res +1 -1
- package/src/tui/Tui.res.mjs +1 -1
- package/svm.schema.json +7 -0
- package/src/HandlerTypeCheck.mjs +0 -14
- package/src/HandlerTypeCheckWorker.mjs +0 -68
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Recovers a resolver's selection tree from the operation text Hasura sends.
|
|
2
|
+
//
|
|
3
|
+
// Hasura hands an action handler `request_query`, the raw GraphQL document,
|
|
4
|
+
// and nothing else about what the caller asked for -- there is no parsed
|
|
5
|
+
// selection in the payload. A resolver that skips expensive work when a field
|
|
6
|
+
// is not requested therefore needs the document read back, which is what this
|
|
7
|
+
// does: names and nesting, aliases resolved to the declared name, fragments
|
|
8
|
+
// inlined, which is the same shape envio-serve resolves against the output
|
|
9
|
+
// type.
|
|
10
|
+
//
|
|
11
|
+
// The selection is an optimisation and never correctness -- a resolver that
|
|
12
|
+
// ignores it is still right, only slower. So every failure here answers with an
|
|
13
|
+
// empty tree, which means "assume everything was asked for", and no request is
|
|
14
|
+
// ever refused because its query could not be read.
|
|
15
|
+
|
|
16
|
+
// A document this size is not a client query. Bailing keeps a pathological
|
|
17
|
+
// body from being lexed token by token before the request is answered.
|
|
18
|
+
const MAX_QUERY_BYTES = 128 * 1024;
|
|
19
|
+
|
|
20
|
+
const NAME_START = /[_A-Za-z]/;
|
|
21
|
+
const NAME_CHAR = /[_0-9A-Za-z]/;
|
|
22
|
+
const DIGIT = /[0-9]/;
|
|
23
|
+
|
|
24
|
+
const PUNCTUATORS = new Set(["!", "$", "&", "(", ")", ":", "=", "@", "[", "]", "{", "|", "}"]);
|
|
25
|
+
|
|
26
|
+
// Lexed, so a brace inside a string argument is a character and not structure.
|
|
27
|
+
function lex(source) {
|
|
28
|
+
const tokens = [];
|
|
29
|
+
let i = 0;
|
|
30
|
+
const length = source.length;
|
|
31
|
+
|
|
32
|
+
while (i < length) {
|
|
33
|
+
const char = source[i];
|
|
34
|
+
|
|
35
|
+
if (char === "" || char === " " || char === "\t" || char === "\n" || char === "\r" || char === ",") {
|
|
36
|
+
i += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (char === "#") {
|
|
41
|
+
while (i < length && source[i] !== "\n" && source[i] !== "\r") i += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (char === "." && source.startsWith("...", i)) {
|
|
46
|
+
tokens.push({ kind: "...", value: "..." });
|
|
47
|
+
i += 3;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (PUNCTUATORS.has(char)) {
|
|
52
|
+
tokens.push({ kind: char, value: char });
|
|
53
|
+
i += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (NAME_START.test(char)) {
|
|
58
|
+
let end = i + 1;
|
|
59
|
+
while (end < length && NAME_CHAR.test(source[end])) end += 1;
|
|
60
|
+
tokens.push({ kind: "name", value: source.slice(i, end) });
|
|
61
|
+
i = end;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (char === '"') {
|
|
66
|
+
if (source.startsWith('"""', i)) {
|
|
67
|
+
let end = i + 3;
|
|
68
|
+
while (end < length) {
|
|
69
|
+
if (source[end] === "\\" && source.startsWith('\\"""', end)) {
|
|
70
|
+
end += 4;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (source.startsWith('"""', end)) break;
|
|
74
|
+
end += 1;
|
|
75
|
+
}
|
|
76
|
+
if (end >= length) throw new Error("Unterminated block string");
|
|
77
|
+
tokens.push({ kind: "value", value: source.slice(i, end + 3) });
|
|
78
|
+
i = end + 3;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
let end = i + 1;
|
|
82
|
+
while (end < length && source[end] !== '"') {
|
|
83
|
+
end += source[end] === "\\" ? 2 : 1;
|
|
84
|
+
}
|
|
85
|
+
if (end >= length) throw new Error("Unterminated string");
|
|
86
|
+
tokens.push({ kind: "value", value: source.slice(i, end + 1) });
|
|
87
|
+
i = end + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (DIGIT.test(char) || (char === "-" && DIGIT.test(source[i + 1] ?? ""))) {
|
|
92
|
+
let end = i + 1;
|
|
93
|
+
while (end < length && /[0-9eE.+-]/.test(source[end])) end += 1;
|
|
94
|
+
tokens.push({ kind: "value", value: source.slice(i, end) });
|
|
95
|
+
i = end;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(`Unexpected character '${char}'`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return tokens;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const OPERATIONS = new Set(["query", "mutation", "subscription"]);
|
|
106
|
+
|
|
107
|
+
class Parser {
|
|
108
|
+
constructor(tokens) {
|
|
109
|
+
this.tokens = tokens;
|
|
110
|
+
this.at = 0;
|
|
111
|
+
this.fragments = new Map();
|
|
112
|
+
this.operations = [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
peek(offset = 0) {
|
|
116
|
+
return this.tokens[this.at + offset];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
next() {
|
|
120
|
+
const token = this.tokens[this.at];
|
|
121
|
+
if (token === undefined) throw new Error("Unexpected end of document");
|
|
122
|
+
this.at += 1;
|
|
123
|
+
return token;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(kind) {
|
|
127
|
+
const token = this.next();
|
|
128
|
+
if (token.kind !== kind) throw new Error(`Expected ${kind}, found ${token.kind}`);
|
|
129
|
+
return token;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
parseDocument() {
|
|
133
|
+
while (this.at < this.tokens.length) {
|
|
134
|
+
const token = this.peek();
|
|
135
|
+
if (token.kind === "{") {
|
|
136
|
+
this.operations.push(this.parseSelectionSet());
|
|
137
|
+
} else if (token.kind === "name" && OPERATIONS.has(token.value)) {
|
|
138
|
+
this.next();
|
|
139
|
+
if (this.peek()?.kind === "name") this.next();
|
|
140
|
+
this.skipParens();
|
|
141
|
+
this.skipDirectives();
|
|
142
|
+
this.operations.push(this.parseSelectionSet());
|
|
143
|
+
} else if (token.kind === "name" && token.value === "fragment") {
|
|
144
|
+
this.next();
|
|
145
|
+
const name = this.expect("name").value;
|
|
146
|
+
this.expect("name"); // `on`
|
|
147
|
+
this.parseTypeCondition();
|
|
148
|
+
this.skipDirectives();
|
|
149
|
+
this.fragments.set(name, this.parseSelectionSet());
|
|
150
|
+
} else {
|
|
151
|
+
throw new Error(`Unexpected ${token.kind} at top level`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A type condition is a single name, but `[T!]!`-shaped tokens appear in
|
|
158
|
+
// variable definitions handled by skipParens, so only the name form is here.
|
|
159
|
+
parseTypeCondition() {
|
|
160
|
+
this.expect("name");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
skipParens() {
|
|
164
|
+
if (this.peek()?.kind !== "(") return;
|
|
165
|
+
let depth = 0;
|
|
166
|
+
do {
|
|
167
|
+
const token = this.next();
|
|
168
|
+
if (token.kind === "(") depth += 1;
|
|
169
|
+
else if (token.kind === ")") depth -= 1;
|
|
170
|
+
else if (token.kind === "{") depth += 1;
|
|
171
|
+
else if (token.kind === "}") depth -= 1;
|
|
172
|
+
} while (depth > 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
skipDirectives() {
|
|
176
|
+
while (this.peek()?.kind === "@") {
|
|
177
|
+
this.next();
|
|
178
|
+
this.expect("name");
|
|
179
|
+
this.skipParens();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
parseSelectionSet() {
|
|
184
|
+
this.expect("{");
|
|
185
|
+
const selections = [];
|
|
186
|
+
while (this.peek()?.kind !== "}") {
|
|
187
|
+
selections.push(this.parseSelection());
|
|
188
|
+
}
|
|
189
|
+
this.expect("}");
|
|
190
|
+
return selections;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
parseSelection() {
|
|
194
|
+
if (this.peek().kind === "...") {
|
|
195
|
+
this.next();
|
|
196
|
+
const token = this.peek();
|
|
197
|
+
if (token?.kind === "name" && token.value !== "on") {
|
|
198
|
+
this.next();
|
|
199
|
+
this.skipDirectives();
|
|
200
|
+
return { kind: "spread", name: token.value };
|
|
201
|
+
}
|
|
202
|
+
if (token?.kind === "name") {
|
|
203
|
+
this.next();
|
|
204
|
+
this.parseTypeCondition();
|
|
205
|
+
}
|
|
206
|
+
this.skipDirectives();
|
|
207
|
+
return { kind: "inline", selections: this.parseSelectionSet() };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let name = this.expect("name").value;
|
|
211
|
+
if (this.peek()?.kind === ":") {
|
|
212
|
+
// An alias renames the field for the caller; the resolver only knows the
|
|
213
|
+
// name it declared, so the alias is dropped rather than reported.
|
|
214
|
+
this.next();
|
|
215
|
+
name = this.expect("name").value;
|
|
216
|
+
}
|
|
217
|
+
this.skipParens();
|
|
218
|
+
this.skipDirectives();
|
|
219
|
+
const selections = this.peek()?.kind === "{" ? this.parseSelectionSet() : null;
|
|
220
|
+
return { kind: "field", name, selections };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function collectFields(selections, fragments, seenFragments, into) {
|
|
225
|
+
for (const selection of selections) {
|
|
226
|
+
if (selection.kind === "field") {
|
|
227
|
+
if (selection.name === "__typename") continue;
|
|
228
|
+
const existing = into[selection.name] ?? {};
|
|
229
|
+
into[selection.name] = existing;
|
|
230
|
+
if (selection.selections !== null) {
|
|
231
|
+
collectFields(selection.selections, fragments, seenFragments, existing);
|
|
232
|
+
}
|
|
233
|
+
} else if (selection.kind === "inline") {
|
|
234
|
+
collectFields(selection.selections, fragments, seenFragments, into);
|
|
235
|
+
} else if (!seenFragments.has(selection.name)) {
|
|
236
|
+
const fragment = fragments.get(selection.name);
|
|
237
|
+
if (fragment !== undefined) {
|
|
238
|
+
seenFragments.add(selection.name);
|
|
239
|
+
collectFields(fragment, fragments, seenFragments, into);
|
|
240
|
+
seenFragments.delete(selection.name);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return into;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function findField(selections, fragments, fieldName, seenFragments, into) {
|
|
248
|
+
for (const selection of selections) {
|
|
249
|
+
if (selection.kind === "field") {
|
|
250
|
+
if (selection.name === fieldName && selection.selections !== null) {
|
|
251
|
+
collectFields(selection.selections, fragments, new Set(), into);
|
|
252
|
+
} else if (selection.selections !== null) {
|
|
253
|
+
findField(selection.selections, fragments, fieldName, seenFragments, into);
|
|
254
|
+
}
|
|
255
|
+
} else if (selection.kind === "inline") {
|
|
256
|
+
findField(selection.selections, fragments, fieldName, seenFragments, into);
|
|
257
|
+
} else if (!seenFragments.has(selection.name)) {
|
|
258
|
+
const fragment = fragments.get(selection.name);
|
|
259
|
+
if (fragment !== undefined) {
|
|
260
|
+
seenFragments.add(selection.name);
|
|
261
|
+
findField(fragment, fragments, fieldName, seenFragments, into);
|
|
262
|
+
seenFragments.delete(selection.name);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return into;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The selection tree for `fieldName`, as names and nesting.
|
|
271
|
+
*
|
|
272
|
+
* Hasura sends no operation name, so a document with more than one operation
|
|
273
|
+
* cannot be narrowed to the one that ran. Every field of that name is merged
|
|
274
|
+
* instead: a superset means a resolver does work it could have skipped, where
|
|
275
|
+
* a subset would have it skip work that was asked for.
|
|
276
|
+
*/
|
|
277
|
+
export function selectionFromRequestQuery(requestQuery, fieldName) {
|
|
278
|
+
if (typeof requestQuery !== "string" || requestQuery.length === 0) return {};
|
|
279
|
+
if (requestQuery.length > MAX_QUERY_BYTES) return {};
|
|
280
|
+
try {
|
|
281
|
+
const document = new Parser(lex(requestQuery)).parseDocument();
|
|
282
|
+
return findField(
|
|
283
|
+
document.operations.flat(),
|
|
284
|
+
document.fragments,
|
|
285
|
+
fieldName,
|
|
286
|
+
new Set(),
|
|
287
|
+
{}
|
|
288
|
+
);
|
|
289
|
+
} catch {
|
|
290
|
+
return {};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Hasura's action contract, translated to and from the dispatcher's.
|
|
2
|
+
//
|
|
3
|
+
// The two differ in the ways that matter to a client. Hasura splices the
|
|
4
|
+
// response body straight in as the field's value, so a result is returned bare
|
|
5
|
+
// rather than under `data`, and a failure is a status code rather than an
|
|
6
|
+
// `errors` array -- a 200 is taken as success whatever the body says.
|
|
7
|
+
//
|
|
8
|
+
// Pure, so the mapping is testable without a socket; `server.js` does the I/O.
|
|
9
|
+
|
|
10
|
+
// Hasura documents 4xx as *the* way a handler reports failure. A 5xx risks
|
|
11
|
+
// being reported as an unreachable webhook instead of the message below, so a
|
|
12
|
+
// resolver's intended status travels in `extensions.http.status` and every
|
|
13
|
+
// error leaves here as a 4xx.
|
|
14
|
+
const STATUS_BY_CODE = {
|
|
15
|
+
BAD_REQUEST: 400,
|
|
16
|
+
BAD_USER_INPUT: 400,
|
|
17
|
+
FORBIDDEN: 403,
|
|
18
|
+
RESOLVER_NOT_FOUND: 404,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_ERROR_STATUS = 400;
|
|
22
|
+
|
|
23
|
+
import { selectionFromRequestQuery } from "./graphqlSelection.js";
|
|
24
|
+
|
|
25
|
+
const isPlainObject = (value) =>
|
|
26
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validates a decoded action payload.
|
|
30
|
+
* Returns null when it is well formed.
|
|
31
|
+
*/
|
|
32
|
+
export function badActionRequest(payload) {
|
|
33
|
+
if (!isPlainObject(payload)) {
|
|
34
|
+
return "expected a JSON object";
|
|
35
|
+
}
|
|
36
|
+
if (!isPlainObject(payload.action) || typeof payload.action.name !== "string" || payload.action.name.length === 0) {
|
|
37
|
+
return "`action.name` must be a non-empty string";
|
|
38
|
+
}
|
|
39
|
+
if (payload.input !== undefined && !isPlainObject(payload.input)) {
|
|
40
|
+
return "`input` must be an object";
|
|
41
|
+
}
|
|
42
|
+
if (payload.session_variables !== undefined && !isPlainObject(payload.session_variables)) {
|
|
43
|
+
return "`session_variables` must be an object";
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Turns a validated action payload into the dispatcher's request.
|
|
50
|
+
*
|
|
51
|
+
* `requestId` is minted by the caller: Hasura sends none, and without one a
|
|
52
|
+
* failure in the logs cannot be tied back to the request that caused it.
|
|
53
|
+
*/
|
|
54
|
+
export function toResolveRequest(payload, requestId) {
|
|
55
|
+
const role = payload.session_variables?.["x-hasura-role"];
|
|
56
|
+
return {
|
|
57
|
+
field: payload.action.name,
|
|
58
|
+
args: payload.input ?? {},
|
|
59
|
+
selection: selectionFromRequestQuery(payload.request_query, payload.action.name),
|
|
60
|
+
// Hasura roles are arbitrary strings, and the dispatcher's are not. Only
|
|
61
|
+
// `admin` grants admin, so an unrecognised role is treated as public
|
|
62
|
+
// rather than rejected -- the same answer an anonymous caller would get.
|
|
63
|
+
role: role === "admin" ? "admin" : "public",
|
|
64
|
+
requestId,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Turns a dispatcher answer into the status and body Hasura expects. */
|
|
69
|
+
export function toActionResponse(answer) {
|
|
70
|
+
const failure = answer.errors?.[0];
|
|
71
|
+
if (failure !== undefined) {
|
|
72
|
+
const extensions = failure.extensions ?? {};
|
|
73
|
+
return {
|
|
74
|
+
status: STATUS_BY_CODE[extensions.code] ?? DEFAULT_ERROR_STATUS,
|
|
75
|
+
body: { message: failure.message, extensions },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { status: 200, body: answer.data ?? null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The error body for a request that never reached the dispatcher. */
|
|
82
|
+
export function actionErrorBody(message, code) {
|
|
83
|
+
return { message, extensions: { code } };
|
|
84
|
+
}
|