@webda/graphql 4.0.0-beta.6 → 4.0.0-beta.8
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/README.md +5 -4
- package/lib/graphql.service.d.ts +109 -3
- package/lib/graphql.service.d.ts.map +1 -1
- package/lib/graphql.service.js +347 -48
- package/lib/graphql.service.js.map +1 -1
- package/lib/mutations.d.ts +8 -0
- package/lib/mutations.d.ts.map +1 -1
- package/lib/mutations.js +27 -0
- package/lib/mutations.js.map +1 -1
- package/lib/names.d.ts +31 -0
- package/lib/names.d.ts.map +1 -0
- package/lib/names.js +78 -0
- package/lib/names.js.map +1 -0
- package/lib/operations.d.ts +110 -0
- package/lib/operations.d.ts.map +1 -0
- package/lib/operations.js +221 -0
- package/lib/operations.js.map +1 -0
- package/lib/types/any.d.ts.map +1 -1
- package/lib/types/any.js +27 -19
- package/lib/types/any.js.map +1 -1
- package/lib/types/date.d.ts.map +1 -1
- package/lib/types/date.js +4 -2
- package/lib/types/date.js.map +1 -1
- package/package.json +11 -6
- package/webda.module.json +10 -0
package/lib/graphql.service.js
CHANGED
|
@@ -32,10 +32,10 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
32
32
|
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
33
|
done = true;
|
|
34
34
|
};
|
|
35
|
-
import { DomainService, DomainServiceParameters, Route, WebdaError, useApplication, useCore, useCoreEvents, useModelMetadata, useRepository, isModelActionAllowed, queryModelWithPermissions, getClientWritableAttributes, assertNoPrivateFields, assertFilterOnly } from "@webda/core";
|
|
35
|
+
import { DomainService, DomainServiceParameters, Route, WebdaError, useApplication, useCore, useCoreEvents, useModelMetadata, useRepository, isModelActionAllowed, queryModelWithPermissions, getClientWritableAttributes, assertNoPrivateFields, assertFilterOnly, callOperation, canCallOperation, createOperationFilter, useInstanceStorage, runWithInstanceStorage, emitCoreEvent, runWithContext, useRouter } from "@webda/core";
|
|
36
36
|
import * as WebdaQL from "@webda/ql";
|
|
37
37
|
import { EventIterator, MergedIterator } from "@webda/runtime";
|
|
38
|
-
import { GraphQLBoolean, GraphQLError, GraphQLInputObjectType, GraphQLList, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema } from "graphql";
|
|
38
|
+
import { GraphQLBoolean, GraphQLError, GraphQLInputObjectType, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema } from "graphql";
|
|
39
39
|
import { createHandler } from "graphql-http";
|
|
40
40
|
import { CloseCode, makeServer } from "graphql-ws";
|
|
41
41
|
import { nextTick } from "process";
|
|
@@ -43,7 +43,9 @@ import { WebSocketServer } from "ws";
|
|
|
43
43
|
import { AnyScalarType } from "./types/any.js";
|
|
44
44
|
import { DateScalar } from "./types/date.js";
|
|
45
45
|
import { GraphQLLong } from "./types/long.js";
|
|
46
|
-
import {
|
|
46
|
+
import { graphQLNames, restoreInputKeys, WEBDA_KEY } from "./names.js";
|
|
47
|
+
import { createFromInput, isInputAttribute, loadForAction, operationError, updateFromInput } from "./mutations.js";
|
|
48
|
+
import { GRAPHQL_NAME, GraphQLOperationContext, graphqlPlacement, operationTypeName, publicCopy } from "./operations.js";
|
|
47
49
|
const GraphIQL = `
|
|
48
50
|
<!doctype html>
|
|
49
51
|
<html lang="en">
|
|
@@ -140,6 +142,7 @@ export class GraphQLParameters extends DomainServiceParameters {
|
|
|
140
142
|
this.userModel ?? (this.userModel = "User");
|
|
141
143
|
this.exposeMe ?? (this.exposeMe = true);
|
|
142
144
|
this.globalSubscription ?? (this.globalSubscription = true);
|
|
145
|
+
this.exposeOperations ?? (this.exposeOperations = ["*"]);
|
|
143
146
|
return this;
|
|
144
147
|
}
|
|
145
148
|
}
|
|
@@ -161,6 +164,10 @@ let GraphQLService = (() => {
|
|
|
161
164
|
constructor() {
|
|
162
165
|
super(...arguments);
|
|
163
166
|
this.schema = __runInitializers(this, _instanceExtraInitializers);
|
|
167
|
+
/**
|
|
168
|
+
* Generated named types, by name: a name is generated once per shape (reset by generateSchema)
|
|
169
|
+
*/
|
|
170
|
+
this.namedTypes = new Map();
|
|
164
171
|
}
|
|
165
172
|
/**
|
|
166
173
|
* Load and initialize GraphQL service parameters
|
|
@@ -170,6 +177,14 @@ let GraphQLService = (() => {
|
|
|
170
177
|
loadParameters(params) {
|
|
171
178
|
return new GraphQLParameters().load(params);
|
|
172
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Parameters with their defaults (the core builds a service configuration through this, not loadParameters)
|
|
182
|
+
* @param params - raw partial configuration
|
|
183
|
+
* @returns initialized GraphQLParameters
|
|
184
|
+
*/
|
|
185
|
+
static createConfiguration(params = {}) {
|
|
186
|
+
return new GraphQLParameters().load(params);
|
|
187
|
+
}
|
|
173
188
|
/**
|
|
174
189
|
* Resolve a JSON Schema property by following $ref pointers
|
|
175
190
|
* @param prop - JSON Schema property that may contain $ref
|
|
@@ -188,6 +203,41 @@ let GraphQLService = (() => {
|
|
|
188
203
|
}
|
|
189
204
|
return prop || { type: "null" };
|
|
190
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* One GraphQL type per name: reuse the type already generated for this name and shape
|
|
208
|
+
* @param name - type name
|
|
209
|
+
* @param shape - what makes two types of this name the same (kind and field names)
|
|
210
|
+
* @param create - builds the type the first time
|
|
211
|
+
* @returns the type
|
|
212
|
+
* @throws Error when the name was generated for another shape
|
|
213
|
+
*/
|
|
214
|
+
namedType(name, shape, create) {
|
|
215
|
+
this.namedTypes ?? (this.namedTypes = new Map());
|
|
216
|
+
const known = this.namedTypes.get(name);
|
|
217
|
+
if (known) {
|
|
218
|
+
if (known.shape !== shape) {
|
|
219
|
+
throw new Error(`GraphQL type ${name} is generated for two different schemas (${known.shape} / ${shape}): give one of them another title`);
|
|
220
|
+
}
|
|
221
|
+
return known.type;
|
|
222
|
+
}
|
|
223
|
+
const type = create();
|
|
224
|
+
this.namedTypes.set(name, { type, shape });
|
|
225
|
+
return type;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Reserve a type name for a type built later (its entry gets the type once built)
|
|
229
|
+
* @param name - type name
|
|
230
|
+
* @param shape - identity of the reserving type
|
|
231
|
+
* @throws Error when the name is already taken by another shape
|
|
232
|
+
*/
|
|
233
|
+
reserveName(name, shape) {
|
|
234
|
+
this.namedTypes ?? (this.namedTypes = new Map());
|
|
235
|
+
const known = this.namedTypes.get(name);
|
|
236
|
+
if (known && known.shape !== shape) {
|
|
237
|
+
throw new Error(`GraphQL type ${name} is generated for two different schemas (${known.shape} / ${shape}): give one of them another title`);
|
|
238
|
+
}
|
|
239
|
+
this.namedTypes.set(name, known || { type: undefined, shape });
|
|
240
|
+
}
|
|
191
241
|
/**
|
|
192
242
|
* Convert a JSON Schema to a GraphQL type definition
|
|
193
243
|
* @param schema - JSON Schema to convert
|
|
@@ -196,6 +246,17 @@ let GraphQLService = (() => {
|
|
|
196
246
|
* @returns object with the GraphQL type and optional description, or undefined for null types
|
|
197
247
|
*/
|
|
198
248
|
getGraphQLSchemaFromSchema(schema, defaultName, input) {
|
|
249
|
+
// `type: ["string", "null"]`: the nullable form of one type (GraphQL fields are nullable already)
|
|
250
|
+
if (Array.isArray(schema?.type)) {
|
|
251
|
+
const types = schema.type.filter(t => t !== "null");
|
|
252
|
+
if (types.length === 0) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
if (types.length > 1) {
|
|
256
|
+
return { type: AnyScalarType, description: schema.description };
|
|
257
|
+
}
|
|
258
|
+
return this.getGraphQLSchemaFromSchema({ ...schema, type: types[0] }, defaultName, input);
|
|
259
|
+
}
|
|
199
260
|
let type;
|
|
200
261
|
if (!schema || !schema.type) {
|
|
201
262
|
type = AnyScalarType;
|
|
@@ -228,27 +289,57 @@ let GraphQLService = (() => {
|
|
|
228
289
|
if (!schema.properties) {
|
|
229
290
|
return { type: AnyScalarType, description: "Map" };
|
|
230
291
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
const res = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema.properties[i], schema.definitions), `${schema.title || defaultName}_${i}`, input);
|
|
292
|
+
// Private (`__`) fields are server-only, at any depth
|
|
293
|
+
const names = this.graphQLNames(Object.keys(schema.properties).filter(i => !i.startsWith("__")), schema.title || defaultName);
|
|
294
|
+
for (const [i, field] of names) {
|
|
295
|
+
const res = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema.properties[i], schema.definitions), `${schema.title || defaultName}_${field}`, input);
|
|
237
296
|
if (!res)
|
|
238
297
|
continue;
|
|
239
|
-
fields[
|
|
298
|
+
fields[field] = this.renamedField(res, i, field, input);
|
|
240
299
|
}
|
|
241
300
|
if (Object.keys(fields).length === 0) {
|
|
242
301
|
this.log("DEBUG", "Return map for", defaultName, "because no fields");
|
|
243
302
|
return { type: AnyScalarType, description: "Map" };
|
|
244
303
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
304
|
+
// A titled input type gets its own name: the output type of the same title already uses the title
|
|
305
|
+
const name = schema.title ? (input ? `${schema.title}Input` : schema.title) : defaultName;
|
|
306
|
+
type = this.namedType(name, `${input ? "input" : "output"}:${Object.keys(fields)
|
|
307
|
+
.sort()
|
|
308
|
+
.map(k => `${k}:${fields[k].type}`)
|
|
309
|
+
.join(",")}`, () => (input ? new GraphQLInputObjectType({ fields, name }) : new GraphQLObjectType({ fields, name })));
|
|
249
310
|
}
|
|
250
311
|
return { type, description: schema.description };
|
|
251
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* GraphQL names of the properties of a type, see {@link graphQLNames}: a JSON key that is not a GraphQL name
|
|
315
|
+
* (`available-balance`) is exposed as `availableBalance`, unless that name is taken (logged, left out)
|
|
316
|
+
* @param keys - the property keys
|
|
317
|
+
* @param type - the type holding them, for the log
|
|
318
|
+
* @returns the GraphQL name of each key exposed
|
|
319
|
+
*/
|
|
320
|
+
graphQLNames(keys, type) {
|
|
321
|
+
return graphQLNames(keys, (key, name) =>
|
|
322
|
+
// The real property (or the first key renamed so) keeps the name: this key stays REST only
|
|
323
|
+
this.log("INFO", `Property '${key}' of ${type} is left out of GraphQL: its name '${name}' is already used`));
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* A field renamed from its JSON key reads that key (output), and carries it to be put back on inputs
|
|
327
|
+
* ({@link restoreInputKeys})
|
|
328
|
+
* @param config - the field config
|
|
329
|
+
* @param key - the JSON key
|
|
330
|
+
* @param name - the GraphQL name
|
|
331
|
+
* @param input - an input type field
|
|
332
|
+
* @returns the field config
|
|
333
|
+
*/
|
|
334
|
+
renamedField(config, key, name, input) {
|
|
335
|
+
if (key === name)
|
|
336
|
+
return config;
|
|
337
|
+
return {
|
|
338
|
+
...config,
|
|
339
|
+
extensions: { ...config.extensions, [WEBDA_KEY]: key },
|
|
340
|
+
...(input ? {} : { resolve: (source) => source?.[key] })
|
|
341
|
+
};
|
|
342
|
+
}
|
|
252
343
|
/**
|
|
253
344
|
* Get or create a GraphQL query result wrapper type with results array and continuationToken
|
|
254
345
|
* @param type - GraphQL object type to wrap in a query result
|
|
@@ -359,22 +450,20 @@ let GraphQLService = (() => {
|
|
|
359
450
|
};
|
|
360
451
|
});
|
|
361
452
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
453
|
+
// Private (`__`) fields are server-only: never part of an output or input type
|
|
454
|
+
const names = this.graphQLNames(Object.keys(schema.properties ?? {}).filter(i => !i.startsWith("__")), defaultName);
|
|
455
|
+
for (const [i, field] of names) {
|
|
367
456
|
// Was initiated by the known graph; inputs never carry private or behavior attributes
|
|
368
|
-
if (fields[
|
|
457
|
+
if (fields[field] ||
|
|
369
458
|
skipFields.includes(i) ||
|
|
370
459
|
(input && (schema.properties[i].readOnly || !isInputAttribute(i, webdaGraph, writable)))) {
|
|
371
460
|
continue;
|
|
372
461
|
}
|
|
373
|
-
const prop = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema.properties[i], schema.definitions), `${defaultName}_${
|
|
462
|
+
const prop = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema.properties[i], schema.definitions), `${defaultName}_${field}`, input);
|
|
374
463
|
if (!prop) {
|
|
375
464
|
continue;
|
|
376
465
|
}
|
|
377
|
-
fields[
|
|
466
|
+
fields[field] = this.renamedField(prop, i, field, input);
|
|
378
467
|
}
|
|
379
468
|
return fields;
|
|
380
469
|
}
|
|
@@ -490,17 +579,40 @@ let GraphQLService = (() => {
|
|
|
490
579
|
super.resolve();
|
|
491
580
|
// Set-up ws server
|
|
492
581
|
this.wss = new WebSocketServer({ noServer: true });
|
|
582
|
+
// The HTTP server events fire outside the instance storage: re-enter it
|
|
583
|
+
const storage = useInstanceStorage();
|
|
493
584
|
useCoreEvents("Webda.Init.Http", (http) => {
|
|
494
585
|
http.on("upgrade", (req, socket, head) => {
|
|
495
|
-
|
|
586
|
+
runWithInstanceStorage(storage, () => {
|
|
587
|
+
if (req.url !== this.parameters.url)
|
|
588
|
+
return;
|
|
496
589
|
(async () => {
|
|
497
|
-
|
|
498
|
-
|
|
590
|
+
const httpServer = Object.values(useCore().getServices()).find(s => s.server === http && typeof s.getContextFromRequest === "function");
|
|
591
|
+
req.webdaContext ?? (req.webdaContext = await (httpServer ?? useCore()).getContextFromRequest(req));
|
|
592
|
+
const context = req.webdaContext;
|
|
593
|
+
await context.init();
|
|
594
|
+
// Same checks as a normal request: Webda.Request listeners and the router request filters
|
|
595
|
+
const allowed = await runWithContext(context, async () => {
|
|
596
|
+
try {
|
|
597
|
+
emitCoreEvent("Webda.Request", { context });
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
// listener error
|
|
601
|
+
}
|
|
602
|
+
return useRouter().checkRequest(context);
|
|
603
|
+
});
|
|
604
|
+
if (!allowed) {
|
|
605
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
499
608
|
this.wss.handleUpgrade(req, socket, head, ws => {
|
|
500
609
|
this.wss.emit("connection", ws, req);
|
|
501
610
|
});
|
|
502
|
-
})()
|
|
503
|
-
|
|
611
|
+
})().catch(err => {
|
|
612
|
+
this.log("ERROR", "GraphQL WebSocket upgrade failed", err);
|
|
613
|
+
socket.destroy();
|
|
614
|
+
});
|
|
615
|
+
});
|
|
504
616
|
});
|
|
505
617
|
});
|
|
506
618
|
return this;
|
|
@@ -514,6 +626,10 @@ let GraphQLService = (() => {
|
|
|
514
626
|
const subscriptions = {};
|
|
515
627
|
const models = this.app.getModels();
|
|
516
628
|
this.modelsMap = {};
|
|
629
|
+
this.namedTypes = new Map();
|
|
630
|
+
// First pass: reserve every exposed model name (and its Input) before any type is built, so a nested
|
|
631
|
+
// titled type of the same name fails whatever the model order
|
|
632
|
+
const exposed = [];
|
|
517
633
|
for (const i in models) {
|
|
518
634
|
const model = models[i];
|
|
519
635
|
const metadata = useModelMetadata(model);
|
|
@@ -534,6 +650,11 @@ let GraphQLService = (() => {
|
|
|
534
650
|
if (!schema)
|
|
535
651
|
continue;
|
|
536
652
|
const name = (metadata.ShortName || i.split("/").pop()).replace("/", "_");
|
|
653
|
+
this.reserveName(name, `model ${i}`);
|
|
654
|
+
this.reserveName(`${name}Input`, `model input ${i}`);
|
|
655
|
+
exposed.push({ i, model, metadata, schema, name });
|
|
656
|
+
}
|
|
657
|
+
for (const { i, model, metadata, schema, name } of exposed) {
|
|
537
658
|
this.log("INFO", "Add GraphQL type", name);
|
|
538
659
|
const modelGraph = metadata.Relations;
|
|
539
660
|
this.modelsMap[i] = new GraphQLObjectType({
|
|
@@ -544,6 +665,8 @@ let GraphQLService = (() => {
|
|
|
544
665
|
fields: this.getGraphQLFieldsFromSchema(schema, name + "Input", modelGraph, true, getClientWritableAttributes(model)),
|
|
545
666
|
name: name + "Input"
|
|
546
667
|
});
|
|
668
|
+
this.namedTypes.get(name).type = this.modelsMap[i];
|
|
669
|
+
this.namedTypes.get(`${name}Input`).type = input;
|
|
547
670
|
const actionsName = Object.keys(metadata.Actions);
|
|
548
671
|
if (!actionsName.includes("create")) {
|
|
549
672
|
mutations[`create${name}`] = {
|
|
@@ -553,7 +676,7 @@ let GraphQLService = (() => {
|
|
|
553
676
|
},
|
|
554
677
|
resolve: async (_, args, context) => {
|
|
555
678
|
this.log("INFO", "Create", name, context.getCurrentUserId());
|
|
556
|
-
return createFromInput(model, args[name], context);
|
|
679
|
+
return createFromInput(model, restoreInputKeys(input, args[name]), context);
|
|
557
680
|
}
|
|
558
681
|
};
|
|
559
682
|
}
|
|
@@ -566,7 +689,7 @@ let GraphQLService = (() => {
|
|
|
566
689
|
type: GraphQLString
|
|
567
690
|
}
|
|
568
691
|
},
|
|
569
|
-
resolve: async (_, args, context) => updateFromInput(model, args.uuid, args[name], context)
|
|
692
|
+
resolve: async (_, args, context) => updateFromInput(model, args.uuid, restoreInputKeys(input, args[name]), context)
|
|
570
693
|
};
|
|
571
694
|
}
|
|
572
695
|
// check for actions
|
|
@@ -619,7 +742,8 @@ let GraphQLService = (() => {
|
|
|
619
742
|
},
|
|
620
743
|
subscribe: async (_source, args, context) => {
|
|
621
744
|
this.log("DEBUG", "Subscription called on", args);
|
|
622
|
-
|
|
745
|
+
// The payload is keyed by the field name: the default resolver reads it from there
|
|
746
|
+
return this.registerAsyncIterator(model, args[uuidField], context, this.transformName(i.split("/").pop()));
|
|
623
747
|
}
|
|
624
748
|
};
|
|
625
749
|
const events = metadata.Events || [];
|
|
@@ -776,6 +900,9 @@ let GraphQLService = (() => {
|
|
|
776
900
|
for (const i in rootFields) {
|
|
777
901
|
subscriptions[i] ?? (subscriptions[i] = { ...rootFields[i], resolve: undefined });
|
|
778
902
|
}
|
|
903
|
+
// Operations (service operations, model and behaviour actions) next to the model schema; added after the copy
|
|
904
|
+
// above on purpose: a query operation is not a subscription
|
|
905
|
+
this.addOperationFields(rootFields, mutations, subscriptions);
|
|
779
906
|
if (this.parameters.globalSubscription) {
|
|
780
907
|
// Create global type based on all previous subscriptions
|
|
781
908
|
subscriptions["Aggregate"] = {
|
|
@@ -811,6 +938,153 @@ let GraphQLService = (() => {
|
|
|
811
938
|
}
|
|
812
939
|
this.schema = this.getGraphQLSchema(rootFields, mutations, subscriptions);
|
|
813
940
|
}
|
|
941
|
+
/**
|
|
942
|
+
* Expose the registered operations, except the model CRUD the model schema already serves: read-only ones as
|
|
943
|
+
* queries, the others as mutations, server-streaming ones as subscriptions
|
|
944
|
+
* @param rootFields - query fields
|
|
945
|
+
* @param mutations - mutation fields
|
|
946
|
+
* @param subscriptions - subscription fields
|
|
947
|
+
* @throws Error when a field name is already defined
|
|
948
|
+
*/
|
|
949
|
+
addOperationFields(rootFields, mutations, subscriptions) {
|
|
950
|
+
const exposed = createOperationFilter(this.parameters.exposeOperations);
|
|
951
|
+
const targets = { query: rootFields, mutation: mutations, subscription: subscriptions };
|
|
952
|
+
const owners = new Map();
|
|
953
|
+
const operations = useInstanceStorage().operations;
|
|
954
|
+
for (const id of Object.keys(operations).sort()) {
|
|
955
|
+
const op = operations[id];
|
|
956
|
+
if (!exposed(id))
|
|
957
|
+
continue;
|
|
958
|
+
const placement = graphqlPlacement(id, op);
|
|
959
|
+
if (!placement)
|
|
960
|
+
continue;
|
|
961
|
+
const target = targets[placement.kind];
|
|
962
|
+
const key = `${placement.kind}.${placement.name}`;
|
|
963
|
+
if (placement.kind === "subscription" && placement.name === "Aggregate" && this.parameters.globalSubscription) {
|
|
964
|
+
throw new Error(`GraphQL subscription field Aggregate of operation ${id} is already defined by the global Aggregate subscription (globalSubscription): rename it with @Operation({ graphql: { subscription: "…" } })`);
|
|
965
|
+
}
|
|
966
|
+
if (target[placement.name]) {
|
|
967
|
+
throw new Error(`GraphQL ${placement.kind} field ${placement.name} of operation ${id} is already defined by ${owners.get(key) ?? "the model schema"}: rename it with @Operation({ graphql: { ${placement.kind}: "…" } })`);
|
|
968
|
+
}
|
|
969
|
+
owners.set(key, `operation ${id}`);
|
|
970
|
+
const base = operationTypeName(id);
|
|
971
|
+
const { args, toInput } = this.operationArgs(op, base);
|
|
972
|
+
const type = this.operationOutputType(op, base);
|
|
973
|
+
const description = op.description || op.summary;
|
|
974
|
+
target[placement.name] =
|
|
975
|
+
placement.kind === "subscription"
|
|
976
|
+
? {
|
|
977
|
+
type,
|
|
978
|
+
args,
|
|
979
|
+
description,
|
|
980
|
+
subscribe: async (_source, fieldArgs, context) => this.subscribeOperation(id, op, toInput(fieldArgs), context),
|
|
981
|
+
resolve: (chunk) => chunk
|
|
982
|
+
}
|
|
983
|
+
: {
|
|
984
|
+
type,
|
|
985
|
+
args,
|
|
986
|
+
description,
|
|
987
|
+
resolve: async (_source, fieldArgs, context) => this.runOperation(id, op, toInput(fieldArgs), context)
|
|
988
|
+
};
|
|
989
|
+
this.log("INFO", `Add GraphQL ${placement.kind}`, placement.name, "for operation", id);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* GraphQL arguments of an operation: one per input schema property, or a single `input` argument
|
|
994
|
+
* @param op - operation
|
|
995
|
+
* @param base - type name prefix
|
|
996
|
+
* @returns the arguments and how to turn them back into the operation input
|
|
997
|
+
*/
|
|
998
|
+
operationArgs(op, base) {
|
|
999
|
+
const schema = op.input && op.input !== "void" ? this.app.getSchema(op.input) : undefined;
|
|
1000
|
+
if (!schema) {
|
|
1001
|
+
return { args: {}, toInput: () => undefined };
|
|
1002
|
+
}
|
|
1003
|
+
const resolved = this.getJsonSchemaDefinition(schema, schema.definitions);
|
|
1004
|
+
const names = Object.keys(resolved.properties ?? {}).filter(name => !name.startsWith("__"));
|
|
1005
|
+
if (resolved.type === "object" && names.length > 0 && names.every(name => GRAPHQL_NAME.test(name))) {
|
|
1006
|
+
const required = new Set(resolved.required ?? []);
|
|
1007
|
+
const args = {};
|
|
1008
|
+
for (const name of names) {
|
|
1009
|
+
const converted = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(resolved.properties[name], resolved.definitions ?? schema.definitions), `${base}Input_${name}`, true);
|
|
1010
|
+
// A property the converter cannot map still is an argument: the Object scalar takes anything
|
|
1011
|
+
const type = (converted?.type ?? AnyScalarType);
|
|
1012
|
+
args[name] = {
|
|
1013
|
+
type: required.has(name) ? new GraphQLNonNull(type) : type,
|
|
1014
|
+
description: converted?.description
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
return {
|
|
1018
|
+
args,
|
|
1019
|
+
toInput: fieldArgs => Object.fromEntries(Object.entries(fieldArgs).map(([k, v]) => [k, args[k] ? restoreInputKeys(args[k].type, v) : v]))
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
const converted = this.getGraphQLSchemaFromSchema(resolved, `${base}Input`, true);
|
|
1023
|
+
const type = (converted?.type ?? AnyScalarType);
|
|
1024
|
+
return {
|
|
1025
|
+
args: { input: { type } },
|
|
1026
|
+
toInput: fieldArgs => restoreInputKeys(type, fieldArgs.input)
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* GraphQL type of an operation result
|
|
1031
|
+
* @param op - operation
|
|
1032
|
+
* @param base - type name prefix
|
|
1033
|
+
* @returns the model type for a model output, Boolean for void, else the converted output schema
|
|
1034
|
+
*/
|
|
1035
|
+
operationOutputType(op, base) {
|
|
1036
|
+
if (!op.output || op.output === "void") {
|
|
1037
|
+
return GraphQLBoolean;
|
|
1038
|
+
}
|
|
1039
|
+
if (this.modelsMap[op.output]) {
|
|
1040
|
+
return this.modelsMap[op.output];
|
|
1041
|
+
}
|
|
1042
|
+
const schema = this.app.getSchema(op.output);
|
|
1043
|
+
const converted = schema &&
|
|
1044
|
+
this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema, schema.definitions), `${base}Output`);
|
|
1045
|
+
return (converted?.type ?? AnyScalarType);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Run an operation for a query or mutation field
|
|
1049
|
+
* @param id - operation id
|
|
1050
|
+
* @param op - operation
|
|
1051
|
+
* @param input - operation input
|
|
1052
|
+
* @param context - the GraphQL request context
|
|
1053
|
+
* @returns the field value
|
|
1054
|
+
*/
|
|
1055
|
+
async runOperation(id, op, input, context) {
|
|
1056
|
+
this.countOperation(context);
|
|
1057
|
+
const ctx = new GraphQLOperationContext(context, input);
|
|
1058
|
+
await ctx.init();
|
|
1059
|
+
try {
|
|
1060
|
+
await callOperation(ctx, id);
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
throw operationError(err);
|
|
1064
|
+
}
|
|
1065
|
+
if (!op.output || op.output === "void") {
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
return this.modelsMap[op.output] ? ctx.result : publicCopy(ctx.result);
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Start a server-streaming operation for a subscription field
|
|
1072
|
+
* @param id - operation id
|
|
1073
|
+
* @param op - operation
|
|
1074
|
+
* @param input - operation input
|
|
1075
|
+
* @param context - the GraphQL request context
|
|
1076
|
+
* @returns the chunks; leaving the subscription cancels the operation
|
|
1077
|
+
*/
|
|
1078
|
+
async subscribeOperation(id, op, input, context) {
|
|
1079
|
+
this.countOperation(context);
|
|
1080
|
+
const ctx = new GraphQLOperationContext(context, input, this.modelsMap[op.output] ? chunk => chunk : publicCopy);
|
|
1081
|
+
await ctx.init();
|
|
1082
|
+
if (!(await canCallOperation(ctx, id, { input }))) {
|
|
1083
|
+
throw operationError(new WebdaError.Forbidden(`${id} PermissionDenied`));
|
|
1084
|
+
}
|
|
1085
|
+
callOperation(ctx, id).then(() => ctx.finish(), err => ctx.fail(err));
|
|
1086
|
+
return ctx.chunks();
|
|
1087
|
+
}
|
|
814
1088
|
/**
|
|
815
1089
|
* Extract the list of event names from the GraphQL selection set (excluding latestEventTime)
|
|
816
1090
|
* @param info - GraphQL resolve info containing field selections
|
|
@@ -980,11 +1254,24 @@ let GraphQLService = (() => {
|
|
|
980
1254
|
*/
|
|
981
1255
|
async registerAsyncEventIterator(model, uuid, events, context, identifier) {
|
|
982
1256
|
const updatedCallback = eventName => async (evt) => {
|
|
983
|
-
|
|
1257
|
+
// A class-wide subscription (uuid null) receives the events of every object of the model
|
|
1258
|
+
if (uuid !== null && evt.object_id !== uuid)
|
|
984
1259
|
return;
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1260
|
+
if (uuid === null) {
|
|
1261
|
+
// Only the events of objects the subscriber can read: an object that is gone or unreadable is skipped
|
|
1262
|
+
let object = evt.object;
|
|
1263
|
+
if (!object) {
|
|
1264
|
+
try {
|
|
1265
|
+
object = await model.ref(evt.object_id).get();
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (!object || !(await isModelActionAllowed(object, context, "get", model)))
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
return { latestEventTime: Date.now(), [eventName]: publicCopy(evt) };
|
|
988
1275
|
};
|
|
989
1276
|
const eventsMap = {};
|
|
990
1277
|
if (uuid !== null) {
|
|
@@ -1008,6 +1295,30 @@ let GraphQLService = (() => {
|
|
|
1008
1295
|
logout: { evt: "nok?" }
|
|
1009
1296
|
}).iterate();
|
|
1010
1297
|
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Hand a WebSocket message to graphql-ws; a failure is logged and closes the socket without leaking details
|
|
1300
|
+
* @param socket - the WebSocket
|
|
1301
|
+
* @param cb - the graphql-ws message handler
|
|
1302
|
+
* @param data - the message
|
|
1303
|
+
* @param storage - the instance storage to re-enter for the resolvers
|
|
1304
|
+
* @returns a promise settled once the message is handled
|
|
1305
|
+
*/
|
|
1306
|
+
async handleSocketMessage(socket, cb, data, storage) {
|
|
1307
|
+
try {
|
|
1308
|
+
// wait for the the operation to complete
|
|
1309
|
+
// - if init message, waits for connect
|
|
1310
|
+
// - if query/mutation, waits for result
|
|
1311
|
+
// - if subscription, waits for complete
|
|
1312
|
+
// The socket events fire outside the instance storage: re-enter it for the resolvers
|
|
1313
|
+
await runWithInstanceStorage(storage, () => cb(data));
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
// all errors that could be thrown during the execution of operations will be caught here
|
|
1317
|
+
this.log("ERROR", "GraphQL websocket message failed", err);
|
|
1318
|
+
// A close reason is limited to 123 bytes and must not leak internal details
|
|
1319
|
+
socket.close(CloseCode.InternalServerError, "Internal server error");
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1011
1322
|
/**
|
|
1012
1323
|
* Assemble the final GraphQLSchema from root query fields, mutations, and subscriptions
|
|
1013
1324
|
* @param rootFields - top-level query fields
|
|
@@ -1059,6 +1370,7 @@ let GraphQLService = (() => {
|
|
|
1059
1370
|
return req.extra?.context || {};
|
|
1060
1371
|
}
|
|
1061
1372
|
});
|
|
1373
|
+
const storage = useInstanceStorage();
|
|
1062
1374
|
this.wss.on("connection", async (socket, request) => {
|
|
1063
1375
|
// a new socket opened, let graphql-ws take over
|
|
1064
1376
|
const closed = this.wsHandler.opened({
|
|
@@ -1067,20 +1379,7 @@ let GraphQLService = (() => {
|
|
|
1067
1379
|
socket.send(data, err => (err ? reject(err) : resolve()));
|
|
1068
1380
|
}), // control your data flow by timing the promise resolve
|
|
1069
1381
|
close: (code, reason) => socket.close(code, reason), // there are protocol standard closures
|
|
1070
|
-
onMessage: cb => socket.on("message",
|
|
1071
|
-
try {
|
|
1072
|
-
// wait for the the operation to complete
|
|
1073
|
-
// - if init message, waits for connect
|
|
1074
|
-
// - if query/mutation, waits for result
|
|
1075
|
-
// - if subscription, waits for complete
|
|
1076
|
-
await cb(event.toString());
|
|
1077
|
-
}
|
|
1078
|
-
catch (err) {
|
|
1079
|
-
// all errors that could be thrown during the
|
|
1080
|
-
// execution of operations will be caught here
|
|
1081
|
-
socket.close(CloseCode.InternalServerError, err.message);
|
|
1082
|
-
}
|
|
1083
|
-
})
|
|
1382
|
+
onMessage: cb => socket.on("message", event => this.handleSocketMessage(socket, cb, event.toString(), storage))
|
|
1084
1383
|
},
|
|
1085
1384
|
// pass values to the `extra` field in the context
|
|
1086
1385
|
{ socket, request, context: request.webdaContext });
|