@terreno/api 0.10.0 → 0.11.0
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/bunfig.toml +5 -2
- package/dist/auth.test.js +257 -0
- package/dist/consentApp.test.js +245 -0
- package/dist/githubAuth.test.js +380 -0
- package/dist/logger.test.d.ts +1 -0
- package/dist/logger.test.js +143 -0
- package/dist/notifiers/googleChatNotifier.test.js +37 -0
- package/dist/openApi.js +2 -2
- package/dist/openApi.test.js +122 -0
- package/dist/openApiBuilder.d.ts +1 -0
- package/dist/openApiBuilder.js +13 -2
- package/dist/openApiBuilder.test.js +66 -0
- package/dist/openApiValidator.test.js +309 -0
- package/dist/plugins.d.ts +8 -8
- package/dist/plugins.js +38 -32
- package/dist/populate.test.js +99 -0
- package/dist/syncConsents.test.js +273 -0
- package/dist/tests.d.ts +3 -3
- package/dist/tests.js +78 -82
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +7 -7
- package/package.json +2 -1
- package/src/__snapshots__/openApi.test.ts.snap +48 -0
- package/src/auth.test.ts +147 -0
- package/src/consentApp.test.ts +162 -0
- package/src/githubAuth.test.ts +307 -1
- package/src/logger.test.ts +149 -0
- package/src/notifiers/googleChatNotifier.test.ts +24 -0
- package/src/openApi.test.ts +152 -0
- package/src/openApi.ts +6 -2
- package/src/openApiBuilder.test.ts +81 -0
- package/src/openApiBuilder.ts +17 -2
- package/src/openApiValidator.test.ts +410 -0
- package/src/plugins.ts +32 -23
- package/src/populate.test.ts +78 -2
- package/src/syncConsents.test.ts +145 -0
- package/src/tests.ts +8 -8
- package/src/utils.ts +4 -4
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import type {ErrorObject} from "ajv";
|
|
2
3
|
|
|
3
4
|
import {modelRouter} from "./api";
|
|
4
5
|
import {addAuthRoutes, setupAuth} from "./auth";
|
|
5
6
|
import {
|
|
6
7
|
buildQuerySchemaFromFields,
|
|
7
8
|
configureOpenApiValidator,
|
|
9
|
+
createModelValidators,
|
|
10
|
+
createValidator,
|
|
11
|
+
getOpenApiValidatorConfig,
|
|
12
|
+
getSchemaFromModel,
|
|
8
13
|
isOpenApiValidatorConfigured,
|
|
9
14
|
resetOpenApiValidatorConfig,
|
|
15
|
+
validateModelRequestBody,
|
|
16
|
+
validateQueryParams,
|
|
10
17
|
validateRequestBody,
|
|
18
|
+
validateResponseData,
|
|
11
19
|
} from "./openApiValidator";
|
|
12
20
|
import {Permissions} from "./permissions";
|
|
13
21
|
import {authAsUser, FoodModel, getBaseServer, RequiredModel, setupDb, UserModel} from "./tests";
|
|
@@ -237,5 +245,407 @@ describe("openApiValidator", () => {
|
|
|
237
245
|
middleware(req, res, () => {});
|
|
238
246
|
}).toThrow();
|
|
239
247
|
});
|
|
248
|
+
|
|
249
|
+
it("calls onError callback instead of throwing when provided at option level", () => {
|
|
250
|
+
configureOpenApiValidator();
|
|
251
|
+
|
|
252
|
+
let capturedErrors: ErrorObject[] = [];
|
|
253
|
+
const middleware = validateRequestBody(
|
|
254
|
+
{name: {required: true, type: "string"}},
|
|
255
|
+
{
|
|
256
|
+
onError: (errors) => {
|
|
257
|
+
capturedErrors = errors;
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
let nextCalled = false;
|
|
263
|
+
const req = {body: {}, method: "POST", path: "/test"} as any;
|
|
264
|
+
const res = {} as any;
|
|
265
|
+
middleware(req, res, () => {
|
|
266
|
+
nextCalled = true;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(nextCalled).toBe(true);
|
|
270
|
+
expect(capturedErrors.length).toBeGreaterThan(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("calls global onValidationError when no per-route handler", () => {
|
|
274
|
+
let capturedErrors: ErrorObject[] = [];
|
|
275
|
+
configureOpenApiValidator({
|
|
276
|
+
onValidationError: (errors) => {
|
|
277
|
+
capturedErrors = errors;
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const middleware = validateRequestBody({name: {required: true, type: "string"}});
|
|
282
|
+
|
|
283
|
+
let nextCalled = false;
|
|
284
|
+
const req = {body: {}, method: "POST", path: "/test"} as any;
|
|
285
|
+
const res = {} as any;
|
|
286
|
+
middleware(req, res, () => {
|
|
287
|
+
nextCalled = true;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(nextCalled).toBe(true);
|
|
291
|
+
expect(capturedErrors.length).toBeGreaterThan(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("skips validation when enabled=false on options", () => {
|
|
295
|
+
configureOpenApiValidator();
|
|
296
|
+
|
|
297
|
+
const middleware = validateRequestBody(
|
|
298
|
+
{name: {required: true, type: "string"}},
|
|
299
|
+
{enabled: false}
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
let nextCalled = false;
|
|
303
|
+
const req = {body: {}, method: "POST", path: "/test"} as any;
|
|
304
|
+
const res = {} as any;
|
|
305
|
+
middleware(req, res, () => {
|
|
306
|
+
nextCalled = true;
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(nextCalled).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("respects validateRequests=false in global config", () => {
|
|
313
|
+
configureOpenApiValidator({validateRequests: false});
|
|
314
|
+
|
|
315
|
+
const middleware = validateRequestBody({name: {required: true, type: "string"}});
|
|
316
|
+
|
|
317
|
+
let nextCalled = false;
|
|
318
|
+
const req = {body: {}, method: "POST", path: "/test"} as any;
|
|
319
|
+
const res = {} as any;
|
|
320
|
+
middleware(req, res, () => {
|
|
321
|
+
nextCalled = true;
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(nextCalled).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("validateQueryParams middleware", () => {
|
|
329
|
+
it("is a no-op when not configured", () => {
|
|
330
|
+
resetOpenApiValidatorConfig();
|
|
331
|
+
|
|
332
|
+
const middleware = validateQueryParams({
|
|
333
|
+
page: {type: "number"},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
let nextCalled = false;
|
|
337
|
+
const req = {method: "GET", path: "/test", query: {}} as any;
|
|
338
|
+
const res = {} as any;
|
|
339
|
+
middleware(req, res, () => {
|
|
340
|
+
nextCalled = true;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(nextCalled).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("coerces types when configured", () => {
|
|
347
|
+
configureOpenApiValidator({coerceTypes: true});
|
|
348
|
+
|
|
349
|
+
const middleware = validateQueryParams({
|
|
350
|
+
page: {type: "number"},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const req = {method: "GET", path: "/test", query: {page: "3"}} as any;
|
|
354
|
+
const res = {} as any;
|
|
355
|
+
middleware(req, res, () => {});
|
|
356
|
+
|
|
357
|
+
expect(req.query.page).toBe(3);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("throws validation error when query does not match", () => {
|
|
361
|
+
configureOpenApiValidator({coerceTypes: false});
|
|
362
|
+
|
|
363
|
+
const middleware = validateQueryParams({
|
|
364
|
+
page: {required: true, type: "number"},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const req = {method: "GET", path: "/test", query: {}} as any;
|
|
368
|
+
const res = {} as any;
|
|
369
|
+
// Required top-level property missing triggers an error
|
|
370
|
+
// We need required: [] at schema level via required: true on property. Confirm via calling.
|
|
371
|
+
expect(() => {
|
|
372
|
+
middleware(req, res, () => {});
|
|
373
|
+
}).toThrow();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("uses onError callback for query validation", () => {
|
|
377
|
+
let captured: any[] = [];
|
|
378
|
+
configureOpenApiValidator({coerceTypes: false});
|
|
379
|
+
|
|
380
|
+
const middleware = validateQueryParams(
|
|
381
|
+
{page: {required: true, type: "number"}},
|
|
382
|
+
{
|
|
383
|
+
onError: (errors) => {
|
|
384
|
+
captured = errors;
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
let nextCalled = false;
|
|
390
|
+
const req = {method: "GET", path: "/test", query: {}} as any;
|
|
391
|
+
const res = {} as any;
|
|
392
|
+
middleware(req, res, () => {
|
|
393
|
+
nextCalled = true;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(nextCalled).toBe(true);
|
|
397
|
+
expect(captured.length).toBeGreaterThan(0);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("skips query validation when enabled=false", () => {
|
|
401
|
+
configureOpenApiValidator();
|
|
402
|
+
|
|
403
|
+
const middleware = validateQueryParams(
|
|
404
|
+
{page: {required: true, type: "number"}},
|
|
405
|
+
{enabled: false}
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
let nextCalled = false;
|
|
409
|
+
const req = {method: "GET", path: "/test", query: {}} as any;
|
|
410
|
+
const res = {} as any;
|
|
411
|
+
middleware(req, res, () => {
|
|
412
|
+
nextCalled = true;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
expect(nextCalled).toBe(true);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("validateResponseData", () => {
|
|
420
|
+
it("returns valid when validateResponses is disabled", () => {
|
|
421
|
+
configureOpenApiValidator({validateResponses: false});
|
|
422
|
+
const result = validateResponseData({foo: "bar"}, {name: {type: "string"}});
|
|
423
|
+
expect(result.valid).toBe(true);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("validates response shape when validateResponses is enabled", () => {
|
|
427
|
+
configureOpenApiValidator({validateResponses: true});
|
|
428
|
+
const result = validateResponseData(
|
|
429
|
+
{name: "Apple"},
|
|
430
|
+
{name: {required: true, type: "string"}}
|
|
431
|
+
);
|
|
432
|
+
expect(result.valid).toBe(true);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("returns errors for invalid response shape", () => {
|
|
436
|
+
configureOpenApiValidator({coerceTypes: false, validateResponses: true});
|
|
437
|
+
const result = validateResponseData(
|
|
438
|
+
{name: 42 as any},
|
|
439
|
+
{name: {required: true, type: "string"}}
|
|
440
|
+
);
|
|
441
|
+
expect(result.valid).toBe(false);
|
|
442
|
+
expect(result.errors).toBeDefined();
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe("createValidator", () => {
|
|
447
|
+
it("runs body then query validation and calls next once both pass", () => {
|
|
448
|
+
configureOpenApiValidator({coerceTypes: true});
|
|
449
|
+
|
|
450
|
+
const middleware = createValidator({
|
|
451
|
+
body: {name: {required: true, type: "string"}},
|
|
452
|
+
query: {page: {type: "number"}},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
let nextCalled = false;
|
|
456
|
+
const req = {
|
|
457
|
+
body: {name: "ok"},
|
|
458
|
+
method: "POST",
|
|
459
|
+
path: "/test",
|
|
460
|
+
query: {page: "2"},
|
|
461
|
+
} as any;
|
|
462
|
+
const res = {} as any;
|
|
463
|
+
middleware(req, res, () => {
|
|
464
|
+
nextCalled = true;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
expect(nextCalled).toBe(true);
|
|
468
|
+
expect(req.query.page).toBe(2);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("skips when neither body nor query schemas are provided", () => {
|
|
472
|
+
configureOpenApiValidator();
|
|
473
|
+
|
|
474
|
+
const middleware = createValidator({});
|
|
475
|
+
|
|
476
|
+
let nextCalled = false;
|
|
477
|
+
const req = {body: {}, method: "POST", path: "/test", query: {}} as any;
|
|
478
|
+
const res = {} as any;
|
|
479
|
+
middleware(req, res, () => {
|
|
480
|
+
nextCalled = true;
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
expect(nextCalled).toBe(true);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("runs only query validation when only query provided", () => {
|
|
487
|
+
configureOpenApiValidator({coerceTypes: true});
|
|
488
|
+
|
|
489
|
+
const middleware = createValidator({
|
|
490
|
+
query: {page: {type: "number"}},
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
let nextCalled = false;
|
|
494
|
+
const req = {method: "GET", path: "/test", query: {page: "5"}} as any;
|
|
495
|
+
const res = {} as any;
|
|
496
|
+
middleware(req, res, () => {
|
|
497
|
+
nextCalled = true;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(nextCalled).toBe(true);
|
|
501
|
+
expect(req.query.page).toBe(5);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("propagates body validation error via next", () => {
|
|
505
|
+
let capturedErrors: ErrorObject[] = [];
|
|
506
|
+
configureOpenApiValidator({
|
|
507
|
+
onValidationError: (errors) => {
|
|
508
|
+
capturedErrors = errors;
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const middleware = createValidator({
|
|
513
|
+
body: {name: {required: true, type: "string"}},
|
|
514
|
+
query: {page: {type: "number"}},
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const req = {body: {}, method: "POST", path: "/test", query: {}} as any;
|
|
518
|
+
const res = {} as any;
|
|
519
|
+
|
|
520
|
+
let nextCalled = false;
|
|
521
|
+
middleware(req, res, () => {
|
|
522
|
+
nextCalled = true;
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
expect(capturedErrors.length).toBeGreaterThan(0);
|
|
526
|
+
expect(nextCalled).toBe(true);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("createModelValidators", () => {
|
|
531
|
+
beforeEach(() => {
|
|
532
|
+
configureOpenApiValidator();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("returns create and update middleware", () => {
|
|
536
|
+
const validators = createModelValidators(RequiredModel);
|
|
537
|
+
expect(typeof validators.create).toBe("function");
|
|
538
|
+
expect(typeof validators.update).toBe("function");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("create validator rejects body with wrong type", () => {
|
|
542
|
+
configureOpenApiValidator({coerceTypes: false});
|
|
543
|
+
const {create} = createModelValidators(RequiredModel);
|
|
544
|
+
|
|
545
|
+
const req = {body: {name: 123}, method: "POST", path: "/required"} as any;
|
|
546
|
+
const res = {} as any;
|
|
547
|
+
expect(() => {
|
|
548
|
+
create(req, res, () => {});
|
|
549
|
+
}).toThrow();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("update validator allows a partial body", () => {
|
|
553
|
+
const {update} = createModelValidators(RequiredModel);
|
|
554
|
+
|
|
555
|
+
let nextCalled = false;
|
|
556
|
+
const req = {body: {about: "info"}, method: "PATCH", path: "/required"} as any;
|
|
557
|
+
const res = {} as any;
|
|
558
|
+
update(req, res, () => {
|
|
559
|
+
nextCalled = true;
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
expect(nextCalled).toBe(true);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("supports onAdditionalPropertiesRemoved option", () => {
|
|
566
|
+
configureOpenApiValidator({removeAdditional: true});
|
|
567
|
+
const removedProps: string[] = [];
|
|
568
|
+
const {create} = createModelValidators(RequiredModel, {
|
|
569
|
+
onAdditionalPropertiesRemoved: (props) => {
|
|
570
|
+
removedProps.push(...props);
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Extra property gets stripped and hook is called
|
|
575
|
+
const req = {
|
|
576
|
+
body: {extra: "strip me", name: "Apple"},
|
|
577
|
+
method: "POST",
|
|
578
|
+
path: "/required",
|
|
579
|
+
} as any;
|
|
580
|
+
create(req, {} as any, () => {});
|
|
581
|
+
expect(removedProps).toContain("extra");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("supports onError option", () => {
|
|
585
|
+
configureOpenApiValidator({coerceTypes: false});
|
|
586
|
+
let errorHandled: any[] = [];
|
|
587
|
+
const {create} = createModelValidators(RequiredModel, {
|
|
588
|
+
onError: (errors) => {
|
|
589
|
+
errorHandled = errors;
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// Wrong type triggers error handler
|
|
594
|
+
const req = {body: {name: 42}, method: "POST", path: "/required"} as any;
|
|
595
|
+
create(req, {} as any, () => {});
|
|
596
|
+
expect(errorHandled.length).toBeGreaterThan(0);
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("validateModelRequestBody", () => {
|
|
601
|
+
beforeEach(() => {
|
|
602
|
+
configureOpenApiValidator();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("creates a validator from a Mongoose model", () => {
|
|
606
|
+
const middleware = validateModelRequestBody(RequiredModel);
|
|
607
|
+
|
|
608
|
+
const req = {body: {}, method: "POST", path: "/required"} as any;
|
|
609
|
+
const res = {} as any;
|
|
610
|
+
expect(() => {
|
|
611
|
+
middleware(req, res, () => {});
|
|
612
|
+
}).toThrow();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("respects excludeFields option", () => {
|
|
616
|
+
const middleware = validateModelRequestBody(RequiredModel, {
|
|
617
|
+
excludeFields: ["name"],
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// Without 'name' required, empty body passes
|
|
621
|
+
let nextCalled = false;
|
|
622
|
+
const req = {body: {}, method: "POST", path: "/required"} as any;
|
|
623
|
+
const res = {} as any;
|
|
624
|
+
middleware(req, res, () => {
|
|
625
|
+
nextCalled = true;
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
expect(nextCalled).toBe(true);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
describe("getSchemaFromModel", () => {
|
|
633
|
+
it("returns properties for a Mongoose model", () => {
|
|
634
|
+
const schema = getSchemaFromModel(RequiredModel);
|
|
635
|
+
expect(schema.name).toBeDefined();
|
|
636
|
+
expect(schema.about).toBeDefined();
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
describe("getOpenApiValidatorConfig", () => {
|
|
641
|
+
it("returns a shallow copy of the config", () => {
|
|
642
|
+
configureOpenApiValidator({removeAdditional: false});
|
|
643
|
+
const config = getOpenApiValidatorConfig();
|
|
644
|
+
expect(config.removeAdditional).toBe(false);
|
|
645
|
+
|
|
646
|
+
// Mutating the returned copy should not affect internal state
|
|
647
|
+
(config as any).removeAdditional = true;
|
|
648
|
+
expect(getOpenApiValidatorConfig().removeAdditional).toBe(false);
|
|
649
|
+
});
|
|
240
650
|
});
|
|
241
651
|
});
|
package/src/plugins.ts
CHANGED
|
@@ -15,10 +15,12 @@ export interface BaseUser {
|
|
|
15
15
|
email: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export
|
|
19
|
-
schema.add({
|
|
20
|
-
|
|
21
|
-
}
|
|
18
|
+
export const baseUserPlugin = (schema: Schema<any, any, any, any>): void => {
|
|
19
|
+
schema.add({
|
|
20
|
+
admin: {default: false, description: "Whether the user has admin privileges", type: Boolean},
|
|
21
|
+
});
|
|
22
|
+
schema.add({email: {description: "The user's email address", index: true, type: String}});
|
|
23
|
+
};
|
|
22
24
|
|
|
23
25
|
/** For models with the isDeletedPlugin, extend this interface to add the appropriate fields. */
|
|
24
26
|
export interface IsDeleted {
|
|
@@ -26,7 +28,7 @@ export interface IsDeleted {
|
|
|
26
28
|
deleted: boolean;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
export
|
|
31
|
+
export const isDeletedPlugin = (schema: Schema<any, any, any, any>, defaultValue = false): void => {
|
|
30
32
|
schema.add({
|
|
31
33
|
deleted: {
|
|
32
34
|
default: defaultValue,
|
|
@@ -37,21 +39,24 @@ export function isDeletedPlugin(schema: Schema<any, any, any, any>, defaultValue
|
|
|
37
39
|
type: Boolean,
|
|
38
40
|
},
|
|
39
41
|
});
|
|
40
|
-
|
|
42
|
+
const applyDeleteFilter = (q: Query<any, any>): void => {
|
|
41
43
|
const query = q.getQuery();
|
|
42
44
|
if (query && query.deleted === undefined) {
|
|
43
45
|
void q.where({deleted: {$ne: true}});
|
|
44
46
|
}
|
|
45
|
-
}
|
|
47
|
+
};
|
|
46
48
|
schema.pre("find", function () {
|
|
47
49
|
applyDeleteFilter(this);
|
|
48
50
|
});
|
|
49
51
|
schema.pre("findOne", function () {
|
|
50
52
|
applyDeleteFilter(this);
|
|
51
53
|
});
|
|
52
|
-
}
|
|
54
|
+
};
|
|
53
55
|
|
|
54
|
-
export
|
|
56
|
+
export const isDisabledPlugin = (
|
|
57
|
+
schema: Schema<any, any, any, any>,
|
|
58
|
+
defaultValue = false
|
|
59
|
+
): void => {
|
|
55
60
|
schema.add({
|
|
56
61
|
disabled: {
|
|
57
62
|
default: defaultValue,
|
|
@@ -60,16 +65,18 @@ export function isDisabledPlugin(schema: Schema<any, any, any, any>, defaultValu
|
|
|
60
65
|
type: Boolean,
|
|
61
66
|
},
|
|
62
67
|
});
|
|
63
|
-
}
|
|
68
|
+
};
|
|
64
69
|
|
|
65
70
|
export interface CreatedDeleted {
|
|
66
71
|
updated: {type: Date; required: true};
|
|
67
72
|
created: {type: Date; required: true};
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
export
|
|
71
|
-
schema.add({
|
|
72
|
-
|
|
75
|
+
export const createdUpdatedPlugin = (schema: Schema<any, any, any, any>): void => {
|
|
76
|
+
schema.add({
|
|
77
|
+
updated: {description: "When this document was last updated", index: true, type: Date},
|
|
78
|
+
});
|
|
79
|
+
schema.add({created: {description: "When this document was created", index: true, type: Date}});
|
|
73
80
|
|
|
74
81
|
schema.pre("save", function () {
|
|
75
82
|
if (this.disableCreatedUpdatedPlugin === true) {
|
|
@@ -86,11 +93,13 @@ export function createdUpdatedPlugin(schema: Schema<any, any, any, any>) {
|
|
|
86
93
|
schema.pre(/save|updateOne|insertMany/, function () {
|
|
87
94
|
void this.updateOne({}, {$set: {updated: new Date()}});
|
|
88
95
|
});
|
|
89
|
-
}
|
|
96
|
+
};
|
|
90
97
|
|
|
91
|
-
export
|
|
92
|
-
schema.add({
|
|
93
|
-
}
|
|
98
|
+
export const firebaseJWTPlugin = (schema: Schema): void => {
|
|
99
|
+
schema.add({
|
|
100
|
+
firebaseId: {description: "The user's Firebase authentication ID", index: true, type: String},
|
|
101
|
+
});
|
|
102
|
+
};
|
|
94
103
|
|
|
95
104
|
/**
|
|
96
105
|
* This adds a static method `Model.findOneOrNone` to the schema. This should replace `Model.findOne` in most instances.
|
|
@@ -99,7 +108,7 @@ export function firebaseJWTPlugin(schema: Schema) {
|
|
|
99
108
|
* document, or throws an exception if multiple are found.
|
|
100
109
|
* @param schema Mongoose Schema
|
|
101
110
|
*/
|
|
102
|
-
export
|
|
111
|
+
export const findOneOrNone = <T>(schema: Schema<T>): void => {
|
|
103
112
|
schema.statics.findOneOrNone = async function (
|
|
104
113
|
query: Record<string, any>,
|
|
105
114
|
errorArgs?: Partial<APIErrorConstructor>
|
|
@@ -118,7 +127,7 @@ export function findOneOrNone<T>(schema: Schema<T>) {
|
|
|
118
127
|
}
|
|
119
128
|
return results[0];
|
|
120
129
|
};
|
|
121
|
-
}
|
|
130
|
+
};
|
|
122
131
|
|
|
123
132
|
/**
|
|
124
133
|
* This adds a static method `Model.findExactlyOne` to the schema. This or findOneOrNone should replace `Model.findOne`
|
|
@@ -128,7 +137,7 @@ export function findOneOrNone<T>(schema: Schema<T>) {
|
|
|
128
137
|
* multiple or none are found.
|
|
129
138
|
* @param schema Mongoose Schema
|
|
130
139
|
*/
|
|
131
|
-
export
|
|
140
|
+
export const findExactlyOne = <T>(schema: Schema<T>): void => {
|
|
132
141
|
schema.statics.findExactlyOne = async function (
|
|
133
142
|
query: Record<string, any>,
|
|
134
143
|
errorArgs?: Partial<APIErrorConstructor>
|
|
@@ -152,7 +161,7 @@ export function findExactlyOne<T>(schema: Schema<T>) {
|
|
|
152
161
|
}
|
|
153
162
|
return results[0];
|
|
154
163
|
};
|
|
155
|
-
}
|
|
164
|
+
};
|
|
156
165
|
|
|
157
166
|
/**
|
|
158
167
|
* This adds a static method `Model.upsert` to the schema. This method will either update an existing document
|
|
@@ -160,7 +169,7 @@ export function findExactlyOne<T>(schema: Schema<T>) {
|
|
|
160
169
|
* match the conditions to prevent ambiguous updates.
|
|
161
170
|
* @param schema Mongoose Schema
|
|
162
171
|
*/
|
|
163
|
-
export
|
|
172
|
+
export const upsertPlugin = <T>(schema: Schema<any, any, any, any>): void => {
|
|
164
173
|
schema.statics.upsert = async function (
|
|
165
174
|
conditions: Record<string, any>,
|
|
166
175
|
update: Record<string, any>
|
|
@@ -187,7 +196,7 @@ export function upsertPlugin<T>(schema: Schema<any, any, any, any>) {
|
|
|
187
196
|
const newDoc = new this(combinedData);
|
|
188
197
|
return newDoc.save();
|
|
189
198
|
};
|
|
190
|
-
}
|
|
199
|
+
};
|
|
191
200
|
|
|
192
201
|
/** For models with the upsertPlugin, extend this interface to add the upsert static method. */
|
|
193
202
|
export interface HasUpsert<T> {
|
package/src/populate.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import mongoose, {Schema} from "mongoose";
|
|
2
3
|
|
|
3
|
-
import {unpopulate} from "./populate";
|
|
4
|
-
import {FoodModel, setupDb} from "./tests";
|
|
4
|
+
import {fixMixedFields, getOpenApiSpecForModel, unpopulate} from "./populate";
|
|
5
|
+
import {FoodModel, setupDb, UserModel} from "./tests";
|
|
5
6
|
|
|
6
7
|
describe("populate functions", () => {
|
|
7
8
|
let admin: any;
|
|
@@ -121,3 +122,78 @@ describe("unpopulate edge cases", () => {
|
|
|
121
122
|
expect(result.containers[1].items).toEqual(["item-3", "item-4"]);
|
|
122
123
|
});
|
|
123
124
|
});
|
|
125
|
+
|
|
126
|
+
describe("fixMixedFields", () => {
|
|
127
|
+
it("returns early when schema is missing", () => {
|
|
128
|
+
const properties = {foo: {type: "object"}};
|
|
129
|
+
expect(() => fixMixedFields(null, properties)).not.toThrow();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns early when properties is missing", () => {
|
|
133
|
+
const schema = new Schema({});
|
|
134
|
+
expect(() => fixMixedFields(schema, null as any)).not.toThrow();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("replaces Mixed fields with only description", () => {
|
|
138
|
+
const schema = new Schema({data: {description: "any data", type: Schema.Types.Mixed}});
|
|
139
|
+
const properties: any = {data: {description: "any data", type: "object"}};
|
|
140
|
+
fixMixedFields(schema, properties);
|
|
141
|
+
expect(properties.data).toEqual({description: "any data"});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("recurses into arrays of sub-documents", () => {
|
|
145
|
+
const subSchema = new Schema({meta: {type: Schema.Types.Mixed}});
|
|
146
|
+
const schema = new Schema({items: [subSchema]});
|
|
147
|
+
const properties: any = {
|
|
148
|
+
items: {
|
|
149
|
+
items: {
|
|
150
|
+
properties: {
|
|
151
|
+
meta: {type: "object"},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
type: "array",
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
fixMixedFields(schema, properties);
|
|
158
|
+
expect(properties.items.items.properties.meta).toEqual({description: undefined});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("skips unknown paths", () => {
|
|
162
|
+
const schema = new Schema({foo: String});
|
|
163
|
+
const properties = {unknownKey: {type: "string"}};
|
|
164
|
+
expect(() => fixMixedFields(schema, properties)).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("getOpenApiSpecForModel edge cases", () => {
|
|
169
|
+
it("returns model properties without populatePaths", () => {
|
|
170
|
+
const result = getOpenApiSpecForModel(UserModel);
|
|
171
|
+
expect(result.properties).toBeDefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns with extraModelProperties merged", () => {
|
|
175
|
+
const result = getOpenApiSpecForModel(UserModel, {
|
|
176
|
+
extraModelProperties: {customField: {type: "string"}},
|
|
177
|
+
});
|
|
178
|
+
expect(result.properties.customField).toEqual({type: "string"});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("skips populate paths without ref", () => {
|
|
182
|
+
// Create a schema with a non-referenced ObjectId field
|
|
183
|
+
const testSchema = new Schema({name: String, simpleId: Schema.Types.ObjectId});
|
|
184
|
+
const TestModelNoRef =
|
|
185
|
+
mongoose.models.TestModelNoRef || mongoose.model("TestModelNoRef", testSchema);
|
|
186
|
+
const result = getOpenApiSpecForModel(TestModelNoRef, {
|
|
187
|
+
populatePaths: [{path: "simpleId"}],
|
|
188
|
+
});
|
|
189
|
+
// Should not throw, simpleId stays as-is
|
|
190
|
+
expect(result.properties).toBeDefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("populates with fields allowlist", () => {
|
|
194
|
+
const result = getOpenApiSpecForModel(FoodModel, {
|
|
195
|
+
populatePaths: [{fields: ["name"], path: "ownerId"}],
|
|
196
|
+
});
|
|
197
|
+
expect(result.properties).toBeDefined();
|
|
198
|
+
});
|
|
199
|
+
});
|