@crombolo/api-management 0.15.0 → 0.17.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/package.json +3 -3
- package/src/handlers/document.ts +10 -1
- package/src/index.ts +1 -0
- package/src/notification-events.ts +103 -0
- package/src/ports-content.ts +9 -1
- package/src/router.ts +20 -0
- package/src/routes.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/api-management",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Umbraco-compatible Management API handlers for crombolo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/contracts": "0.
|
|
31
|
+
"@crombolo/core": "0.17.0",
|
|
32
|
+
"@crombolo/contracts": "0.17.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/handlers/document.ts
CHANGED
|
@@ -847,7 +847,16 @@ export function registerDocumentHandlers(
|
|
|
847
847
|
ctx.principal as NonNullable<typeof ctx.principal>,
|
|
848
848
|
ids,
|
|
849
849
|
)
|
|
850
|
-
if (
|
|
850
|
+
if (saved === 'notFound') return problemResponse(notFound('The document could not be found'))
|
|
851
|
+
if (saved === 'unavailable')
|
|
852
|
+
return problemResponse(
|
|
853
|
+
problemDetails({
|
|
854
|
+
title: 'Notifications are unavailable',
|
|
855
|
+
status: 400,
|
|
856
|
+
detail:
|
|
857
|
+
"This site can't send e-mail yet, so it can't tell you when pages change. Ask an administrator to set up e-mail.",
|
|
858
|
+
}),
|
|
859
|
+
)
|
|
851
860
|
return done(ctx, 'Notification settings saved')
|
|
852
861
|
})
|
|
853
862
|
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from './handlers/package.ts'
|
|
|
6
6
|
export * from './handlers/settings-stubs.ts'
|
|
7
7
|
export * from './handlers/template-query.ts'
|
|
8
8
|
export * from './handlers/user.ts'
|
|
9
|
+
export * from './notification-events.ts'
|
|
9
10
|
export * from './ports.ts'
|
|
10
11
|
export * from './ports-content.ts'
|
|
11
12
|
export * from './ports-diagnostics.ts'
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of Umbraco's eleven notification actions a Management API operation
|
|
3
|
+
* performs, and on which item (story 045). Pure: the router hands the server
|
|
4
|
+
* what it says, and the server finds who asked to hear of it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** What a successful operation did, for the people subscribed to its page. */
|
|
8
|
+
export interface RaisedContentChange {
|
|
9
|
+
/** `Umb.Document.Publish` and the rest; create-and-publish is two. */
|
|
10
|
+
actions: string[]
|
|
11
|
+
/** The item it names: a document's key, or for a rollback the version's. */
|
|
12
|
+
key: string
|
|
13
|
+
keyKind: 'document' | 'version'
|
|
14
|
+
/** A branch publish: one change for the page, "and the pages under it". */
|
|
15
|
+
withDescendants?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Mapping {
|
|
19
|
+
actions: string[]
|
|
20
|
+
from: 'created' | 'id' | 'body-parent'
|
|
21
|
+
/** A publish that only sets a schedule publishes nothing yet: the job notifies when it does. */
|
|
22
|
+
scheduleOnly?: boolean
|
|
23
|
+
keyKind?: 'document' | 'version'
|
|
24
|
+
withDescendants?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CREATE = 'Umb.Document.Create'
|
|
28
|
+
const UPDATE = 'Umb.Document.Update'
|
|
29
|
+
const PUBLISH = 'Umb.Document.Publish'
|
|
30
|
+
|
|
31
|
+
const BY_OPERATION: Record<string, Mapping> = {
|
|
32
|
+
PostDocument: { actions: [CREATE], from: 'created' },
|
|
33
|
+
PostDocumentCreateAndPublish: { actions: [CREATE, PUBLISH], from: 'created' },
|
|
34
|
+
PutDocumentById: { actions: [UPDATE], from: 'id' },
|
|
35
|
+
PutDocumentByIdUpdateAndPublish: { actions: [UPDATE, PUBLISH], from: 'id' },
|
|
36
|
+
PutDocumentByIdPublish: { actions: [PUBLISH], from: 'id', scheduleOnly: true },
|
|
37
|
+
PutDocumentByIdPublishWithDescendants: {
|
|
38
|
+
actions: [PUBLISH],
|
|
39
|
+
from: 'id',
|
|
40
|
+
withDescendants: true,
|
|
41
|
+
},
|
|
42
|
+
PutDocumentByIdMoveToRecycleBin: { actions: ['Umb.Document.Delete'], from: 'id' },
|
|
43
|
+
PutDocumentByIdMove: { actions: ['Umb.Document.Move'], from: 'id' },
|
|
44
|
+
// Notified on the original, as Umbraco does.
|
|
45
|
+
PostDocumentByIdCopy: { actions: ['Umb.Document.Duplicate'], from: 'id' },
|
|
46
|
+
// Notified on the parent whose children were sorted; sorting at the root notifies nobody.
|
|
47
|
+
PutDocumentByIdSortChildren: { actions: ['Umb.Document.Sort'], from: 'id' },
|
|
48
|
+
PutDocumentSort: { actions: ['Umb.Document.Sort'], from: 'body-parent' },
|
|
49
|
+
PostDocumentVersionByIdRollback: {
|
|
50
|
+
actions: ['Umb.Document.Rollback'],
|
|
51
|
+
from: 'id',
|
|
52
|
+
keyKind: 'version',
|
|
53
|
+
},
|
|
54
|
+
PutRecycleBinDocumentByIdRestore: { actions: ['Umb.DocumentRecycleBin.Restore'], from: 'id' },
|
|
55
|
+
PostDocumentByIdPublicAccess: { actions: ['Umb.Document.PublicAccess'], from: 'id' },
|
|
56
|
+
PutDocumentByIdPublicAccess: { actions: ['Umb.Document.PublicAccess'], from: 'id' },
|
|
57
|
+
DeleteDocumentByIdPublicAccess: { actions: ['Umb.Document.PublicAccess'], from: 'id' },
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether the router has to keep the request body for this operation. */
|
|
61
|
+
export const changeReadsBody = (operationId: string): boolean =>
|
|
62
|
+
BY_OPERATION[operationId]?.from === 'body-parent' ||
|
|
63
|
+
BY_OPERATION[operationId]?.scheduleOnly === true
|
|
64
|
+
|
|
65
|
+
/** Whether every culture in a publish request is scheduled for later rather than published now. */
|
|
66
|
+
const onlySchedules = (body: unknown): boolean => {
|
|
67
|
+
const schedules = (
|
|
68
|
+
body as { publishSchedules?: Array<{ schedule?: { publishTime?: unknown } | null }> }
|
|
69
|
+
)?.publishSchedules
|
|
70
|
+
return (
|
|
71
|
+
Array.isArray(schedules) &&
|
|
72
|
+
schedules.length > 0 &&
|
|
73
|
+
schedules.every((entry) => Boolean(entry?.schedule?.publishTime))
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The change a successful operation made, if it is one anybody can subscribe to. */
|
|
78
|
+
export function contentChangeFor(
|
|
79
|
+
operationId: string,
|
|
80
|
+
params: Readonly<Record<string, string>>,
|
|
81
|
+
response: Pick<Response, 'status' | 'headers'>,
|
|
82
|
+
body: unknown,
|
|
83
|
+
): RaisedContentChange | undefined {
|
|
84
|
+
if (response.status < 200 || response.status >= 300) return undefined
|
|
85
|
+
const mapping = BY_OPERATION[operationId]
|
|
86
|
+
if (!mapping) return undefined
|
|
87
|
+
if (mapping.scheduleOnly && onlySchedules(body)) return undefined
|
|
88
|
+
const key =
|
|
89
|
+
mapping.from === 'created'
|
|
90
|
+
? response.headers.get('umb-generated-resource')
|
|
91
|
+
: mapping.from === 'body-parent'
|
|
92
|
+
? ((body as { parent?: { id?: unknown } | null } | undefined)?.parent?.id as
|
|
93
|
+
| string
|
|
94
|
+
| undefined)
|
|
95
|
+
: params.id
|
|
96
|
+
if (!key) return undefined
|
|
97
|
+
return {
|
|
98
|
+
actions: mapping.actions,
|
|
99
|
+
key: key.toLowerCase(),
|
|
100
|
+
keyKind: mapping.keyKind ?? 'document',
|
|
101
|
+
...(mapping.withDescendants ? { withDescendants: true } : {}),
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/ports-content.ts
CHANGED
|
@@ -370,7 +370,15 @@ export interface DocumentPort extends TreePort {
|
|
|
370
370
|
key: string,
|
|
371
371
|
principal: Principal,
|
|
372
372
|
): Promise<Array<{ actionId: string; alias: string; subscribed: boolean }> | undefined>
|
|
373
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Replaces the user's subscriptions. `unavailable` refuses a new one on a
|
|
375
|
+
* site that cannot send e-mail; clearing them is always allowed.
|
|
376
|
+
*/
|
|
377
|
+
setNotifications(
|
|
378
|
+
key: string,
|
|
379
|
+
principal: Principal,
|
|
380
|
+
actionIds: string[],
|
|
381
|
+
): Promise<'saved' | 'notFound' | 'unavailable'>
|
|
374
382
|
/** The document as it stood at a version. */
|
|
375
383
|
atVersion(
|
|
376
384
|
versionId: string,
|
package/src/router.ts
CHANGED
|
@@ -25,6 +25,11 @@ import {
|
|
|
25
25
|
auditReadsBody,
|
|
26
26
|
} from './audit.ts'
|
|
27
27
|
import { authorize, LOCAL_LOGIN_OPERATIONS, type NodeLookup } from './authorization.ts'
|
|
28
|
+
import {
|
|
29
|
+
changeReadsBody,
|
|
30
|
+
contentChangeFor,
|
|
31
|
+
type RaisedContentChange,
|
|
32
|
+
} from './notification-events.ts'
|
|
28
33
|
import { type ServerEvent, serverEventFor } from './server-events.ts'
|
|
29
34
|
import { type RaisedWebhookEvent, webhookEventFor } from './webhook-events.ts'
|
|
30
35
|
|
|
@@ -94,6 +99,8 @@ export interface RouterOptions {
|
|
|
94
99
|
onError?: (error: unknown) => Response | undefined
|
|
95
100
|
/** Told about every webhook event a successful operation raises. */
|
|
96
101
|
onWebhookEvent?: (event: RaisedWebhookEvent) => void
|
|
102
|
+
/** A change to a page that people can subscribe to, and who made it (story 045). */
|
|
103
|
+
onContentChange?: (change: RaisedContentChange, principal: Principal | undefined) => void
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
export interface ClientAddress {
|
|
@@ -130,6 +137,7 @@ export class ManagementApiRouter {
|
|
|
130
137
|
readonly #clientAddress: (request: Request) => ClientAddress
|
|
131
138
|
readonly #onError: RouterOptions['onError']
|
|
132
139
|
readonly #onWebhookEvent: RouterOptions['onWebhookEvent']
|
|
140
|
+
readonly #onContentChange: RouterOptions['onContentChange']
|
|
133
141
|
|
|
134
142
|
constructor(options: RouterOptions = {}) {
|
|
135
143
|
const operations = options.operations ?? listOperations(loadSpec())
|
|
@@ -143,6 +151,7 @@ export class ManagementApiRouter {
|
|
|
143
151
|
this.#clientAddress = options.clientAddress ?? forwardedOnly
|
|
144
152
|
this.#onError = options.onError
|
|
145
153
|
this.#onWebhookEvent = options.onWebhookEvent
|
|
154
|
+
this.#onContentChange = options.onContentChange
|
|
146
155
|
for (const operation of operations) {
|
|
147
156
|
const segments = operation.path.split('/').filter((s) => s.length > 0)
|
|
148
157
|
const paramNames = segments.map((s) =>
|
|
@@ -242,6 +251,13 @@ export class ManagementApiRouter {
|
|
|
242
251
|
if (!handler) this.#onNotImplemented?.(operation, request)
|
|
243
252
|
const plan = handler && this.#audit ? auditPlanFor(operation.operationId) : undefined
|
|
244
253
|
const audit = plan ? await this.#beforeAudit(plan, params, request) : undefined
|
|
254
|
+
const changeBody =
|
|
255
|
+
handler && this.#onContentChange && changeReadsBody(operation.operationId)
|
|
256
|
+
? await request
|
|
257
|
+
.clone()
|
|
258
|
+
.json()
|
|
259
|
+
.catch(() => undefined)
|
|
260
|
+
: undefined
|
|
245
261
|
const response = handler
|
|
246
262
|
? await this.#run(handler, { request, url, params, operation, notifications, principal })
|
|
247
263
|
: problemResponse(notImplemented(operation.operationId))
|
|
@@ -255,6 +271,10 @@ export class ManagementApiRouter {
|
|
|
255
271
|
const event = webhookEventFor(operation.operationId, params, response)
|
|
256
272
|
if (event) this.#onWebhookEvent(event)
|
|
257
273
|
}
|
|
274
|
+
if (handler && this.#onContentChange) {
|
|
275
|
+
const change = contentChangeFor(operation.operationId, params, response, changeBody)
|
|
276
|
+
if (change) this.#onContentChange(change, principal)
|
|
277
|
+
}
|
|
258
278
|
const headers = new Headers(response.headers)
|
|
259
279
|
applyNotifications(headers, request.method, notifications)
|
|
260
280
|
headers.set('cache-control', 'no-store')
|
package/src/routes.ts
CHANGED
|
@@ -57,6 +57,7 @@ export interface ManagementApiOptions {
|
|
|
57
57
|
clientAddress?: RouterOptions['clientAddress']
|
|
58
58
|
onError?: RouterOptions['onError']
|
|
59
59
|
onWebhookEvent?: RouterOptions['onWebhookEvent']
|
|
60
|
+
onContentChange?: RouterOptions['onContentChange']
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export function createManagementApiRouter(options: ManagementApiOptions = {}): ManagementApiRouter {
|
|
@@ -70,6 +71,7 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
|
|
|
70
71
|
clientAddress: options.clientAddress,
|
|
71
72
|
onError: options.onError,
|
|
72
73
|
onWebhookEvent: options.onWebhookEvent,
|
|
74
|
+
onContentChange: options.onContentChange,
|
|
73
75
|
})
|
|
74
76
|
const { deps } = options
|
|
75
77
|
if (!deps) return router
|