@webda/graphql 4.0.0-beta.6 → 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.
@@ -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,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
- type = new (input ? GraphQLInputObjectType : GraphQLObjectType)({
246
- fields,
247
- name: schema.title || defaultName
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
  }
@@ -490,17 +552,40 @@ let GraphQLService = (() => {
490
552
  super.resolve();
491
553
  // Set-up ws server
492
554
  this.wss = new WebSocketServer({ noServer: true });
555
+ // The HTTP server events fire outside the instance storage: re-enter it
556
+ const storage = useInstanceStorage();
493
557
  useCoreEvents("Webda.Init.Http", (http) => {
494
558
  http.on("upgrade", (req, socket, head) => {
495
- if (req.url === this.parameters.url) {
559
+ runWithInstanceStorage(storage, () => {
560
+ if (req.url !== this.parameters.url)
561
+ return;
496
562
  (async () => {
497
- req.webdaContext ?? (req.webdaContext = await useCore().getContextFromRequest(req));
498
- await req.webdaContext.init();
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
+ }
499
581
  this.wss.handleUpgrade(req, socket, head, ws => {
500
582
  this.wss.emit("connection", ws, req);
501
583
  });
502
- })();
503
- }
584
+ })().catch(err => {
585
+ this.log("ERROR", "GraphQL WebSocket upgrade failed", err);
586
+ socket.destroy();
587
+ });
588
+ });
504
589
  });
505
590
  });
506
591
  return this;
@@ -514,6 +599,10 @@ let GraphQLService = (() => {
514
599
  const subscriptions = {};
515
600
  const models = this.app.getModels();
516
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 = [];
517
606
  for (const i in models) {
518
607
  const model = models[i];
519
608
  const metadata = useModelMetadata(model);
@@ -534,6 +623,11 @@ let GraphQLService = (() => {
534
623
  if (!schema)
535
624
  continue;
536
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) {
537
631
  this.log("INFO", "Add GraphQL type", name);
538
632
  const modelGraph = metadata.Relations;
539
633
  this.modelsMap[i] = new GraphQLObjectType({
@@ -544,6 +638,8 @@ let GraphQLService = (() => {
544
638
  fields: this.getGraphQLFieldsFromSchema(schema, name + "Input", modelGraph, true, getClientWritableAttributes(model)),
545
639
  name: name + "Input"
546
640
  });
641
+ this.namedTypes.get(name).type = this.modelsMap[i];
642
+ this.namedTypes.get(`${name}Input`).type = input;
547
643
  const actionsName = Object.keys(metadata.Actions);
548
644
  if (!actionsName.includes("create")) {
549
645
  mutations[`create${name}`] = {
@@ -619,7 +715,8 @@ let GraphQLService = (() => {
619
715
  },
620
716
  subscribe: async (_source, args, context) => {
621
717
  this.log("DEBUG", "Subscription called on", args);
622
- return this.registerAsyncIterator(model, args[uuidField], context);
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()));
623
720
  }
624
721
  };
625
722
  const events = metadata.Events || [];
@@ -776,6 +873,9 @@ let GraphQLService = (() => {
776
873
  for (const i in rootFields) {
777
874
  subscriptions[i] ?? (subscriptions[i] = { ...rootFields[i], resolve: undefined });
778
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);
779
879
  if (this.parameters.globalSubscription) {
780
880
  // Create global type based on all previous subscriptions
781
881
  subscriptions["Aggregate"] = {
@@ -811,6 +911,149 @@ let GraphQLService = (() => {
811
911
  }
812
912
  this.schema = this.getGraphQLSchema(rootFields, mutations, subscriptions);
813
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
+ }
814
1057
  /**
815
1058
  * Extract the list of event names from the GraphQL selection set (excluding latestEventTime)
816
1059
  * @param info - GraphQL resolve info containing field selections
@@ -980,11 +1223,24 @@ let GraphQLService = (() => {
980
1223
  */
981
1224
  async registerAsyncEventIterator(model, uuid, events, context, identifier) {
982
1225
  const updatedCallback = eventName => async (evt) => {
983
- if (uuid === null || evt.object_id !== uuid)
1226
+ // A class-wide subscription (uuid null) receives the events of every object of the model
1227
+ if (uuid !== null && evt.object_id !== uuid)
984
1228
  return;
985
- // We rely on the cache of the store to get the full object
986
- // We let the other listeners finish before returning the object
987
- return { latestEventTime: Date.now(), [eventName]: evt };
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) };
988
1244
  };
989
1245
  const eventsMap = {};
990
1246
  if (uuid !== null) {
@@ -1008,6 +1264,30 @@ let GraphQLService = (() => {
1008
1264
  logout: { evt: "nok?" }
1009
1265
  }).iterate();
1010
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
+ }
1011
1291
  /**
1012
1292
  * Assemble the final GraphQLSchema from root query fields, mutations, and subscriptions
1013
1293
  * @param rootFields - top-level query fields
@@ -1059,6 +1339,7 @@ let GraphQLService = (() => {
1059
1339
  return req.extra?.context || {};
1060
1340
  }
1061
1341
  });
1342
+ const storage = useInstanceStorage();
1062
1343
  this.wss.on("connection", async (socket, request) => {
1063
1344
  // a new socket opened, let graphql-ws take over
1064
1345
  const closed = this.wsHandler.opened({
@@ -1067,20 +1348,7 @@ let GraphQLService = (() => {
1067
1348
  socket.send(data, err => (err ? reject(err) : resolve()));
1068
1349
  }), // control your data flow by timing the promise resolve
1069
1350
  close: (code, reason) => socket.close(code, reason), // there are protocol standard closures
1070
- onMessage: cb => socket.on("message", async (event) => {
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
- })
1351
+ onMessage: cb => socket.on("message", event => this.handleSocketMessage(socket, cb, event.toString(), storage))
1084
1352
  },
1085
1353
  // pass values to the `extra` field in the context
1086
1354
  { socket, request, context: request.webdaContext });