@nymphjs/server 1.0.0-beta.12 → 1.0.0-beta.120

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.
@@ -18,24 +18,32 @@ import {
18
18
  } from '@nymphjs/nymph';
19
19
  import { EntityInvalidDataError } from '@nymphjs/nymph';
20
20
 
21
- import { statusDescriptions } from './statusDescriptions';
21
+ import { statusDescriptions } from './statusDescriptions.js';
22
22
 
23
23
  type NymphResponse = Response<any, { nymph: Nymph }>;
24
24
 
25
25
  const NOT_FOUND_ERROR = 'Entity is not found.';
26
26
 
27
+ export class ForbiddenClassError extends Error {
28
+ constructor(message: string) {
29
+ super(message);
30
+
31
+ this.name = 'ForbiddenClassError';
32
+ }
33
+ }
34
+
27
35
  /**
28
36
  * A REST server middleware creator for Nymph.
29
37
  *
30
38
  * Written by Hunter Perrin for SciActive.
31
39
  *
32
- * @author Hunter Perrin <hperrin@gmail.com>
40
+ * @author Hunter Perrin <hperrin@port87.com>
33
41
  * @copyright SciActive Inc
34
42
  * @see http://nymph.io/
35
43
  */
36
44
  export function createServer(
37
45
  nymph: Nymph,
38
- { jsonOptions = {} }: { jsonOptions?: OptionsJson } = {}
46
+ { jsonOptions = {} }: { jsonOptions?: OptionsJson } = {},
39
47
  ) {
40
48
  const rest = express();
41
49
  rest.use(cookieParser());
@@ -44,22 +52,25 @@ export function createServer(
44
52
  function instantiateNymph(
45
53
  _request: Request,
46
54
  response: NymphResponse,
47
- next: NextFunction
55
+ next: NextFunction,
48
56
  ) {
49
57
  response.locals.nymph = nymph.clone();
50
58
  next();
51
59
  }
52
60
 
53
- function authenticateTilmeld(
61
+ async function authenticateTilmeld(
54
62
  request: Request,
55
63
  response: NymphResponse,
56
- next: NextFunction
64
+ next: NextFunction,
57
65
  ) {
58
66
  if (response.locals.nymph.tilmeld) {
59
67
  response.locals.nymph.tilmeld.request = request;
60
68
  response.locals.nymph.tilmeld.response = response;
61
69
  try {
62
- response.locals.nymph.tilmeld.authenticate();
70
+ await response.locals.nymph.tilmeld.authenticate(
71
+ false,
72
+ request.header('x-tilmeld-token-renewal') === 'off',
73
+ );
63
74
  } catch (e: any) {
64
75
  httpError(response, 500, e);
65
76
  return;
@@ -71,7 +82,7 @@ export function createServer(
71
82
  function unauthenticateTilmeld(
72
83
  _request: Request,
73
84
  response: NymphResponse,
74
- next: NextFunction
85
+ next: NextFunction,
75
86
  ) {
76
87
  if (response.locals.nymph.tilmeld) {
77
88
  response.locals.nymph.tilmeld.request = null;
@@ -140,6 +151,10 @@ export function createServer(
140
151
  let EntityClass;
141
152
  try {
142
153
  EntityClass = response.locals.nymph.getEntityClass(data[0].class);
154
+ if (!EntityClass.restEnabled) {
155
+ httpError(response, 403);
156
+ return;
157
+ }
143
158
  } catch (e: any) {
144
159
  httpError(response, 400, e);
145
160
  return;
@@ -147,10 +162,24 @@ export function createServer(
147
162
  options.class = EntityClass;
148
163
  options.source = 'client';
149
164
  options.skipAc = false;
150
- selectors = classNamesToEntityConstructors(
151
- response.locals.nymph,
152
- selectors
153
- );
165
+ if (options.return === 'object') {
166
+ options.return = 'entity';
167
+ }
168
+ try {
169
+ selectors = classNamesToEntityConstructors(
170
+ response.locals.nymph,
171
+ selectors,
172
+ true,
173
+ );
174
+ } catch (e: any) {
175
+ if (e?.message === 'Not accessible.') {
176
+ httpError(response, 403);
177
+ return;
178
+ } else {
179
+ httpError(response, 500, e);
180
+ return;
181
+ }
182
+ }
154
183
  let result:
155
184
  | EntityInterface
156
185
  | EntityInterface[]
@@ -162,12 +191,12 @@ export function createServer(
162
191
  if (action === 'entity') {
163
192
  result = await response.locals.nymph.getEntity(
164
193
  options,
165
- ...selectors
194
+ ...selectors,
166
195
  );
167
196
  } else {
168
197
  result = await response.locals.nymph.getEntities(
169
198
  options,
170
- ...selectors
199
+ ...selectors,
171
200
  );
172
201
  }
173
202
  } catch (e: any) {
@@ -192,10 +221,10 @@ export function createServer(
192
221
  }
193
222
  if (response.locals.nymph.tilmeld) {
194
223
  if (
195
- !response.locals.nymph.tilmeld.checkClientUIDPermissions(
224
+ !(await response.locals.nymph.tilmeld.checkClientUIDPermissions(
196
225
  data,
197
- TilmeldAccessLevels.READ_ACCESS
198
- )
226
+ TilmeldAccessLevels.READ_ACCESS,
227
+ ))
199
228
  ) {
200
229
  httpError(response, 403);
201
230
  return;
@@ -241,6 +270,7 @@ export function createServer(
241
270
  let invalidRequest = false;
242
271
  let conflict = false;
243
272
  let notfound = false;
273
+ let forbidden = false;
244
274
  let lastException = null;
245
275
  for (let entData of data) {
246
276
  if (entData.guid) {
@@ -254,6 +284,8 @@ export function createServer(
254
284
  } catch (e: any) {
255
285
  if (e instanceof EntityConflictError) {
256
286
  conflict = true;
287
+ } else if (e instanceof ForbiddenClassError) {
288
+ forbidden = true;
257
289
  } else if (e.message === NOT_FOUND_ERROR) {
258
290
  notfound = true;
259
291
  } else if (e instanceof InvalidParametersError) {
@@ -293,6 +325,9 @@ export function createServer(
293
325
  } else if (conflict) {
294
326
  httpError(response, 409);
295
327
  return;
328
+ } else if (forbidden) {
329
+ httpError(response, 403);
330
+ return;
296
331
  } else if (notfound) {
297
332
  httpError(response, 404);
298
333
  return;
@@ -313,92 +348,158 @@ export function createServer(
313
348
  httpError(response, 400);
314
349
  return;
315
350
  }
316
- const params = referencesToEntities(
317
- [...data.params],
318
- response.locals.nymph
319
- );
320
- if (data.static) {
321
- let EntityClass: EntityConstructor;
322
- try {
323
- EntityClass = response.locals.nymph.getEntityClass(data.class);
324
- } catch (e: any) {
325
- httpError(response, 400);
326
- return;
327
- }
328
- if (
329
- EntityClass.clientEnabledStaticMethods.indexOf(data.method) === -1
330
- ) {
331
- httpError(response, 403);
332
- return;
333
- }
334
- if (!(data.method in EntityClass)) {
335
- httpError(response, 400);
336
- return;
337
- }
338
- // @ts-ignore Dynamic methods make TypeScript sad.
339
- const method: Function = EntityClass[data.method];
340
- if (typeof method !== 'function') {
341
- httpError(response, 400);
342
- return;
343
- }
344
- try {
345
- const result = method.call(EntityClass, ...params);
346
- let ret = result;
347
- if (result instanceof Promise) {
348
- ret = await result;
351
+ try {
352
+ const params = referencesToEntities(
353
+ [...data.params],
354
+ response.locals.nymph,
355
+ );
356
+ if (data.static) {
357
+ let EntityClass: EntityConstructor;
358
+ try {
359
+ EntityClass = response.locals.nymph.getEntityClass(data.class);
360
+ if (!EntityClass.restEnabled) {
361
+ httpError(response, 403);
362
+ return;
363
+ }
364
+ } catch (e: any) {
365
+ httpError(response, 400);
366
+ return;
349
367
  }
350
- response.status(200);
351
- response.setHeader('Content-Type', 'application/json');
352
- response.send({ return: ret });
353
- } catch (e: any) {
354
- httpError(response, 500, e);
355
- return;
356
- }
357
- } else {
358
- let entity: EntityInterface;
359
- try {
360
- entity = await loadEntity(data.entity, response.locals.nymph);
361
- } catch (e: any) {
362
- if (e instanceof EntityConflictError) {
363
- httpError(response, 409);
364
- } else if (e.message === NOT_FOUND_ERROR) {
365
- httpError(response, 404, e);
366
- } else if (e instanceof InvalidParametersError) {
367
- httpError(response, 400, e);
368
+ if (
369
+ EntityClass.clientEnabledStaticMethods.indexOf(data.method) === -1
370
+ ) {
371
+ httpError(response, 403);
372
+ return;
373
+ }
374
+ if (!(data.method in EntityClass)) {
375
+ httpError(response, 400);
376
+ return;
377
+ }
378
+ // @ts-ignore Dynamic methods make TypeScript sad.
379
+ const method: Function = EntityClass[data.method];
380
+ if (typeof method !== 'function') {
381
+ httpError(response, 400);
382
+ return;
383
+ }
384
+ if (data.iterator) {
385
+ // Ping every 15 seconds to keep connection alive.
386
+ const interval = setInterval(() => {
387
+ if (response.headersSent) {
388
+ response.write('event: ping\n');
389
+ response.write(`data: ${new Date().toISOString()}\n\n`);
390
+ }
391
+ }, 15000);
392
+
393
+ try {
394
+ response.set({
395
+ 'Cache-Control': 'no-cache',
396
+ 'Content-Type': 'text/event-stream',
397
+ Connection: 'keep-alive',
398
+ });
399
+ response.flushHeaders();
400
+
401
+ const result:
402
+ | Iterator<any, any, boolean>
403
+ | AsyncIterator<any, any, boolean> = method.call(
404
+ EntityClass,
405
+ ...params,
406
+ );
407
+ let sequence = result;
408
+ if (result instanceof Promise) {
409
+ sequence = await result;
410
+ }
411
+
412
+ let { value, done } = await sequence.next(response.destroyed);
413
+ while (!done) {
414
+ response.write('event: next\n');
415
+ response.write(`data: ${JSON.stringify(value)}\n\n`);
416
+ // Wait for an event loop each iteration, to allow data to flush.
417
+ await new Promise((resolve) => setImmediate(resolve));
418
+
419
+ ({ value, done } = await sequence.next(response.destroyed));
420
+ }
421
+
422
+ clearInterval(interval);
423
+
424
+ response.write('event: finished\n');
425
+ response.write('data: \n\n');
426
+ response.end();
427
+ } catch (e: any) {
428
+ clearInterval(interval);
429
+ eventStreamError(response, 500, e);
430
+ return;
431
+ }
368
432
  } else {
433
+ try {
434
+ const result = method.call(EntityClass, ...params);
435
+ let ret = result;
436
+ if (result instanceof Promise) {
437
+ ret = await result;
438
+ }
439
+ response.status(200);
440
+ response.setHeader('Content-Type', 'application/json');
441
+ response.send({ return: ret });
442
+ } catch (e: any) {
443
+ httpError(response, 500, e);
444
+ return;
445
+ }
446
+ }
447
+ } else {
448
+ let entity: EntityInterface;
449
+ try {
450
+ entity = await loadEntity(data.entity, response.locals.nymph);
451
+ } catch (e: any) {
452
+ if (e instanceof EntityConflictError) {
453
+ httpError(response, 409);
454
+ } else if (e instanceof ForbiddenClassError) {
455
+ httpError(response, 403);
456
+ } else if (e.message === NOT_FOUND_ERROR) {
457
+ httpError(response, 404, e);
458
+ } else if (e instanceof InvalidParametersError) {
459
+ httpError(response, 400, e);
460
+ } else {
461
+ httpError(response, 500, e);
462
+ }
463
+ return;
464
+ }
465
+ if (data.entity.guid && !entity.guid) {
466
+ httpError(response, 400);
467
+ return;
468
+ }
469
+ if (entity.$getClientEnabledMethods().indexOf(data.method) === -1) {
470
+ httpError(response, 403);
471
+ return;
472
+ }
473
+ if (
474
+ !(data.method in entity) ||
475
+ typeof entity[data.method] !== 'function'
476
+ ) {
477
+ httpError(response, 400);
478
+ return;
479
+ }
480
+ try {
481
+ const result = entity[data.method](...params);
482
+ let ret = result;
483
+ if (result instanceof Promise) {
484
+ ret = await result;
485
+ }
486
+ response.status(200);
487
+ response.setHeader('Content-Type', 'application/json');
488
+ if (data.stateless) {
489
+ response.send({ return: ret });
490
+ } else {
491
+ response.send({ entity: entity, return: ret });
492
+ }
493
+ } catch (e: any) {
369
494
  httpError(response, 500, e);
495
+ return;
370
496
  }
371
- return;
372
497
  }
373
- if (data.entity.guid && !entity.guid) {
374
- httpError(response, 400);
375
- return;
376
- }
377
- if (entity.$getClientEnabledMethods().indexOf(data.method) === -1) {
498
+ } catch (e: any) {
499
+ if (e instanceof ForbiddenClassError) {
378
500
  httpError(response, 403);
379
501
  return;
380
- }
381
- if (
382
- !(data.method in entity) ||
383
- typeof entity[data.method] !== 'function'
384
- ) {
385
- httpError(response, 400);
386
- return;
387
- }
388
- try {
389
- const result = entity[data.method](...params);
390
- let ret = result;
391
- if (result instanceof Promise) {
392
- ret = await result;
393
- }
394
- response.status(200);
395
- response.setHeader('Content-Type', 'application/json');
396
- if (data.stateless) {
397
- response.send({ return: ret });
398
- } else {
399
- response.send({ entity: entity, return: ret });
400
- }
401
- } catch (e: any) {
502
+ } else {
402
503
  httpError(response, 500, e);
403
504
  return;
404
505
  }
@@ -410,10 +511,10 @@ export function createServer(
410
511
  }
411
512
  if (response.locals.nymph.tilmeld) {
412
513
  if (
413
- !response.locals.nymph.tilmeld.checkClientUIDPermissions(
514
+ !(await response.locals.nymph.tilmeld.checkClientUIDPermissions(
414
515
  data,
415
- TilmeldAccessLevels.WRITE_ACCESS
416
- )
516
+ TilmeldAccessLevels.WRITE_ACCESS,
517
+ ))
417
518
  ) {
418
519
  httpError(response, 403);
419
520
  return;
@@ -472,7 +573,7 @@ export function createServer(
472
573
  response: NymphResponse,
473
574
  action: string,
474
575
  data: any,
475
- patch: boolean
576
+ patch: boolean,
476
577
  ) {
477
578
  if (action === 'uid') {
478
579
  if (typeof data.name !== 'string' || typeof data.value !== 'number') {
@@ -481,10 +582,10 @@ export function createServer(
481
582
  }
482
583
  if (response.locals.nymph.tilmeld) {
483
584
  if (
484
- !response.locals.nymph.tilmeld.checkClientUIDPermissions(
585
+ !(await response.locals.nymph.tilmeld.checkClientUIDPermissions(
485
586
  data.name,
486
- TilmeldAccessLevels.FULL_ACCESS
487
- )
587
+ TilmeldAccessLevels.FULL_ACCESS,
588
+ ))
488
589
  ) {
489
590
  httpError(response, 403);
490
591
  return;
@@ -512,6 +613,7 @@ export function createServer(
512
613
  let hadSuccess = false;
513
614
  let invalidRequest = false;
514
615
  let conflict = false;
616
+ let forbidden = false;
515
617
  let notfound = false;
516
618
  let lastException = null;
517
619
  for (let entData of data) {
@@ -526,6 +628,8 @@ export function createServer(
526
628
  } catch (e: any) {
527
629
  if (e instanceof EntityConflictError) {
528
630
  conflict = true;
631
+ } else if (e instanceof ForbiddenClassError) {
632
+ forbidden = true;
529
633
  } else if (e.message === NOT_FOUND_ERROR) {
530
634
  notfound = true;
531
635
  } else if (e instanceof InvalidParametersError) {
@@ -561,6 +665,8 @@ export function createServer(
561
665
  if (!hadSuccess) {
562
666
  if (invalidRequest) {
563
667
  httpError(response, 400, lastException);
668
+ } else if (forbidden) {
669
+ httpError(response, 403);
564
670
  } else if (conflict) {
565
671
  httpError(response, 409);
566
672
  } else if (notfound) {
@@ -605,6 +711,10 @@ export function createServer(
605
711
  let EntityClass: EntityConstructor;
606
712
  try {
607
713
  EntityClass = response.locals.nymph.getEntityClass(entData.class);
714
+ if (!EntityClass.restEnabled) {
715
+ httpError(response, 403);
716
+ return;
717
+ }
608
718
  } catch (e: any) {
609
719
  invalidRequest = true;
610
720
  failures = true;
@@ -614,7 +724,7 @@ export function createServer(
614
724
  try {
615
725
  entity = await response.locals.nymph.getEntity(
616
726
  { class: EntityClass },
617
- { type: '&', guid: entData.guid }
727
+ { type: '&', guid: entData.guid },
618
728
  );
619
729
  } catch (e: any) {
620
730
  lastException = e;
@@ -661,10 +771,10 @@ export function createServer(
661
771
  }
662
772
  if (response.locals.nymph.tilmeld) {
663
773
  if (
664
- !response.locals.nymph.tilmeld.checkClientUIDPermissions(
774
+ !(await response.locals.nymph.tilmeld.checkClientUIDPermissions(
665
775
  data,
666
- TilmeldAccessLevels.FULL_ACCESS
667
- )
776
+ TilmeldAccessLevels.FULL_ACCESS,
777
+ ))
668
778
  ) {
669
779
  httpError(response, 403);
670
780
  return;
@@ -698,15 +808,18 @@ export function createServer(
698
808
  entityData: EntityJson | EntityPatch,
699
809
  nymph: Nymph,
700
810
  patch = false,
701
- allowConflict = false
811
+ allowConflict = false,
702
812
  ): Promise<EntityInterface> {
703
813
  if (entityData.class === 'Entity') {
704
814
  // Don't let clients use the `Entity` class, since it has no validity/AC checks.
705
815
  throw new InvalidParametersError(
706
- "Can't use Entity class directly from the front end."
816
+ "Can't use Entity class directly from the front end.",
707
817
  );
708
818
  }
709
819
  let EntityClass = nymph.getEntityClass(entityData.class);
820
+ if (!EntityClass.restEnabled) {
821
+ throw new ForbiddenClassError('Not accessible.');
822
+ }
710
823
  let entity: EntityInterface | null;
711
824
  if (entityData.guid) {
712
825
  entity = await nymph.getEntity(
@@ -714,7 +827,7 @@ export function createServer(
714
827
  {
715
828
  type: '&',
716
829
  guid: `${entityData['guid']}`,
717
- }
830
+ },
718
831
  );
719
832
  if (entity === null) {
720
833
  throw new Error(NOT_FOUND_ERROR);
@@ -739,10 +852,15 @@ export function createServer(
739
852
  * @returns The item, converted.
740
853
  */
741
854
  function referencesToEntities(item: any, nymph: Nymph): any {
742
- if (Array.isArray(item)) {
855
+ if (item == null) {
856
+ return item;
857
+ } else if (Array.isArray(item)) {
743
858
  if (item.length === 3 && item[0] === 'nymph_entity_reference') {
744
859
  try {
745
- const EntityClass = nymph.getEntityClass(item[1]);
860
+ const EntityClass = nymph.getEntityClass(item[2]);
861
+ if (!EntityClass.restEnabled) {
862
+ throw new ForbiddenClassError('Not accessible.');
863
+ }
746
864
  return EntityClass.factoryReference(item as EntityReference);
747
865
  } catch (e: any) {
748
866
  return item;
@@ -770,7 +888,7 @@ export function createServer(
770
888
  function httpError(
771
889
  res: NymphResponse,
772
890
  defaultStatusCode: number,
773
- error?: Error & { status?: number; statusText?: string }
891
+ error?: Error & { status?: number; statusText?: string },
774
892
  ) {
775
893
  const status = error?.status || defaultStatusCode;
776
894
  const statusText =
@@ -780,26 +898,82 @@ export function createServer(
780
898
  : error.status in statusDescriptions &&
781
899
  statusDescriptions[error.status]) ||
782
900
  'Internal Server Error';
901
+
902
+ const errorResponse = error
903
+ ? {
904
+ textStatus: `${status} ${statusText}`,
905
+ statusText,
906
+ message: error.message,
907
+ error,
908
+ ...(process.env.NODE_ENV !== 'production'
909
+ ? { stack: error.stack }
910
+ : {}),
911
+ }
912
+ : {
913
+ textStatus: `${status} ${statusText}`,
914
+ statusText,
915
+ message: statusText,
916
+ };
917
+
783
918
  if (!res.headersSent) {
784
919
  res.status(status);
785
920
  res.setHeader('Content-Type', 'application/json');
786
921
  }
787
- if (error) {
788
- res.send({
789
- textStatus: `${status} ${statusText}`,
790
- statusText,
791
- message: error.message,
792
- error,
793
- ...(process.env.NODE_ENV !== 'production'
794
- ? { stack: error.stack }
795
- : {}),
796
- });
922
+
923
+ res.send(errorResponse);
924
+ }
925
+
926
+ /**
927
+ * End the event stream with an HTTP error response.
928
+ *
929
+ * @param res The server response object.
930
+ * @param defaultStatusCode The HTTP status code to use if none is given in the error object.
931
+ * @param error An optional error object to report.
932
+ */
933
+ function eventStreamError(
934
+ res: NymphResponse,
935
+ defaultStatusCode: number,
936
+ error?: Error & { status?: number; statusText?: string },
937
+ ) {
938
+ const status = error?.status || defaultStatusCode;
939
+ const statusText =
940
+ error?.statusText ||
941
+ (error?.status == null
942
+ ? statusDescriptions[defaultStatusCode]
943
+ : error.status in statusDescriptions &&
944
+ statusDescriptions[error.status]) ||
945
+ 'Internal Server Error';
946
+
947
+ const errorResponse = error
948
+ ? {
949
+ status,
950
+ textStatus: `${status} ${statusText}`,
951
+ statusText,
952
+ message: error.message,
953
+ error,
954
+ ...(process.env.NODE_ENV !== 'production'
955
+ ? { stack: error.stack }
956
+ : {}),
957
+ }
958
+ : {
959
+ status,
960
+ textStatus: `${status} ${statusText}`,
961
+ statusText,
962
+ message: statusText,
963
+ };
964
+
965
+ if (!res.headersSent) {
966
+ res.status(status);
967
+ res.setHeader('Content-Type', 'application/json');
968
+
969
+ res.send(errorResponse);
797
970
  } else {
798
- res.send({
799
- textStatus: `${status} ${statusText}`,
800
- statusText,
801
- message: statusText,
802
- });
971
+ res.write('event: error\n');
972
+ res.write(`data: ${JSON.stringify(errorResponse)}\n\n`);
973
+
974
+ res.write('event: finished\n');
975
+ res.write('data: \n\n');
976
+ res.end();
803
977
  }
804
978
  }
805
979