adapt-authoring-core 2.2.4 → 2.3.1
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/docs/hooks.md +82 -19
- package/lib/Hook.js +30 -2
- package/package.json +1 -1
- package/tests/Hook.spec.js +176 -4
package/docs/hooks.md
CHANGED
|
@@ -8,12 +8,13 @@ A hook is a point in the code where external observers can run their own functio
|
|
|
8
8
|
|
|
9
9
|
All hook observers must complete before the operation continues. For example, a document won't be inserted until all `preInsertHook` observers have finished executing.
|
|
10
10
|
|
|
11
|
-
###
|
|
11
|
+
### Hook types
|
|
12
12
|
|
|
13
|
-
Hooks
|
|
13
|
+
Hooks support three execution types:
|
|
14
14
|
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
15
|
+
- **Parallel** (default): observers run at the same time. Observers receive a deep copy of arguments to prevent unintended modifications.
|
|
16
|
+
- **Series**: observers run one after another. When `mutable: true` is set, observers can modify shared arguments in place.
|
|
17
|
+
- **Middleware**: observers wrap a core function using a `next()` pattern (like Express middleware). Each observer receives `(next, ...args)` and must call `next(...args)` to continue the chain. This allows observers to run logic both before and after the core operation, with shared scope across both.
|
|
17
18
|
|
|
18
19
|
## Basic usage
|
|
19
20
|
|
|
@@ -32,12 +33,23 @@ class MyModule extends AbstractModule {
|
|
|
32
33
|
|
|
33
34
|
// force observers to run in series
|
|
34
35
|
this.mySeriesHook = new Hook({ type: Hook.Types.Series })
|
|
36
|
+
|
|
37
|
+
// middleware hook — observers wrap a core function
|
|
38
|
+
this.myMiddlewareHook = new Hook({ type: Hook.Types.Middleware })
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
async doSomething () {
|
|
38
42
|
// Invoke the hook, passing any relevant data
|
|
39
43
|
await this.myBasicHook.invoke(someData)
|
|
40
44
|
}
|
|
45
|
+
|
|
46
|
+
async doSomethingWrapped (data) {
|
|
47
|
+
// Invoke middleware hook — first arg is the core function, rest are passed through
|
|
48
|
+
const coreFn = async (data) => {
|
|
49
|
+
return await this.db.insert(data)
|
|
50
|
+
}
|
|
51
|
+
return this.myMiddlewareHook.invoke(coreFn, data)
|
|
52
|
+
}
|
|
41
53
|
}
|
|
42
54
|
```
|
|
43
55
|
|
|
@@ -106,21 +118,24 @@ try {
|
|
|
106
118
|
|
|
107
119
|
Below are some commonly used hooks, which you may find useful.
|
|
108
120
|
|
|
109
|
-
| Module | Hook | Description | Parameters |
|
|
110
|
-
| ------ | ---- | ----------- | ---------- |
|
|
111
|
-
| AbstractModule | `readyHook` | Module has initialised | |
|
|
112
|
-
| AbstractApiModule | `requestHook` | API request received | `(req)` |
|
|
113
|
-
| AbstractApiModule | `
|
|
114
|
-
| AbstractApiModule | `
|
|
115
|
-
| AbstractApiModule | `
|
|
116
|
-
| AbstractApiModule | `
|
|
117
|
-
| AbstractApiModule | `
|
|
118
|
-
| AbstractApiModule | `
|
|
119
|
-
| AbstractApiModule | `
|
|
120
|
-
|
|
|
121
|
-
|
|
|
122
|
-
|
|
|
123
|
-
|
|
|
121
|
+
| Module | Hook | Description | Parameters | Type |
|
|
122
|
+
| ------ | ---- | ----------- | ---------- | :--: |
|
|
123
|
+
| AbstractModule | `readyHook` | Module has initialised | | Parallel |
|
|
124
|
+
| AbstractApiModule | `requestHook` | API request received | `(req)` | Mutable |
|
|
125
|
+
| AbstractApiModule | `insertHook` | Wraps the insert operation | `(next, data, options, mongoOptions)` | Middleware |
|
|
126
|
+
| AbstractApiModule | `updateHook` | Wraps the update operation | `(next, query, data, options, mongoOptions)` | Middleware |
|
|
127
|
+
| AbstractApiModule | `deleteHook` | Wraps the delete operation | `(next, query, options, mongoOptions)` | Middleware |
|
|
128
|
+
| AbstractApiModule | `preInsertHook` | Before document insert | `(data, options, mongoOptions)` | Mutable |
|
|
129
|
+
| AbstractApiModule | `postInsertHook` | After document insert | `(doc)` | Parallel |
|
|
130
|
+
| AbstractApiModule | `preUpdateHook` | Before document update | `(originalDoc, newData, options, mongoOptions)` | Mutable |
|
|
131
|
+
| AbstractApiModule | `postUpdateHook` | After document update | `(originalDoc, updatedDoc)` | Parallel |
|
|
132
|
+
| AbstractApiModule | `preDeleteHook` | Before document delete | `(doc, options, mongoOptions)` | Parallel |
|
|
133
|
+
| AbstractApiModule | `postDeleteHook` | After document delete | `(doc)` | Parallel |
|
|
134
|
+
| AbstractApiModule | `accessCheckHook` | Check document access | `(req, doc)` | Parallel |
|
|
135
|
+
| AdaptFrameworkBuild | `preBuildHook` | Before course build starts | | Mutable |
|
|
136
|
+
| AdaptFrameworkBuild | `postBuildHook` | After course build completes | | Mutable |
|
|
137
|
+
| AdaptFrameworkModule | `preImportHook` | Before course import starts | | Mutable |
|
|
138
|
+
| AdaptFrameworkModule | `postImportHook` | After course import completes | | Parallel |
|
|
124
139
|
|
|
125
140
|
## Practical examples
|
|
126
141
|
|
|
@@ -201,6 +216,54 @@ async init () {
|
|
|
201
216
|
}
|
|
202
217
|
```
|
|
203
218
|
|
|
219
|
+
### Wrapping a CRUD operation (middleware)
|
|
220
|
+
|
|
221
|
+
Middleware hooks let you run logic both before and after the core operation, with shared scope. This is useful when you need pre-operation state to inform post-operation actions.
|
|
222
|
+
|
|
223
|
+
```javascript
|
|
224
|
+
async init () {
|
|
225
|
+
await super.init()
|
|
226
|
+
const content = await this.app.waitForModule('content')
|
|
227
|
+
|
|
228
|
+
content.deleteHook.tap(async (next, query, options, mongoOptions) => {
|
|
229
|
+
// gather related data BEFORE the delete
|
|
230
|
+
const item = await content.findOne(query)
|
|
231
|
+
const related = await this.findRelated(item)
|
|
232
|
+
|
|
233
|
+
// run the actual delete
|
|
234
|
+
const result = await next(query, options, mongoOptions)
|
|
235
|
+
|
|
236
|
+
// clean up related data AFTER — item and related are still in scope
|
|
237
|
+
for (const r of related) {
|
|
238
|
+
await this.cleanup(r)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return result
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
You can also use middleware to guard operations. If you don't call `next()`, the operation is blocked:
|
|
247
|
+
|
|
248
|
+
```javascript
|
|
249
|
+
content.insertHook.tap(async (next, data, options, mongoOptions) => {
|
|
250
|
+
if (data._restricted) {
|
|
251
|
+
throw new Error('Cannot insert restricted items')
|
|
252
|
+
}
|
|
253
|
+
return next(data, options, mongoOptions)
|
|
254
|
+
})
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Choosing between pre/post hooks and middleware
|
|
258
|
+
|
|
259
|
+
Use **pre/post hooks** when you only care about one side of an operation — mutating data before a write, or reacting after one. Use **middleware** when you need to own the full lifecycle: gathering state before, acting after, with shared context across both.
|
|
260
|
+
|
|
261
|
+
Middleware wraps the entire operation including pre/post hooks:
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
middleware → pre-hook → validate → write → post-hook → middleware returns
|
|
265
|
+
```
|
|
266
|
+
|
|
204
267
|
### Waiting for server startup
|
|
205
268
|
|
|
206
269
|
```javascript
|
package/lib/Hook.js
CHANGED
|
@@ -9,11 +9,13 @@ class Hook {
|
|
|
9
9
|
* @type {Object}
|
|
10
10
|
* @property {String} Parallel
|
|
11
11
|
* @property {String} Series
|
|
12
|
+
* @property {String} Middleware
|
|
12
13
|
*/
|
|
13
14
|
static get Types () {
|
|
14
15
|
return {
|
|
15
16
|
Parallel: 'parallel',
|
|
16
|
-
Series: 'series'
|
|
17
|
+
Series: 'series',
|
|
18
|
+
Middleware: 'middleware'
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -69,7 +71,10 @@ class Hook {
|
|
|
69
71
|
async invoke (...args) {
|
|
70
72
|
let error, data
|
|
71
73
|
try {
|
|
72
|
-
if (this._options.type === Hook.Types.
|
|
74
|
+
if (this._options.type === Hook.Types.Middleware) {
|
|
75
|
+
const [coreFn, ...rest] = args
|
|
76
|
+
data = await this._invokeMiddleware(coreFn, ...rest)
|
|
77
|
+
} else if (this._options.type === Hook.Types.Parallel) {
|
|
73
78
|
data = await Promise.all(this._hookObservers.map(o => o(...args)))
|
|
74
79
|
} else {
|
|
75
80
|
// if not mutable, send a deep copy of the args to avoid any meddling
|
|
@@ -82,6 +87,29 @@ class Hook {
|
|
|
82
87
|
if (error) throw error
|
|
83
88
|
return data
|
|
84
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Builds and invokes a middleware chain around a core function.
|
|
93
|
+
* Each observer receives (next, ...args) and must call next(...args) to continue the chain.
|
|
94
|
+
* @param {Function} coreFn The core function to wrap
|
|
95
|
+
* @param {...*} args Arguments to pass through the chain
|
|
96
|
+
* @return {Promise}
|
|
97
|
+
*/
|
|
98
|
+
async _invokeMiddleware (coreFn, ...args) {
|
|
99
|
+
let coreResult
|
|
100
|
+
const wrappedCoreFn = async (...a) => {
|
|
101
|
+
coreResult = await coreFn(...a)
|
|
102
|
+
return coreResult
|
|
103
|
+
}
|
|
104
|
+
let fn = wrappedCoreFn
|
|
105
|
+
for (let i = this._hookObservers.length - 1; i >= 0; i--) {
|
|
106
|
+
const observer = this._hookObservers[i]
|
|
107
|
+
const next = fn
|
|
108
|
+
fn = (...a) => observer(next, ...a)
|
|
109
|
+
}
|
|
110
|
+
const result = await fn(...args)
|
|
111
|
+
return result !== undefined ? result : coreResult
|
|
112
|
+
}
|
|
85
113
|
}
|
|
86
114
|
|
|
87
115
|
export default Hook
|
package/package.json
CHANGED
package/tests/Hook.spec.js
CHANGED
|
@@ -11,11 +11,13 @@ describe('Hook', () => {
|
|
|
11
11
|
it('should expose Series type', () => {
|
|
12
12
|
assert.equal(Hook.Types.Series, 'series')
|
|
13
13
|
})
|
|
14
|
-
})
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
it('should expose Middleware type', () => {
|
|
16
|
+
assert.equal(Hook.Types.Middleware, 'middleware')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should have exactly three type keys', () => {
|
|
20
|
+
assert.equal(Object.keys(Hook.Types).length, 3)
|
|
19
21
|
})
|
|
20
22
|
})
|
|
21
23
|
|
|
@@ -342,6 +344,176 @@ describe('Hook', () => {
|
|
|
342
344
|
assert.deepEqual(arr, [1, 2, 3])
|
|
343
345
|
})
|
|
344
346
|
})
|
|
347
|
+
|
|
348
|
+
describe('middleware hooks', () => {
|
|
349
|
+
it('should call the core function when no observers', async () => {
|
|
350
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
351
|
+
let called = false
|
|
352
|
+
const core = async () => { called = true; return 'result' }
|
|
353
|
+
const result = await hook.invoke(core)
|
|
354
|
+
assert.equal(called, true)
|
|
355
|
+
assert.equal(result, 'result')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('should pass arguments through to the core function', async () => {
|
|
359
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
360
|
+
let receivedArgs
|
|
361
|
+
const core = async (...args) => { receivedArgs = args }
|
|
362
|
+
await hook.invoke(core, 'a', 'b', 'c')
|
|
363
|
+
assert.deepEqual(receivedArgs, ['a', 'b', 'c'])
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('should wrap the core function with a single observer', async () => {
|
|
367
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
368
|
+
const order = []
|
|
369
|
+
hook.tap(async (next, arg) => {
|
|
370
|
+
order.push('before')
|
|
371
|
+
const result = await next(arg)
|
|
372
|
+
order.push('after')
|
|
373
|
+
return result
|
|
374
|
+
})
|
|
375
|
+
const core = async (arg) => { order.push('core'); return arg }
|
|
376
|
+
await hook.invoke(core, 'data')
|
|
377
|
+
assert.deepEqual(order, ['before', 'core', 'after'])
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('should return the core function result through the chain', async () => {
|
|
381
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
382
|
+
hook.tap(async (next, val) => next(val))
|
|
383
|
+
const core = async (val) => val * 2
|
|
384
|
+
const result = await hook.invoke(core, 5)
|
|
385
|
+
assert.equal(result, 10)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('should execute multiple observers in order', async () => {
|
|
389
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
390
|
+
const order = []
|
|
391
|
+
hook.tap(async (next, arg) => {
|
|
392
|
+
order.push('outer-before')
|
|
393
|
+
const result = await next(arg)
|
|
394
|
+
order.push('outer-after')
|
|
395
|
+
return result
|
|
396
|
+
})
|
|
397
|
+
hook.tap(async (next, arg) => {
|
|
398
|
+
order.push('inner-before')
|
|
399
|
+
const result = await next(arg)
|
|
400
|
+
order.push('inner-after')
|
|
401
|
+
return result
|
|
402
|
+
})
|
|
403
|
+
const core = async () => { order.push('core') }
|
|
404
|
+
await hook.invoke(core)
|
|
405
|
+
assert.deepEqual(order, ['outer-before', 'inner-before', 'core', 'inner-after', 'outer-after'])
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('should allow observers to modify arguments before core', async () => {
|
|
409
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
410
|
+
hook.tap(async (next, data) => next({ ...data, extra: true }))
|
|
411
|
+
let received
|
|
412
|
+
const core = async (data) => { received = data; return data }
|
|
413
|
+
await hook.invoke(core, { original: true })
|
|
414
|
+
assert.deepEqual(received, { original: true, extra: true })
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('should allow observers to modify the return value after core', async () => {
|
|
418
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
419
|
+
hook.tap(async (next, val) => {
|
|
420
|
+
const result = await next(val)
|
|
421
|
+
return result + ' modified'
|
|
422
|
+
})
|
|
423
|
+
const core = async (val) => val
|
|
424
|
+
const result = await hook.invoke(core, 'original')
|
|
425
|
+
assert.equal(result, 'original modified')
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('should allow an observer to short-circuit without calling next', async () => {
|
|
429
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
430
|
+
let coreCalled = false
|
|
431
|
+
hook.tap(async () => 'blocked')
|
|
432
|
+
const core = async () => { coreCalled = true; return 'core' }
|
|
433
|
+
const result = await hook.invoke(core)
|
|
434
|
+
assert.equal(coreCalled, false)
|
|
435
|
+
assert.equal(result, 'blocked')
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('should propagate errors from the core function', async () => {
|
|
439
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
440
|
+
hook.tap(async (next) => next())
|
|
441
|
+
const core = async () => { throw new Error('core error') }
|
|
442
|
+
await assert.rejects(hook.invoke(core), { message: 'core error' })
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('should propagate errors from observers', async () => {
|
|
446
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
447
|
+
hook.tap(async () => { throw new Error('observer error') })
|
|
448
|
+
const core = async () => 'ok'
|
|
449
|
+
await assert.rejects(hook.invoke(core), { message: 'observer error' })
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('should allow observer to catch and handle core errors', async () => {
|
|
453
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
454
|
+
hook.tap(async (next) => {
|
|
455
|
+
try {
|
|
456
|
+
return await next()
|
|
457
|
+
} catch {
|
|
458
|
+
return 'recovered'
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
const core = async () => { throw new Error('fail') }
|
|
462
|
+
const result = await hook.invoke(core)
|
|
463
|
+
assert.equal(result, 'recovered')
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('should support shared state between before and after phases', async () => {
|
|
467
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
468
|
+
hook.tap(async (next, data) => {
|
|
469
|
+
const snapshot = { ...data }
|
|
470
|
+
const result = await next(data)
|
|
471
|
+
return { result, snapshot }
|
|
472
|
+
})
|
|
473
|
+
const core = async (data) => { data.mutated = true; return data }
|
|
474
|
+
const output = await hook.invoke(core, { value: 1 })
|
|
475
|
+
assert.deepEqual(output.snapshot, { value: 1 })
|
|
476
|
+
assert.equal(output.result.mutated, true)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('should handle sync observers', async () => {
|
|
480
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
481
|
+
hook.tap((next, val) => next(val))
|
|
482
|
+
const core = (val) => val + 1
|
|
483
|
+
const result = await hook.invoke(core, 1)
|
|
484
|
+
assert.equal(result, 2)
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('should fall back to core result when observer calls next() without returning', async () => {
|
|
488
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
489
|
+
hook.tap(async (next, val) => {
|
|
490
|
+
await next(val) // calls next but doesn't return the result
|
|
491
|
+
})
|
|
492
|
+
const core = async (val) => val * 3
|
|
493
|
+
const result = await hook.invoke(core, 7)
|
|
494
|
+
assert.equal(result, 21)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('should fall back to core result through multiple non-returning observers', async () => {
|
|
498
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
499
|
+
hook.tap(async (next, val) => { await next(val) })
|
|
500
|
+
hook.tap(async (next, val) => { await next(val) })
|
|
501
|
+
const core = async (val) => ({ id: val })
|
|
502
|
+
const result = await hook.invoke(core, 42)
|
|
503
|
+
assert.deepEqual(result, { id: 42 })
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('should prefer explicit observer return over core result fallback', async () => {
|
|
507
|
+
const hook = new Hook({ type: Hook.Types.Middleware })
|
|
508
|
+
hook.tap(async (next, val) => {
|
|
509
|
+
await next(val)
|
|
510
|
+
return 'transformed'
|
|
511
|
+
})
|
|
512
|
+
const core = async (val) => val
|
|
513
|
+
const result = await hook.invoke(core, 'original')
|
|
514
|
+
assert.equal(result, 'transformed')
|
|
515
|
+
})
|
|
516
|
+
})
|
|
345
517
|
})
|
|
346
518
|
|
|
347
519
|
describe('#onInvoke()', () => {
|