@webda/graphql 4.0.0-beta.5 → 4.0.0-beta.7
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 +114 -4
- package/lib/graphql.service.d.ts.map +1 -1
- package/lib/graphql.service.js +340 -33
- 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/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 } 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,8 @@ 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 { createFromInput, isInputAttribute, loadForAction, updateFromInput } from "./mutations.js";
|
|
46
|
+
import { createFromInput, isInputAttribute, loadForAction, operationError, updateFromInput } from "./mutations.js";
|
|
47
|
+
import { GRAPHQL_NAME, GraphQLOperationContext, graphqlPlacement, operationTypeName, publicCopy } from "./operations.js";
|
|
47
48
|
const GraphIQL = `
|
|
48
49
|
<!doctype html>
|
|
49
50
|
<html lang="en">
|
|
@@ -140,6 +141,7 @@ export class GraphQLParameters extends DomainServiceParameters {
|
|
|
140
141
|
this.userModel ?? (this.userModel = "User");
|
|
141
142
|
this.exposeMe ?? (this.exposeMe = true);
|
|
142
143
|
this.globalSubscription ?? (this.globalSubscription = true);
|
|
144
|
+
this.exposeOperations ?? (this.exposeOperations = ["*"]);
|
|
143
145
|
return this;
|
|
144
146
|
}
|
|
145
147
|
}
|
|
@@ -161,6 +163,10 @@ let GraphQLService = (() => {
|
|
|
161
163
|
constructor() {
|
|
162
164
|
super(...arguments);
|
|
163
165
|
this.schema = __runInitializers(this, _instanceExtraInitializers);
|
|
166
|
+
/**
|
|
167
|
+
* Generated named types, by name: a name is generated once per shape (reset by generateSchema)
|
|
168
|
+
*/
|
|
169
|
+
this.namedTypes = new Map();
|
|
164
170
|
}
|
|
165
171
|
/**
|
|
166
172
|
* Load and initialize GraphQL service parameters
|
|
@@ -170,6 +176,14 @@ let GraphQLService = (() => {
|
|
|
170
176
|
loadParameters(params) {
|
|
171
177
|
return new GraphQLParameters().load(params);
|
|
172
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Parameters with their defaults (the core builds a service configuration through this, not loadParameters)
|
|
181
|
+
* @param params - raw partial configuration
|
|
182
|
+
* @returns initialized GraphQLParameters
|
|
183
|
+
*/
|
|
184
|
+
static createConfiguration(params = {}) {
|
|
185
|
+
return new GraphQLParameters().load(params);
|
|
186
|
+
}
|
|
173
187
|
/**
|
|
174
188
|
* Resolve a JSON Schema property by following $ref pointers
|
|
175
189
|
* @param prop - JSON Schema property that may contain $ref
|
|
@@ -188,6 +202,41 @@ let GraphQLService = (() => {
|
|
|
188
202
|
}
|
|
189
203
|
return prop || { type: "null" };
|
|
190
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* One GraphQL type per name: reuse the type already generated for this name and shape
|
|
207
|
+
* @param name - type name
|
|
208
|
+
* @param shape - what makes two types of this name the same (kind and field names)
|
|
209
|
+
* @param create - builds the type the first time
|
|
210
|
+
* @returns the type
|
|
211
|
+
* @throws Error when the name was generated for another shape
|
|
212
|
+
*/
|
|
213
|
+
namedType(name, shape, create) {
|
|
214
|
+
this.namedTypes ?? (this.namedTypes = new Map());
|
|
215
|
+
const known = this.namedTypes.get(name);
|
|
216
|
+
if (known) {
|
|
217
|
+
if (known.shape !== shape) {
|
|
218
|
+
throw new Error(`GraphQL type ${name} is generated for two different schemas (${known.shape} / ${shape}): give one of them another title`);
|
|
219
|
+
}
|
|
220
|
+
return known.type;
|
|
221
|
+
}
|
|
222
|
+
const type = create();
|
|
223
|
+
this.namedTypes.set(name, { type, shape });
|
|
224
|
+
return type;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Reserve a type name for a type built later (its entry gets the type once built)
|
|
228
|
+
* @param name - type name
|
|
229
|
+
* @param shape - identity of the reserving type
|
|
230
|
+
* @throws Error when the name is already taken by another shape
|
|
231
|
+
*/
|
|
232
|
+
reserveName(name, shape) {
|
|
233
|
+
this.namedTypes ?? (this.namedTypes = new Map());
|
|
234
|
+
const known = this.namedTypes.get(name);
|
|
235
|
+
if (known && known.shape !== shape) {
|
|
236
|
+
throw new Error(`GraphQL type ${name} is generated for two different schemas (${known.shape} / ${shape}): give one of them another title`);
|
|
237
|
+
}
|
|
238
|
+
this.namedTypes.set(name, known || { type: undefined, shape });
|
|
239
|
+
}
|
|
191
240
|
/**
|
|
192
241
|
* Convert a JSON Schema to a GraphQL type definition
|
|
193
242
|
* @param schema - JSON Schema to convert
|
|
@@ -196,6 +245,17 @@ let GraphQLService = (() => {
|
|
|
196
245
|
* @returns object with the GraphQL type and optional description, or undefined for null types
|
|
197
246
|
*/
|
|
198
247
|
getGraphQLSchemaFromSchema(schema, defaultName, input) {
|
|
248
|
+
// `type: ["string", "null"]`: the nullable form of one type (GraphQL fields are nullable already)
|
|
249
|
+
if (Array.isArray(schema?.type)) {
|
|
250
|
+
const types = schema.type.filter(t => t !== "null");
|
|
251
|
+
if (types.length === 0) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
if (types.length > 1) {
|
|
255
|
+
return { type: AnyScalarType, description: schema.description };
|
|
256
|
+
}
|
|
257
|
+
return this.getGraphQLSchemaFromSchema({ ...schema, type: types[0] }, defaultName, input);
|
|
258
|
+
}
|
|
199
259
|
let type;
|
|
200
260
|
if (!schema || !schema.type) {
|
|
201
261
|
type = AnyScalarType;
|
|
@@ -242,10 +302,12 @@ let GraphQLService = (() => {
|
|
|
242
302
|
this.log("DEBUG", "Return map for", defaultName, "because no fields");
|
|
243
303
|
return { type: AnyScalarType, description: "Map" };
|
|
244
304
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
305
|
+
// A titled input type gets its own name: the output type of the same title already uses the title
|
|
306
|
+
const name = schema.title ? (input ? `${schema.title}Input` : schema.title) : defaultName;
|
|
307
|
+
type = this.namedType(name, `${input ? "input" : "output"}:${Object.keys(fields)
|
|
308
|
+
.sort()
|
|
309
|
+
.map(k => `${k}:${fields[k].type}`)
|
|
310
|
+
.join(",")}`, () => (input ? new GraphQLInputObjectType({ fields, name }) : new GraphQLObjectType({ fields, name })));
|
|
249
311
|
}
|
|
250
312
|
return { type, description: schema.description };
|
|
251
313
|
}
|
|
@@ -383,7 +445,8 @@ let GraphQLService = (() => {
|
|
|
383
445
|
* private (`__`) fields
|
|
384
446
|
* @param filter - the client filter
|
|
385
447
|
* @returns the validator, undefined without filter
|
|
386
|
-
* @throws GraphQLError BAD_USER_INPUT for an invalid filter
|
|
448
|
+
* @throws GraphQLError BAD_USER_INPUT for an invalid filter, one reading private fields, or a statement (DELETE,
|
|
449
|
+
* UPDATE, SELECT field list): GraphQL selection sets choose the fields
|
|
387
450
|
*/
|
|
388
451
|
parseFilter(filter) {
|
|
389
452
|
if (!filter) {
|
|
@@ -392,6 +455,7 @@ let GraphQLService = (() => {
|
|
|
392
455
|
try {
|
|
393
456
|
const validator = new WebdaQL.PartialValidator(WebdaQL.unsanitize(filter));
|
|
394
457
|
assertNoPrivateFields(validator);
|
|
458
|
+
assertFilterOnly(validator);
|
|
395
459
|
return validator;
|
|
396
460
|
}
|
|
397
461
|
catch (err) {
|
|
@@ -488,17 +552,40 @@ let GraphQLService = (() => {
|
|
|
488
552
|
super.resolve();
|
|
489
553
|
// Set-up ws server
|
|
490
554
|
this.wss = new WebSocketServer({ noServer: true });
|
|
555
|
+
// The HTTP server events fire outside the instance storage: re-enter it
|
|
556
|
+
const storage = useInstanceStorage();
|
|
491
557
|
useCoreEvents("Webda.Init.Http", (http) => {
|
|
492
558
|
http.on("upgrade", (req, socket, head) => {
|
|
493
|
-
|
|
559
|
+
runWithInstanceStorage(storage, () => {
|
|
560
|
+
if (req.url !== this.parameters.url)
|
|
561
|
+
return;
|
|
494
562
|
(async () => {
|
|
495
|
-
|
|
496
|
-
|
|
563
|
+
const httpServer = Object.values(useCore().getServices()).find(s => s.server === http && typeof s.getContextFromRequest === "function");
|
|
564
|
+
req.webdaContext ?? (req.webdaContext = await (httpServer ?? useCore()).getContextFromRequest(req));
|
|
565
|
+
const context = req.webdaContext;
|
|
566
|
+
await context.init();
|
|
567
|
+
// Same checks as a normal request: Webda.Request listeners and the router request filters
|
|
568
|
+
const allowed = await runWithContext(context, async () => {
|
|
569
|
+
try {
|
|
570
|
+
emitCoreEvent("Webda.Request", { context });
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
// listener error
|
|
574
|
+
}
|
|
575
|
+
return useRouter().checkRequest(context);
|
|
576
|
+
});
|
|
577
|
+
if (!allowed) {
|
|
578
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
497
581
|
this.wss.handleUpgrade(req, socket, head, ws => {
|
|
498
582
|
this.wss.emit("connection", ws, req);
|
|
499
583
|
});
|
|
500
|
-
})()
|
|
501
|
-
|
|
584
|
+
})().catch(err => {
|
|
585
|
+
this.log("ERROR", "GraphQL WebSocket upgrade failed", err);
|
|
586
|
+
socket.destroy();
|
|
587
|
+
});
|
|
588
|
+
});
|
|
502
589
|
});
|
|
503
590
|
});
|
|
504
591
|
return this;
|
|
@@ -512,6 +599,10 @@ let GraphQLService = (() => {
|
|
|
512
599
|
const subscriptions = {};
|
|
513
600
|
const models = this.app.getModels();
|
|
514
601
|
this.modelsMap = {};
|
|
602
|
+
this.namedTypes = new Map();
|
|
603
|
+
// First pass: reserve every exposed model name (and its Input) before any type is built, so a nested
|
|
604
|
+
// titled type of the same name fails whatever the model order
|
|
605
|
+
const exposed = [];
|
|
515
606
|
for (const i in models) {
|
|
516
607
|
const model = models[i];
|
|
517
608
|
const metadata = useModelMetadata(model);
|
|
@@ -532,6 +623,11 @@ let GraphQLService = (() => {
|
|
|
532
623
|
if (!schema)
|
|
533
624
|
continue;
|
|
534
625
|
const name = (metadata.ShortName || i.split("/").pop()).replace("/", "_");
|
|
626
|
+
this.reserveName(name, `model ${i}`);
|
|
627
|
+
this.reserveName(`${name}Input`, `model input ${i}`);
|
|
628
|
+
exposed.push({ i, model, metadata, schema, name });
|
|
629
|
+
}
|
|
630
|
+
for (const { i, model, metadata, schema, name } of exposed) {
|
|
535
631
|
this.log("INFO", "Add GraphQL type", name);
|
|
536
632
|
const modelGraph = metadata.Relations;
|
|
537
633
|
this.modelsMap[i] = new GraphQLObjectType({
|
|
@@ -542,6 +638,8 @@ let GraphQLService = (() => {
|
|
|
542
638
|
fields: this.getGraphQLFieldsFromSchema(schema, name + "Input", modelGraph, true, getClientWritableAttributes(model)),
|
|
543
639
|
name: name + "Input"
|
|
544
640
|
});
|
|
641
|
+
this.namedTypes.get(name).type = this.modelsMap[i];
|
|
642
|
+
this.namedTypes.get(`${name}Input`).type = input;
|
|
545
643
|
const actionsName = Object.keys(metadata.Actions);
|
|
546
644
|
if (!actionsName.includes("create")) {
|
|
547
645
|
mutations[`create${name}`] = {
|
|
@@ -597,7 +695,7 @@ let GraphQLService = (() => {
|
|
|
597
695
|
},
|
|
598
696
|
resolve: async (_, args, context) => {
|
|
599
697
|
// Same permission filtering as the DomainService query operation
|
|
600
|
-
return await
|
|
698
|
+
return await this.queryModel(model, WebdaQL.unsanitize(args.query || ""), context);
|
|
601
699
|
},
|
|
602
700
|
subscribe: async (_source, args, context) => {
|
|
603
701
|
this.log("DEBUG", "Subscription called on", args);
|
|
@@ -617,7 +715,8 @@ let GraphQLService = (() => {
|
|
|
617
715
|
},
|
|
618
716
|
subscribe: async (_source, args, context) => {
|
|
619
717
|
this.log("DEBUG", "Subscription called on", args);
|
|
620
|
-
|
|
718
|
+
// The payload is keyed by the field name: the default resolver reads it from there
|
|
719
|
+
return this.registerAsyncIterator(model, args[uuidField], context, this.transformName(i.split("/").pop()));
|
|
621
720
|
}
|
|
622
721
|
};
|
|
623
722
|
const events = metadata.Events || [];
|
|
@@ -774,6 +873,9 @@ let GraphQLService = (() => {
|
|
|
774
873
|
for (const i in rootFields) {
|
|
775
874
|
subscriptions[i] ?? (subscriptions[i] = { ...rootFields[i], resolve: undefined });
|
|
776
875
|
}
|
|
876
|
+
// Operations (service operations, model and behaviour actions) next to the model schema; added after the copy
|
|
877
|
+
// above on purpose: a query operation is not a subscription
|
|
878
|
+
this.addOperationFields(rootFields, mutations, subscriptions);
|
|
777
879
|
if (this.parameters.globalSubscription) {
|
|
778
880
|
// Create global type based on all previous subscriptions
|
|
779
881
|
subscriptions["Aggregate"] = {
|
|
@@ -809,6 +911,149 @@ let GraphQLService = (() => {
|
|
|
809
911
|
}
|
|
810
912
|
this.schema = this.getGraphQLSchema(rootFields, mutations, subscriptions);
|
|
811
913
|
}
|
|
914
|
+
/**
|
|
915
|
+
* Expose the registered operations, except the model CRUD the model schema already serves: read-only ones as
|
|
916
|
+
* queries, the others as mutations, server-streaming ones as subscriptions
|
|
917
|
+
* @param rootFields - query fields
|
|
918
|
+
* @param mutations - mutation fields
|
|
919
|
+
* @param subscriptions - subscription fields
|
|
920
|
+
* @throws Error when a field name is already defined
|
|
921
|
+
*/
|
|
922
|
+
addOperationFields(rootFields, mutations, subscriptions) {
|
|
923
|
+
const exposed = createOperationFilter(this.parameters.exposeOperations);
|
|
924
|
+
const targets = { query: rootFields, mutation: mutations, subscription: subscriptions };
|
|
925
|
+
const owners = new Map();
|
|
926
|
+
const operations = useInstanceStorage().operations;
|
|
927
|
+
for (const id of Object.keys(operations).sort()) {
|
|
928
|
+
const op = operations[id];
|
|
929
|
+
if (!exposed(id))
|
|
930
|
+
continue;
|
|
931
|
+
const placement = graphqlPlacement(id, op);
|
|
932
|
+
if (!placement)
|
|
933
|
+
continue;
|
|
934
|
+
const target = targets[placement.kind];
|
|
935
|
+
const key = `${placement.kind}.${placement.name}`;
|
|
936
|
+
if (placement.kind === "subscription" && placement.name === "Aggregate" && this.parameters.globalSubscription) {
|
|
937
|
+
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: "…" } })`);
|
|
938
|
+
}
|
|
939
|
+
if (target[placement.name]) {
|
|
940
|
+
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}: "…" } })`);
|
|
941
|
+
}
|
|
942
|
+
owners.set(key, `operation ${id}`);
|
|
943
|
+
const base = operationTypeName(id);
|
|
944
|
+
const { args, toInput } = this.operationArgs(op, base);
|
|
945
|
+
const type = this.operationOutputType(op, base);
|
|
946
|
+
const description = op.description || op.summary;
|
|
947
|
+
target[placement.name] =
|
|
948
|
+
placement.kind === "subscription"
|
|
949
|
+
? {
|
|
950
|
+
type,
|
|
951
|
+
args,
|
|
952
|
+
description,
|
|
953
|
+
subscribe: async (_source, fieldArgs, context) => this.subscribeOperation(id, op, toInput(fieldArgs), context),
|
|
954
|
+
resolve: (chunk) => chunk
|
|
955
|
+
}
|
|
956
|
+
: {
|
|
957
|
+
type,
|
|
958
|
+
args,
|
|
959
|
+
description,
|
|
960
|
+
resolve: async (_source, fieldArgs, context) => this.runOperation(id, op, toInput(fieldArgs), context)
|
|
961
|
+
};
|
|
962
|
+
this.log("INFO", `Add GraphQL ${placement.kind}`, placement.name, "for operation", id);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* GraphQL arguments of an operation: one per input schema property, or a single `input` argument
|
|
967
|
+
* @param op - operation
|
|
968
|
+
* @param base - type name prefix
|
|
969
|
+
* @returns the arguments and how to turn them back into the operation input
|
|
970
|
+
*/
|
|
971
|
+
operationArgs(op, base) {
|
|
972
|
+
const schema = op.input && op.input !== "void" ? this.app.getSchema(op.input) : undefined;
|
|
973
|
+
if (!schema) {
|
|
974
|
+
return { args: {}, toInput: () => undefined };
|
|
975
|
+
}
|
|
976
|
+
const resolved = this.getJsonSchemaDefinition(schema, schema.definitions);
|
|
977
|
+
const names = Object.keys(resolved.properties ?? {}).filter(name => !name.startsWith("__"));
|
|
978
|
+
if (resolved.type === "object" && names.length > 0 && names.every(name => GRAPHQL_NAME.test(name))) {
|
|
979
|
+
const required = new Set(resolved.required ?? []);
|
|
980
|
+
const args = {};
|
|
981
|
+
for (const name of names) {
|
|
982
|
+
const converted = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(resolved.properties[name], resolved.definitions ?? schema.definitions), `${base}Input_${name}`, true);
|
|
983
|
+
// A property the converter cannot map still is an argument: the Object scalar takes anything
|
|
984
|
+
const type = (converted?.type ?? AnyScalarType);
|
|
985
|
+
args[name] = {
|
|
986
|
+
type: required.has(name) ? new GraphQLNonNull(type) : type,
|
|
987
|
+
description: converted?.description
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
return { args, toInput: fieldArgs => ({ ...fieldArgs }) };
|
|
991
|
+
}
|
|
992
|
+
const converted = this.getGraphQLSchemaFromSchema(resolved, `${base}Input`, true);
|
|
993
|
+
return {
|
|
994
|
+
args: { input: { type: (converted?.type ?? AnyScalarType) } },
|
|
995
|
+
toInput: fieldArgs => fieldArgs.input
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* GraphQL type of an operation result
|
|
1000
|
+
* @param op - operation
|
|
1001
|
+
* @param base - type name prefix
|
|
1002
|
+
* @returns the model type for a model output, Boolean for void, else the converted output schema
|
|
1003
|
+
*/
|
|
1004
|
+
operationOutputType(op, base) {
|
|
1005
|
+
if (!op.output || op.output === "void") {
|
|
1006
|
+
return GraphQLBoolean;
|
|
1007
|
+
}
|
|
1008
|
+
if (this.modelsMap[op.output]) {
|
|
1009
|
+
return this.modelsMap[op.output];
|
|
1010
|
+
}
|
|
1011
|
+
const schema = this.app.getSchema(op.output);
|
|
1012
|
+
const converted = schema &&
|
|
1013
|
+
this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema, schema.definitions), `${base}Output`);
|
|
1014
|
+
return (converted?.type ?? AnyScalarType);
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Run an operation for a query or mutation field
|
|
1018
|
+
* @param id - operation id
|
|
1019
|
+
* @param op - operation
|
|
1020
|
+
* @param input - operation input
|
|
1021
|
+
* @param context - the GraphQL request context
|
|
1022
|
+
* @returns the field value
|
|
1023
|
+
*/
|
|
1024
|
+
async runOperation(id, op, input, context) {
|
|
1025
|
+
this.countOperation(context);
|
|
1026
|
+
const ctx = new GraphQLOperationContext(context, input);
|
|
1027
|
+
await ctx.init();
|
|
1028
|
+
try {
|
|
1029
|
+
await callOperation(ctx, id);
|
|
1030
|
+
}
|
|
1031
|
+
catch (err) {
|
|
1032
|
+
throw operationError(err);
|
|
1033
|
+
}
|
|
1034
|
+
if (!op.output || op.output === "void") {
|
|
1035
|
+
return true;
|
|
1036
|
+
}
|
|
1037
|
+
return this.modelsMap[op.output] ? ctx.result : publicCopy(ctx.result);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Start a server-streaming operation for a subscription field
|
|
1041
|
+
* @param id - operation id
|
|
1042
|
+
* @param op - operation
|
|
1043
|
+
* @param input - operation input
|
|
1044
|
+
* @param context - the GraphQL request context
|
|
1045
|
+
* @returns the chunks; leaving the subscription cancels the operation
|
|
1046
|
+
*/
|
|
1047
|
+
async subscribeOperation(id, op, input, context) {
|
|
1048
|
+
this.countOperation(context);
|
|
1049
|
+
const ctx = new GraphQLOperationContext(context, input, this.modelsMap[op.output] ? chunk => chunk : publicCopy);
|
|
1050
|
+
await ctx.init();
|
|
1051
|
+
if (!(await canCallOperation(ctx, id, { input }))) {
|
|
1052
|
+
throw operationError(new WebdaError.Forbidden(`${id} PermissionDenied`));
|
|
1053
|
+
}
|
|
1054
|
+
callOperation(ctx, id).then(() => ctx.finish(), err => ctx.fail(err));
|
|
1055
|
+
return ctx.chunks();
|
|
1056
|
+
}
|
|
812
1057
|
/**
|
|
813
1058
|
* Extract the list of event names from the GraphQL selection set (excluding latestEventTime)
|
|
814
1059
|
* @param info - GraphQL resolve info containing field selections
|
|
@@ -830,8 +1075,44 @@ let GraphQLService = (() => {
|
|
|
830
1075
|
* @returns the readable related objects
|
|
831
1076
|
*/
|
|
832
1077
|
async queryRelated(related, model, query, context) {
|
|
1078
|
+
this.assertFilterQuery(query);
|
|
833
1079
|
return queryModelWithPermissions(model, related.getQuery(query), context);
|
|
834
1080
|
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Run a root query argument: the DomainService query with the caller permissions
|
|
1083
|
+
* @param model - the model class
|
|
1084
|
+
* @param query - the client query
|
|
1085
|
+
* @param context - the caller context
|
|
1086
|
+
* @returns the readable results
|
|
1087
|
+
* @throws GraphQLError BAD_USER_INPUT for a statement (DELETE, UPDATE, SELECT field list)
|
|
1088
|
+
*/
|
|
1089
|
+
async queryModel(model, query, context) {
|
|
1090
|
+
this.assertFilterQuery(query);
|
|
1091
|
+
return queryModelWithPermissions(model, query, context);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Refuse a query argument that is not a filter: DELETE and UPDATE are never run from GraphQL, and a SELECT field
|
|
1095
|
+
* list is refused because the selection set already chooses the fields
|
|
1096
|
+
*
|
|
1097
|
+
* A query that does not parse is left to the query path, which reports it as before.
|
|
1098
|
+
* @param query - the client query
|
|
1099
|
+
* @throws GraphQLError BAD_USER_INPUT for a DELETE, an UPDATE or a SELECT field list
|
|
1100
|
+
*/
|
|
1101
|
+
assertFilterQuery(query) {
|
|
1102
|
+
let validator;
|
|
1103
|
+
try {
|
|
1104
|
+
validator = new WebdaQL.QueryValidator(query ?? "");
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
try {
|
|
1110
|
+
assertFilterOnly(validator);
|
|
1111
|
+
}
|
|
1112
|
+
catch (err) {
|
|
1113
|
+
throw new GraphQLError(err.message, { extensions: { code: "BAD_USER_INPUT" } });
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
835
1116
|
/**
|
|
836
1117
|
* Create an async iterator that streams query results, updating when store events occur
|
|
837
1118
|
* @param model - model class to query
|
|
@@ -842,6 +1123,7 @@ let GraphQLService = (() => {
|
|
|
842
1123
|
*/
|
|
843
1124
|
async registerAsyncIteratorQuery(model, plural, query, context) {
|
|
844
1125
|
// Results are always filtered with the subscriber permissions (permission query and canAct "get")
|
|
1126
|
+
this.assertFilterQuery(query);
|
|
845
1127
|
const runQuery = () => queryModelWithPermissions(model, query, context);
|
|
846
1128
|
let result = await runQuery();
|
|
847
1129
|
const queryInfo = new WebdaQL.QueryValidator(query);
|
|
@@ -941,11 +1223,24 @@ let GraphQLService = (() => {
|
|
|
941
1223
|
*/
|
|
942
1224
|
async registerAsyncEventIterator(model, uuid, events, context, identifier) {
|
|
943
1225
|
const updatedCallback = eventName => async (evt) => {
|
|
944
|
-
|
|
1226
|
+
// A class-wide subscription (uuid null) receives the events of every object of the model
|
|
1227
|
+
if (uuid !== null && evt.object_id !== uuid)
|
|
945
1228
|
return;
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1229
|
+
if (uuid === null) {
|
|
1230
|
+
// Only the events of objects the subscriber can read: an object that is gone or unreadable is skipped
|
|
1231
|
+
let object = evt.object;
|
|
1232
|
+
if (!object) {
|
|
1233
|
+
try {
|
|
1234
|
+
object = await model.ref(evt.object_id).get();
|
|
1235
|
+
}
|
|
1236
|
+
catch {
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (!object || !(await isModelActionAllowed(object, context, "get", model)))
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
return { latestEventTime: Date.now(), [eventName]: publicCopy(evt) };
|
|
949
1244
|
};
|
|
950
1245
|
const eventsMap = {};
|
|
951
1246
|
if (uuid !== null) {
|
|
@@ -969,6 +1264,30 @@ let GraphQLService = (() => {
|
|
|
969
1264
|
logout: { evt: "nok?" }
|
|
970
1265
|
}).iterate();
|
|
971
1266
|
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Hand a WebSocket message to graphql-ws; a failure is logged and closes the socket without leaking details
|
|
1269
|
+
* @param socket - the WebSocket
|
|
1270
|
+
* @param cb - the graphql-ws message handler
|
|
1271
|
+
* @param data - the message
|
|
1272
|
+
* @param storage - the instance storage to re-enter for the resolvers
|
|
1273
|
+
* @returns a promise settled once the message is handled
|
|
1274
|
+
*/
|
|
1275
|
+
async handleSocketMessage(socket, cb, data, storage) {
|
|
1276
|
+
try {
|
|
1277
|
+
// wait for the the operation to complete
|
|
1278
|
+
// - if init message, waits for connect
|
|
1279
|
+
// - if query/mutation, waits for result
|
|
1280
|
+
// - if subscription, waits for complete
|
|
1281
|
+
// The socket events fire outside the instance storage: re-enter it for the resolvers
|
|
1282
|
+
await runWithInstanceStorage(storage, () => cb(data));
|
|
1283
|
+
}
|
|
1284
|
+
catch (err) {
|
|
1285
|
+
// all errors that could be thrown during the execution of operations will be caught here
|
|
1286
|
+
this.log("ERROR", "GraphQL websocket message failed", err);
|
|
1287
|
+
// A close reason is limited to 123 bytes and must not leak internal details
|
|
1288
|
+
socket.close(CloseCode.InternalServerError, "Internal server error");
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
972
1291
|
/**
|
|
973
1292
|
* Assemble the final GraphQLSchema from root query fields, mutations, and subscriptions
|
|
974
1293
|
* @param rootFields - top-level query fields
|
|
@@ -1020,6 +1339,7 @@ let GraphQLService = (() => {
|
|
|
1020
1339
|
return req.extra?.context || {};
|
|
1021
1340
|
}
|
|
1022
1341
|
});
|
|
1342
|
+
const storage = useInstanceStorage();
|
|
1023
1343
|
this.wss.on("connection", async (socket, request) => {
|
|
1024
1344
|
// a new socket opened, let graphql-ws take over
|
|
1025
1345
|
const closed = this.wsHandler.opened({
|
|
@@ -1028,20 +1348,7 @@ let GraphQLService = (() => {
|
|
|
1028
1348
|
socket.send(data, err => (err ? reject(err) : resolve()));
|
|
1029
1349
|
}), // control your data flow by timing the promise resolve
|
|
1030
1350
|
close: (code, reason) => socket.close(code, reason), // there are protocol standard closures
|
|
1031
|
-
onMessage: cb => socket.on("message",
|
|
1032
|
-
try {
|
|
1033
|
-
// wait for the the operation to complete
|
|
1034
|
-
// - if init message, waits for connect
|
|
1035
|
-
// - if query/mutation, waits for result
|
|
1036
|
-
// - if subscription, waits for complete
|
|
1037
|
-
await cb(event.toString());
|
|
1038
|
-
}
|
|
1039
|
-
catch (err) {
|
|
1040
|
-
// all errors that could be thrown during the
|
|
1041
|
-
// execution of operations will be caught here
|
|
1042
|
-
socket.close(CloseCode.InternalServerError, err.message);
|
|
1043
|
-
}
|
|
1044
|
-
})
|
|
1351
|
+
onMessage: cb => socket.on("message", event => this.handleSocketMessage(socket, cb, event.toString(), storage))
|
|
1045
1352
|
},
|
|
1046
1353
|
// pass values to the `extra` field in the context
|
|
1047
1354
|
{ socket, request, context: request.webdaContext });
|