@crombolo/api-management 0.11.0 → 0.13.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/package.json +3 -3
- package/src/divergences.ts +22 -0
- package/src/handlers/data-type.ts +25 -0
- package/src/handlers/diagnostics.ts +89 -0
- package/src/handlers/document.ts +78 -6
- package/src/handlers/dynamic-root.ts +63 -0
- package/src/handlers/reference-data.ts +35 -0
- package/src/handlers/relations.ts +60 -0
- package/src/handlers/settings-stubs.ts +1 -10
- package/src/handlers/static-files.ts +98 -0
- package/src/handlers/template.ts +2 -0
- package/src/handlers/webhook.ts +146 -0
- package/src/index.ts +4 -0
- package/src/ports-content.ts +52 -0
- package/src/ports-diagnostics.ts +17 -0
- package/src/ports-webhooks.ts +32 -0
- package/src/ports.ts +21 -0
- package/src/router.ts +25 -1
- package/src/routes.ts +31 -2
- package/src/webhook-events.ts +74 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/api-management",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
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.13.1",
|
|
32
|
+
"@crombolo/contracts": "0.13.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operations that answer 501 on purpose, with the reason; `coverage:api` lists
|
|
3
|
+
* them apart from what is still to build. docs/roadmap.md, "Deliberate divergence".
|
|
4
|
+
*/
|
|
5
|
+
const INSTALL =
|
|
6
|
+
'There is no browser install wizard: `crombolo init` and schema-as-code stand a site up.'
|
|
7
|
+
const UPGRADE =
|
|
8
|
+
'Upgrades run from the command line with `crombolo upgrade`, not from the backoffice.'
|
|
9
|
+
|
|
10
|
+
export const DELIBERATE_DIVERGENCES: Readonly<Record<string, string>> = {
|
|
11
|
+
GetInstallSettings: INSTALL,
|
|
12
|
+
PostInstallSetup: INSTALL,
|
|
13
|
+
PostInstallValidateDatabase: INSTALL,
|
|
14
|
+
GetUpgradeSettings: UPGRADE,
|
|
15
|
+
PostUpgradeAuthorize: UPGRADE,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Areas still to build, by the story that builds them. */
|
|
19
|
+
export const PLANNED_AREAS: Readonly<Record<string, string>> = {}
|
|
20
|
+
|
|
21
|
+
/** Operations outside those areas that a story builds. */
|
|
22
|
+
export const PLANNED_OPERATIONS: Readonly<Record<string, string>> = {}
|
|
@@ -205,6 +205,31 @@ export function registerDataTypeHandlers(router: ManagementApiRouter, port: Data
|
|
|
205
205
|
return Response.json({ total: result.total, items: result.items.map(toItem) })
|
|
206
206
|
})
|
|
207
207
|
|
|
208
|
+
const isUsed = port.isUsed
|
|
209
|
+
if (isUsed)
|
|
210
|
+
router.handle('GetDataTypeByIdIsUsed', async (ctx) => {
|
|
211
|
+
const used = await isUsed(ctx.params.id as string)
|
|
212
|
+
if (used === undefined)
|
|
213
|
+
return problemResponse(notFound('Data type not found', 'The data type could not be found.'))
|
|
214
|
+
return Response.json(used)
|
|
215
|
+
})
|
|
216
|
+
const propertyIsUsed = port.propertyIsUsed
|
|
217
|
+
if (propertyIsUsed)
|
|
218
|
+
router.handle('GetPropertyTypeIsUsed', async (ctx) => {
|
|
219
|
+
const contentTypeId = ctx.url.searchParams.get('contentTypeId')
|
|
220
|
+
const alias = ctx.url.searchParams.get('propertyAlias')
|
|
221
|
+
const used = contentTypeId && alias ? await propertyIsUsed(contentTypeId, alias) : undefined
|
|
222
|
+
if (used === undefined)
|
|
223
|
+
return problemResponse(
|
|
224
|
+
problemDetails({
|
|
225
|
+
title: 'Property type not found',
|
|
226
|
+
detail: 'There is no property with that alias on that content type.',
|
|
227
|
+
status: 400,
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
return Response.json(used)
|
|
231
|
+
})
|
|
232
|
+
|
|
208
233
|
router.handle('GetDataTypeByIdReferencedBy', async (ctx) => {
|
|
209
234
|
const page = paging(ctx)
|
|
210
235
|
if (!page) return problemResponse(invalidSkipTake())
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health checks, telemetry, profiling and the server's troubleshooting and
|
|
3
|
+
* upgrade-check answers. Telemetry and profiling are fixed: nothing is
|
|
4
|
+
* collected or sent, and there is no profiler. docs/diagnostics.md.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@crombolo/core'
|
|
7
|
+
import type { DiagnosticsPort } from '../ports-diagnostics.ts'
|
|
8
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
9
|
+
import { paging } from './content-type.ts'
|
|
10
|
+
import { readBody } from './document.ts'
|
|
11
|
+
|
|
12
|
+
const TELEMETRY_LEVEL = 'Minimal'
|
|
13
|
+
|
|
14
|
+
const badRequest = (title: string, detail: string) => problemDetails({ title, detail, status: 400 })
|
|
15
|
+
|
|
16
|
+
export function registerTelemetryAndProfilingHandlers(router: ManagementApiRouter): void {
|
|
17
|
+
router.handle('GetTelemetry', (ctx) => {
|
|
18
|
+
const page = paging(ctx)
|
|
19
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
20
|
+
const items = [{ telemetryLevel: TELEMETRY_LEVEL }]
|
|
21
|
+
return Response.json({
|
|
22
|
+
total: items.length,
|
|
23
|
+
items: items.slice(page.skip, page.skip + page.take),
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
router.handle('GetTelemetryLevel', () => Response.json({ telemetryLevel: TELEMETRY_LEVEL }))
|
|
27
|
+
router.handle('PostTelemetryLevel', async (ctx) => {
|
|
28
|
+
const { telemetryLevel } = await readBody(ctx)
|
|
29
|
+
if (telemetryLevel === TELEMETRY_LEVEL) return new Response(null, { status: 200 })
|
|
30
|
+
return problemResponse(
|
|
31
|
+
badRequest(
|
|
32
|
+
'Telemetry cannot be raised',
|
|
33
|
+
'This site collects no telemetry and sends nothing anywhere, so the level stays at Minimal.',
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
router.handle('GetProfilingStatus', () => Response.json({ enabled: false }))
|
|
38
|
+
router.handle('PutProfilingStatus', async (ctx) => {
|
|
39
|
+
const { enabled } = await readBody(ctx)
|
|
40
|
+
if (enabled === false) return new Response(null, { status: 200 })
|
|
41
|
+
return problemResponse(
|
|
42
|
+
badRequest('Profiling is not available', 'This site has no profiler to switch on.'),
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function registerDiagnosticsHandlers(
|
|
48
|
+
router: ManagementApiRouter,
|
|
49
|
+
diagnostics: DiagnosticsPort,
|
|
50
|
+
): void {
|
|
51
|
+
const groupNamed = (name: string) =>
|
|
52
|
+
diagnostics.groups().find((group) => group.name.toLowerCase() === name.toLowerCase())
|
|
53
|
+
const noGroup = () => problemResponse(notFound('The health check group could not be found'))
|
|
54
|
+
|
|
55
|
+
router.handle('GetHealthCheckGroup', (ctx) => {
|
|
56
|
+
const page = paging(ctx)
|
|
57
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
58
|
+
const groups = diagnostics.groups()
|
|
59
|
+
return Response.json({
|
|
60
|
+
total: groups.length,
|
|
61
|
+
items: groups.slice(page.skip, page.skip + page.take).map(({ name }) => ({ name })),
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
router.handle('GetHealthCheckGroupByName', (ctx) => {
|
|
65
|
+
const group = groupNamed(String(ctx.params.name))
|
|
66
|
+
return group ? Response.json(group) : noGroup()
|
|
67
|
+
})
|
|
68
|
+
router.handle('PostHealthCheckGroupByNameCheck', async (ctx) => {
|
|
69
|
+
const group = groupNamed(String(ctx.params.name))
|
|
70
|
+
const checks = group && (await diagnostics.run(group.name))
|
|
71
|
+
return checks ? Response.json({ checks }) : noGroup()
|
|
72
|
+
})
|
|
73
|
+
// No check here offers an action: each one reports, and the fix is a change
|
|
74
|
+
// to the site's configuration rather than something a button can do.
|
|
75
|
+
router.handle('PostHealthCheckExecuteAction', () =>
|
|
76
|
+
problemResponse(
|
|
77
|
+
badRequest(
|
|
78
|
+
'There is nothing to run',
|
|
79
|
+
'These health checks report only; none offers an action.',
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
router.handle('GetServerTroubleshooting', async () =>
|
|
84
|
+
Response.json(await diagnostics.troubleshooting()),
|
|
85
|
+
)
|
|
86
|
+
router.handle('GetServerUpgradeCheck', async () =>
|
|
87
|
+
Response.json(await diagnostics.upgradeCheck()),
|
|
88
|
+
)
|
|
89
|
+
}
|
package/src/handlers/document.ts
CHANGED
|
@@ -7,8 +7,10 @@ import type {
|
|
|
7
7
|
DocumentTreeItem,
|
|
8
8
|
DocumentValidationError,
|
|
9
9
|
DocumentValue,
|
|
10
|
+
PatchOperation,
|
|
10
11
|
} from '@crombolo/core'
|
|
11
12
|
import {
|
|
13
|
+
applyJsonPatch,
|
|
12
14
|
DocumentPermissions,
|
|
13
15
|
invalidSkipTake,
|
|
14
16
|
notFound,
|
|
@@ -18,7 +20,7 @@ import {
|
|
|
18
20
|
ROOT_ACCESS,
|
|
19
21
|
} from '@crombolo/core'
|
|
20
22
|
import type { PreviewPort } from '../ports.ts'
|
|
21
|
-
import type { DocumentPort, PortFailure, SaveDocument } from '../ports-content.ts'
|
|
23
|
+
import type { ApprovalGatePort, DocumentPort, PortFailure, SaveDocument } from '../ports-content.ts'
|
|
22
24
|
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
23
25
|
import { created, paging } from './content-type.ts'
|
|
24
26
|
import {
|
|
@@ -275,7 +277,14 @@ export function registerDocumentHandlers(
|
|
|
275
277
|
router: ManagementApiRouter,
|
|
276
278
|
port: DocumentPort,
|
|
277
279
|
preview?: PreviewPort,
|
|
280
|
+
approvals?: ApprovalGatePort,
|
|
278
281
|
): void {
|
|
282
|
+
/** A page a workflow covers goes through it: the gate's refusal, as a problem. */
|
|
283
|
+
const needsApproval = (title: string, detail: string) =>
|
|
284
|
+
problemResponse(
|
|
285
|
+
problemDetails({ title, status: 400, detail, operationStatus: 'NeedsApproval' }),
|
|
286
|
+
)
|
|
287
|
+
const principalOf = (ctx: RequestContext) => ctx.principal as NonNullable<typeof ctx.principal>
|
|
279
288
|
const trees: TreeSource = {
|
|
280
289
|
treeRoot: (paging) => port.treeRoot(paging),
|
|
281
290
|
treeChildren: (key, paging) => port.treeChildren(key, paging),
|
|
@@ -328,6 +337,12 @@ export function registerDocumentHandlers(
|
|
|
328
337
|
const body = await readBody(ctx)
|
|
329
338
|
const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
|
|
330
339
|
const input = toSaveDocument(key, body)
|
|
340
|
+
const refusal = await approvals?.newPageRefusal(
|
|
341
|
+
principalOf(ctx),
|
|
342
|
+
input.parentKey,
|
|
343
|
+
input.contentTypeKey,
|
|
344
|
+
)
|
|
345
|
+
if (refusal) return needsApproval('The document needs approval before it is published', refusal)
|
|
331
346
|
const result = await port.createAndPublish(
|
|
332
347
|
input,
|
|
333
348
|
readCultures(body),
|
|
@@ -344,6 +359,10 @@ export function registerDocumentHandlers(
|
|
|
344
359
|
const body = await readBody(ctx)
|
|
345
360
|
const key = ctx.params.id as string
|
|
346
361
|
const input = toSaveDocument(key, body)
|
|
362
|
+
const refusal =
|
|
363
|
+
(await approvals?.beforeSave(principalOf(ctx), key)) ??
|
|
364
|
+
(await approvals?.publishRefusal(principalOf(ctx), key, 'publish'))
|
|
365
|
+
if (refusal) return needsApproval('The document needs approval before it is published', refusal)
|
|
347
366
|
const result = await port.updateAndPublish(
|
|
348
367
|
key,
|
|
349
368
|
input,
|
|
@@ -471,11 +490,14 @@ export function registerDocumentHandlers(
|
|
|
471
490
|
return created(`${ctx.url.origin}/umbraco/management/api/v1/document/${result.key}`, result.key)
|
|
472
491
|
})
|
|
473
492
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
493
|
+
const saveUpdate = async (
|
|
494
|
+
ctx: RequestContext,
|
|
495
|
+
key: string,
|
|
496
|
+
existing: DocumentAggregate,
|
|
497
|
+
body: Record<string, unknown>,
|
|
498
|
+
) => {
|
|
499
|
+
const refusal = await approvals?.beforeSave(principalOf(ctx), key)
|
|
500
|
+
if (refusal) return needsApproval('The document is waiting for approval', refusal)
|
|
479
501
|
const result = await port.update(
|
|
480
502
|
key,
|
|
481
503
|
{
|
|
@@ -494,8 +516,51 @@ export function registerDocumentHandlers(
|
|
|
494
516
|
}),
|
|
495
517
|
)
|
|
496
518
|
}
|
|
519
|
+
await approvals?.afterSave(principalOf(ctx), key)
|
|
497
520
|
ctx.notifications.push({ message: 'Document saved', category: 'Content', type: 'Success' })
|
|
498
521
|
return new Response(null, { status: 200 })
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
router.handle('PutDocumentById', async (ctx) => {
|
|
525
|
+
const key = ctx.params.id as string
|
|
526
|
+
const existing = await port.byKey(key)
|
|
527
|
+
if (!existing) return problemResponse(notFound('The document could not be found'))
|
|
528
|
+
return saveUpdate(ctx, key, existing, await readBody(ctx))
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
// JSON Patch over the update model, Umbraco's filter segments included; the
|
|
532
|
+
// patched model then saves exactly as a PUT would.
|
|
533
|
+
router.handle('PatchDocumentByIdPatch', async (ctx) => {
|
|
534
|
+
const key = ctx.params.id as string
|
|
535
|
+
const existing = await port.byKey(key)
|
|
536
|
+
if (!existing) return problemResponse(notFound('The document could not be found'))
|
|
537
|
+
const invalid = (detail: string, status = 400) =>
|
|
538
|
+
problemResponse(problemDetails({ title: 'Could not patch the document', status, detail }))
|
|
539
|
+
const { operations } = await readBody(ctx)
|
|
540
|
+
if (!Array.isArray(operations) || operations.length === 0)
|
|
541
|
+
return invalid('A patch needs at least one operation.')
|
|
542
|
+
const current = {
|
|
543
|
+
template: existing.componentKey ? { id: existing.componentKey } : null,
|
|
544
|
+
values: existing.values.map(({ alias, culture, segment, value }) => ({
|
|
545
|
+
alias,
|
|
546
|
+
culture,
|
|
547
|
+
segment,
|
|
548
|
+
value,
|
|
549
|
+
})),
|
|
550
|
+
variants: existing.variants.map(({ culture, segment, name }) => ({ culture, segment, name })),
|
|
551
|
+
}
|
|
552
|
+
const patched = applyJsonPatch(current, operations as PatchOperation[])
|
|
553
|
+
if (!patched.ok) return invalid(patched.reason)
|
|
554
|
+
const body = patched.value as Record<string, unknown>
|
|
555
|
+
if (!Array.isArray(body.values) || !Array.isArray(body.variants))
|
|
556
|
+
return invalid('A patch may change values and variants, but not remove them.')
|
|
557
|
+
const aliases = new Set((await port.propertyAliases?.(key)) ?? [])
|
|
558
|
+
const unknown = (body.values as Array<{ alias?: unknown }>).find(
|
|
559
|
+
(value) => !aliases.has(String(value?.alias)),
|
|
560
|
+
)
|
|
561
|
+
if (unknown)
|
|
562
|
+
return invalid(`This document's type has no property "${String(unknown.alias)}".`, 422)
|
|
563
|
+
return saveUpdate(ctx, key, existing, body)
|
|
499
564
|
})
|
|
500
565
|
|
|
501
566
|
router.handle('PutDocumentByIdPublish', async (ctx) => {
|
|
@@ -503,6 +568,8 @@ export function registerDocumentHandlers(
|
|
|
503
568
|
if (!(await port.byKey(key)))
|
|
504
569
|
return problemResponse(notFound('The document could not be found'))
|
|
505
570
|
const body = await readBody(ctx)
|
|
571
|
+
const refusal = await approvals?.publishRefusal(principalOf(ctx), key, 'publish')
|
|
572
|
+
if (refusal) return needsApproval('The document needs approval before it is published', refusal)
|
|
506
573
|
const schedules = readSchedules(body)
|
|
507
574
|
if (schedules === 'invalid')
|
|
508
575
|
return problemResponse(
|
|
@@ -547,6 +614,8 @@ export function registerDocumentHandlers(
|
|
|
547
614
|
const key = ctx.params.id as string
|
|
548
615
|
if (!(await port.byKey(key)))
|
|
549
616
|
return problemResponse(notFound('The document could not be found'))
|
|
617
|
+
const refusal = await approvals?.publishRefusal(principalOf(ctx), key, 'unpublish')
|
|
618
|
+
if (refusal) return needsApproval('Taking the document down needs approval', refusal)
|
|
550
619
|
const result = await port.unpublish(key, readCultures(await readBody(ctx)))
|
|
551
620
|
if (!result.ok) {
|
|
552
621
|
return problemResponse(
|
|
@@ -665,6 +734,9 @@ export function registerDocumentHandlers(
|
|
|
665
734
|
const branchTasks = new Map<string, { documentKey: string }>()
|
|
666
735
|
router.handle('PutDocumentByIdPublishWithDescendants', async (ctx) => {
|
|
667
736
|
const key = ctx.params.id as string
|
|
737
|
+
const refusal = await approvals?.branchRefusal(principalOf(ctx), key)
|
|
738
|
+
if (refusal)
|
|
739
|
+
return needsApproval('Part of this branch needs approval before it is published', refusal)
|
|
668
740
|
const body = await readBody(ctx)
|
|
669
741
|
const cultures = Array.isArray(body.cultures) ? (body.cultures as unknown[]).map(String) : []
|
|
670
742
|
const result = await port.publishBranch(
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic roots: a content picker's start node, resolved from the document being
|
|
3
|
+
* edited by an origin and a list of steps.
|
|
4
|
+
*/
|
|
5
|
+
import { problemDetails, problemResponse } from '@crombolo/core'
|
|
6
|
+
import type { DynamicRootPort } from '../ports.ts'
|
|
7
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
8
|
+
import { readBody } from './document.ts'
|
|
9
|
+
|
|
10
|
+
export const DYNAMIC_ROOT_STEP_ALIASES = [
|
|
11
|
+
'NearestAncestorOrSelf',
|
|
12
|
+
'FurthestAncestorOrSelf',
|
|
13
|
+
'NearestDescendantOrSelf',
|
|
14
|
+
'FurthestDescendantOrSelf',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
const EMPTY_GUID = '00000000-0000-0000-0000-000000000000'
|
|
18
|
+
|
|
19
|
+
const keyOf = (value: unknown): string | null => {
|
|
20
|
+
const id = (value as { id?: unknown } | null | undefined)?.id ?? value
|
|
21
|
+
return typeof id === 'string' && id !== '' && id !== EMPTY_GUID ? id : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function registerDynamicRootHandlers(
|
|
25
|
+
router: ManagementApiRouter,
|
|
26
|
+
port: DynamicRootPort,
|
|
27
|
+
): void {
|
|
28
|
+
router.handle('GetDynamicRootSteps', () => Response.json(DYNAMIC_ROOT_STEP_ALIASES))
|
|
29
|
+
router.handle('PostDynamicRootQuery', async (ctx) => {
|
|
30
|
+
const body = await readBody(ctx)
|
|
31
|
+
const context = body.context as { id?: unknown; parent?: unknown } | undefined
|
|
32
|
+
const query = body.query as
|
|
33
|
+
| { origin?: { alias?: unknown; id?: unknown }; steps?: unknown }
|
|
34
|
+
| undefined
|
|
35
|
+
if (!context || typeof query?.origin?.alias !== 'string')
|
|
36
|
+
return problemResponse(
|
|
37
|
+
problemDetails({
|
|
38
|
+
title: 'Invalid dynamic root query',
|
|
39
|
+
detail: 'A query needs a context and an origin.',
|
|
40
|
+
status: 400,
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
const steps = Array.isArray(query.steps) ? query.steps : []
|
|
44
|
+
const roots = await port.resolve({
|
|
45
|
+
currentKey: keyOf(context.id),
|
|
46
|
+
parentKey: keyOf(context.parent),
|
|
47
|
+
origin: { alias: query.origin.alias, key: keyOf(query.origin.id) },
|
|
48
|
+
steps: steps.map((step) => {
|
|
49
|
+
const { alias, documentTypeIds } = (step ?? {}) as {
|
|
50
|
+
alias?: unknown
|
|
51
|
+
documentTypeIds?: unknown
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
alias: String(alias ?? ''),
|
|
55
|
+
documentTypeKeys: Array.isArray(documentTypeIds)
|
|
56
|
+
? documentTypeIds.filter((id): id is string => typeof id === 'string')
|
|
57
|
+
: [],
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
})
|
|
61
|
+
return Response.json({ roots })
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed reference answers: the node object types a relation type can join, and
|
|
3
|
+
* the help pages. Umbraco relays its help pages from our.umbraco.com; there is
|
|
4
|
+
* nothing to relay here, so the list is honestly empty.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidSkipTake, ObjectTypes, problemResponse } from '@crombolo/core'
|
|
7
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
8
|
+
import { paging } from './content-type.ts'
|
|
9
|
+
|
|
10
|
+
/** Internal markers rather than things a relation can point at. */
|
|
11
|
+
const HIDDEN = new Set<string>(['SystemRoot', 'LockObject'])
|
|
12
|
+
|
|
13
|
+
/** `DocumentTypeContainer` → `Document Type Container`. */
|
|
14
|
+
const spaced = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
15
|
+
|
|
16
|
+
export const OBJECT_TYPES = Object.entries(ObjectTypes)
|
|
17
|
+
.filter(([name]) => !HIDDEN.has(name))
|
|
18
|
+
.map(([name, id]) => ({ id, name: spaced(name) }))
|
|
19
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
20
|
+
|
|
21
|
+
export function registerReferenceDataHandlers(router: ManagementApiRouter): void {
|
|
22
|
+
router.handle('GetObjectTypes', (ctx) => {
|
|
23
|
+
const page = paging(ctx)
|
|
24
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
25
|
+
return Response.json({
|
|
26
|
+
total: OBJECT_TYPES.length,
|
|
27
|
+
items: OBJECT_TYPES.slice(page.skip, page.skip + page.take),
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
router.handle('GetHelp', (ctx) => {
|
|
31
|
+
const page = paging(ctx)
|
|
32
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
33
|
+
return Response.json({ total: 0, items: [] })
|
|
34
|
+
})
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation types and their relations, read-only and derived (story 046), and
|
|
3
|
+
* the recycle bins' "what refers to what is in here" lists.
|
|
4
|
+
*/
|
|
5
|
+
import { invalidSkipTake, notFound, paged, parseSkipTake, problemResponse } from '@crombolo/core'
|
|
6
|
+
import type { ReferencePort, RelationPort } from '../ports-content.ts'
|
|
7
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
8
|
+
|
|
9
|
+
const missing = () => problemResponse(notFound('The relation type could not be found'))
|
|
10
|
+
|
|
11
|
+
export function registerRelationHandlers(router: ManagementApiRouter, port: RelationPort): void {
|
|
12
|
+
router.handle('GetRelationType', (ctx) => {
|
|
13
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
14
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
15
|
+
const types = port.types()
|
|
16
|
+
return Response.json(
|
|
17
|
+
paged(types.slice(page.value.skip, page.value.skip + page.value.take), types.length),
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
router.handle('GetRelationTypeById', (ctx) => {
|
|
22
|
+
const id = String(ctx.params.id).toLowerCase()
|
|
23
|
+
const type = port.types().find((t) => t.id === id)
|
|
24
|
+
return type ? Response.json(type) : missing()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
router.handle('GetItemRelationType', (ctx) => {
|
|
28
|
+
const ids = new Set(ctx.url.searchParams.getAll('id').map((id) => id.toLowerCase()))
|
|
29
|
+
// Built in, so none of them can be deleted.
|
|
30
|
+
return Response.json(
|
|
31
|
+
port
|
|
32
|
+
.types()
|
|
33
|
+
.filter((t) => ids.has(t.id))
|
|
34
|
+
.map((t) => ({ id: t.id, name: t.name, isDeletable: false, flags: [] })),
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
router.handle('GetRelationByRelationTypeId', async (ctx) => {
|
|
39
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
40
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
41
|
+
const result = await port.relations(String(ctx.params.id), page.value)
|
|
42
|
+
return result ? Response.json(paged(result.items, result.total)) : missing()
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerRecycleBinReferenceHandlers(
|
|
47
|
+
router: ManagementApiRouter,
|
|
48
|
+
port: ReferencePort,
|
|
49
|
+
): void {
|
|
50
|
+
const ask = port.recycleBinReferencedBy?.bind(port)
|
|
51
|
+
if (!ask) return
|
|
52
|
+
for (const area of ['Document', 'Media', 'Element'] as const)
|
|
53
|
+
router.handle(`GetRecycleBin${area}ReferencedBy`, async (ctx) => {
|
|
54
|
+
// Umbraco pages these 20 at a time, as it does every references list.
|
|
55
|
+
const page = parseSkipTake(ctx.url.searchParams, 20)
|
|
56
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
57
|
+
const result = await ask(area, page.value)
|
|
58
|
+
return Response.json(paged(result.items, result.total))
|
|
59
|
+
})
|
|
60
|
+
}
|
|
@@ -23,18 +23,9 @@ const PAGED = [
|
|
|
23
23
|
`GetRecycleBin${b}Children`,
|
|
24
24
|
`GetRecycleBin${b}ReferencedBy`,
|
|
25
25
|
]),
|
|
26
|
-
'GetRecycleBinDocumentReferencedBy', // needs relations, story 046
|
|
27
|
-
'GetRecycleBinMediaReferencedBy',
|
|
28
|
-
'GetRecycleBinElementReferencedBy',
|
|
29
|
-
// The recycle bin and the trash and delete dialogs; needs relations, story 046. Until
|
|
30
|
-
// then they cannot warn that other content links to what is being removed.
|
|
31
|
-
'GetTreeStaticFileRoot',
|
|
32
|
-
'GetTreeStaticFileChildren',
|
|
33
|
-
'GetRelationType',
|
|
34
|
-
'GetWebhook',
|
|
35
26
|
]
|
|
36
27
|
/** Ancestor lookups: a bare array. */
|
|
37
|
-
const ANCESTORS =
|
|
28
|
+
const ANCESTORS = STUB_TREES.map((t) => `GetTree${t}Ancestors`)
|
|
38
29
|
/** Sibling windows: `{ totalBefore, totalAfter, items }`. */
|
|
39
30
|
const SIBLINGS = [
|
|
40
31
|
...STUB_TREES.map((t) => `GetTree${t}Siblings`),
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The static-file picker's tree: the stylesheets and scripts folders the site
|
|
3
|
+
* serves at `/css/` and `/scripts/`, so every path it offers is one a browser
|
|
4
|
+
* can load as it stands. Read-only; the files are edited in their own areas.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidSkipTake, paged, parseSkipTake, problemResponse } from '@crombolo/core'
|
|
7
|
+
import type { FileEntry, FileSystemPort } from '../ports-files.ts'
|
|
8
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
9
|
+
|
|
10
|
+
type Area = Pick<FileSystemPort, 'children' | 'entry'>
|
|
11
|
+
|
|
12
|
+
/** The two served folders, by the URL segment each is served under. */
|
|
13
|
+
export function createStaticFiles(mounts: Record<string, Area>): Area {
|
|
14
|
+
const split = (path: string): [Area, string, string] | undefined => {
|
|
15
|
+
const [, mount = '', ...rest] = path.replace(/\/+$/, '').split('/')
|
|
16
|
+
const area = mounts[mount]
|
|
17
|
+
return area ? [area, mount, `/${rest.join('/')}`] : undefined
|
|
18
|
+
}
|
|
19
|
+
const mounted = (mount: string, entry: FileEntry): FileEntry => ({
|
|
20
|
+
...entry,
|
|
21
|
+
path: `/${mount}${entry.path}`,
|
|
22
|
+
parentPath: entry.parentPath ? `/${mount}${entry.parentPath}` : `/${mount}`,
|
|
23
|
+
})
|
|
24
|
+
const folder = async (mount: string, area: Area): Promise<FileEntry | undefined> => {
|
|
25
|
+
const children = await area.children(null)
|
|
26
|
+
if (!children || children.length === 0) return undefined
|
|
27
|
+
return { name: mount, path: `/${mount}`, parentPath: null, isFolder: true, hasChildren: true }
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
async children(parentPath) {
|
|
31
|
+
if (!parentPath || parentPath === '/') {
|
|
32
|
+
const roots = await Promise.all(
|
|
33
|
+
Object.entries(mounts).map(([mount, area]) => folder(mount, area)),
|
|
34
|
+
)
|
|
35
|
+
return roots.filter((entry): entry is FileEntry => entry !== undefined)
|
|
36
|
+
}
|
|
37
|
+
const found = split(parentPath)
|
|
38
|
+
if (!found) return undefined
|
|
39
|
+
const [area, mount, inner] = found
|
|
40
|
+
const children = await area.children(inner === '/' ? null : inner)
|
|
41
|
+
return children?.map((entry) => mounted(mount, entry))
|
|
42
|
+
},
|
|
43
|
+
async entry(path) {
|
|
44
|
+
const found = split(path)
|
|
45
|
+
if (!found) return undefined
|
|
46
|
+
const [area, mount, inner] = found
|
|
47
|
+
if (inner === '/') return folder(mount, area)
|
|
48
|
+
const entry = await area.entry(inner)
|
|
49
|
+
return entry ? mounted(mount, entry) : undefined
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const treeItem = (entry: FileEntry) => ({
|
|
55
|
+
name: entry.name,
|
|
56
|
+
path: entry.path,
|
|
57
|
+
parent: entry.parentPath ? { path: entry.parentPath } : null,
|
|
58
|
+
isFolder: entry.isFolder,
|
|
59
|
+
hasChildren: entry.hasChildren,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
export function registerStaticFileHandlers(router: ManagementApiRouter, files: Area): void {
|
|
63
|
+
const page = (ctx: RequestContext, entries: readonly FileEntry[] | undefined) => {
|
|
64
|
+
const parsed = parseSkipTake(ctx.url.searchParams)
|
|
65
|
+
if (!parsed.ok) return problemResponse(invalidSkipTake())
|
|
66
|
+
const all = (entries ?? []).map(treeItem)
|
|
67
|
+
const { skip, take } = parsed.value
|
|
68
|
+
return Response.json(paged(all.slice(skip, skip + take), all.length))
|
|
69
|
+
}
|
|
70
|
+
router.handle('GetTreeStaticFileRoot', async (ctx) => page(ctx, await files.children(null)))
|
|
71
|
+
router.handle('GetTreeStaticFileChildren', async (ctx) =>
|
|
72
|
+
page(ctx, await files.children(ctx.url.searchParams.get('parentPath') || null)),
|
|
73
|
+
)
|
|
74
|
+
router.handle('GetTreeStaticFileAncestors', async (ctx) => {
|
|
75
|
+
const path = ctx.url.searchParams.get('descendantPath')
|
|
76
|
+
const chain: FileEntry[] = []
|
|
77
|
+
let current = path ? await files.entry(path) : undefined
|
|
78
|
+
while (current) {
|
|
79
|
+
chain.unshift(current)
|
|
80
|
+
current = current.parentPath ? await files.entry(current.parentPath) : undefined
|
|
81
|
+
}
|
|
82
|
+
return Response.json(chain.map(treeItem))
|
|
83
|
+
})
|
|
84
|
+
router.handle('GetItemStaticFile', async (ctx) => {
|
|
85
|
+
const items = []
|
|
86
|
+
for (const path of ctx.url.searchParams.getAll('path')) {
|
|
87
|
+
const entry = await files.entry(path)
|
|
88
|
+
if (entry)
|
|
89
|
+
items.push({
|
|
90
|
+
isFolder: entry.isFolder,
|
|
91
|
+
name: entry.name,
|
|
92
|
+
parent: entry.parentPath ? { path: entry.parentPath } : null,
|
|
93
|
+
path: entry.path,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return Response.json(items)
|
|
97
|
+
})
|
|
98
|
+
}
|
package/src/handlers/template.ts
CHANGED
|
@@ -22,6 +22,8 @@ function toResponse(model: ComponentModel): ResponseOf<'GetTemplateById'> {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function registerTemplateHandlers(router: ManagementApiRouter, port: TemplatePort): void {
|
|
25
|
+
// Templates are the site's components and are always on.
|
|
26
|
+
router.handle('GetTemplateConfiguration', () => Response.json({ disabled: false }))
|
|
25
27
|
router.handle('GetTemplateById', async ({ params }) => {
|
|
26
28
|
const model = await port.byKey(params.id as string)
|
|
27
29
|
if (!model) return problemResponse(notFound('The template could not be found'))
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The webhook operations: definitions, the events they may name, and the
|
|
3
|
+
* delivery log. docs/webhooks.md.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
invalidSkipTake,
|
|
7
|
+
notFound,
|
|
8
|
+
paged,
|
|
9
|
+
parseSkipTake,
|
|
10
|
+
problemDetails,
|
|
11
|
+
problemResponse,
|
|
12
|
+
} from '@crombolo/core'
|
|
13
|
+
import type {
|
|
14
|
+
WebhookInput,
|
|
15
|
+
WebhookPort,
|
|
16
|
+
WebhookPortResult,
|
|
17
|
+
WebhookPortView,
|
|
18
|
+
} from '../ports-webhooks.ts'
|
|
19
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
20
|
+
import { WEBHOOK_EVENTS } from '../webhook-events.ts'
|
|
21
|
+
import { created } from './content-type.ts'
|
|
22
|
+
|
|
23
|
+
const V1 = '/umbraco/management/api/v1'
|
|
24
|
+
|
|
25
|
+
const EVENTS = new Map(WEBHOOK_EVENTS.map((event) => [event.alias, event]))
|
|
26
|
+
|
|
27
|
+
/** Umbraco names each event in the response, not just by alias. */
|
|
28
|
+
function toResponse(webhook: WebhookPortView) {
|
|
29
|
+
return {
|
|
30
|
+
...webhook,
|
|
31
|
+
events: webhook.events.map(
|
|
32
|
+
(alias) => EVENTS.get(alias) ?? { alias, eventName: alias, eventType: 'Other' },
|
|
33
|
+
),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const strings = (value: unknown): string[] =>
|
|
38
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
|
39
|
+
|
|
40
|
+
/** The request body as a webhook; what is not there reads as empty, for the validator to refuse. */
|
|
41
|
+
async function inputOf(ctx: RequestContext): Promise<WebhookInput> {
|
|
42
|
+
const body = ((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
|
|
43
|
+
const text = (key: string) => (typeof body[key] === 'string' ? (body[key] as string) : null)
|
|
44
|
+
const headers: Record<string, string> = {}
|
|
45
|
+
if (body.headers && typeof body.headers === 'object' && !Array.isArray(body.headers))
|
|
46
|
+
for (const [name, value] of Object.entries(body.headers as Record<string, unknown>))
|
|
47
|
+
if (typeof value === 'string') headers[name] = value
|
|
48
|
+
return {
|
|
49
|
+
id: text('id') ?? undefined,
|
|
50
|
+
name: text('name'),
|
|
51
|
+
description: text('description'),
|
|
52
|
+
url: text('url') ?? '',
|
|
53
|
+
enabled: body.enabled !== false,
|
|
54
|
+
events: strings(body.events),
|
|
55
|
+
contentTypeKeys: strings(body.contentTypeKeys).map((key) => key.toLowerCase()),
|
|
56
|
+
headers,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function refused(result: Exclude<WebhookPortResult, { ok: true }>): Response {
|
|
61
|
+
if (result.status === 404) return problemResponse(notFound('The webhook could not be found'))
|
|
62
|
+
return problemResponse(
|
|
63
|
+
problemDetails({
|
|
64
|
+
title: result.reason,
|
|
65
|
+
status: result.status,
|
|
66
|
+
detail: result.problems?.join(' '),
|
|
67
|
+
...(result.problems ? { errors: { webhook: result.problems } } : {}),
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function registerWebhookHandlers(router: ManagementApiRouter, port: WebhookPort): void {
|
|
73
|
+
router.handle('GetWebhook', async (ctx) => {
|
|
74
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
75
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
76
|
+
const all = await port.list()
|
|
77
|
+
const { skip, take } = page.value
|
|
78
|
+
return Response.json(paged(all.slice(skip, skip + take).map(toResponse), all.length))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
router.handle('GetWebhookById', async (ctx) => {
|
|
82
|
+
const webhook = await port.get(ctx.params.id as string)
|
|
83
|
+
return webhook
|
|
84
|
+
? Response.json(toResponse(webhook))
|
|
85
|
+
: problemResponse(notFound('The webhook could not be found'))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
router.handle('GetItemWebhook', async (ctx) => {
|
|
89
|
+
const ids = new Set(ctx.url.searchParams.getAll('id').map((id) => id.toLowerCase()))
|
|
90
|
+
const all = await port.list()
|
|
91
|
+
return Response.json(
|
|
92
|
+
all
|
|
93
|
+
.filter((webhook) => ids.has(webhook.id))
|
|
94
|
+
.map((webhook) => ({
|
|
95
|
+
id: webhook.id,
|
|
96
|
+
name: webhook.name ?? webhook.url,
|
|
97
|
+
enabled: webhook.enabled,
|
|
98
|
+
url: webhook.url,
|
|
99
|
+
events: webhook.events.map((alias) => EVENTS.get(alias)?.eventName ?? alias).join(', '),
|
|
100
|
+
types: webhook.contentTypeKeys.join(', '),
|
|
101
|
+
flags: [],
|
|
102
|
+
})),
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
router.handle('PostWebhook', async (ctx) => {
|
|
107
|
+
const input = await inputOf(ctx)
|
|
108
|
+
const result = await port.create({ ...input, id: input.id ?? crypto.randomUUID() })
|
|
109
|
+
if (!result.ok) return refused(result)
|
|
110
|
+
return created(`${ctx.url.origin}${V1}/webhook/${result.id}`, result.id)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
router.handle('PutWebhookById', async (ctx) => {
|
|
114
|
+
const result = await port.update(ctx.params.id as string, await inputOf(ctx))
|
|
115
|
+
return result.ok ? new Response(null, { status: 200 }) : refused(result)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
router.handle('DeleteWebhookById', async (ctx) =>
|
|
119
|
+
(await port.remove(ctx.params.id as string))
|
|
120
|
+
? new Response(null, { status: 200 })
|
|
121
|
+
: problemResponse(notFound('The webhook could not be found')),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
router.handle('GetWebhookEvents', (ctx) => {
|
|
125
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
126
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
127
|
+
const { skip, take } = page.value
|
|
128
|
+
return Response.json(paged(WEBHOOK_EVENTS.slice(skip, skip + take), WEBHOOK_EVENTS.length))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
router.handle('GetWebhookLogs', async (ctx) => {
|
|
132
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
133
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
134
|
+
const result = await port.logs(undefined, page.value)
|
|
135
|
+
return Response.json(paged(result.items, result.total))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
router.handle('GetWebhookByIdLogs', async (ctx) => {
|
|
139
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
140
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
141
|
+
if (!(await port.get(ctx.params.id as string)))
|
|
142
|
+
return problemResponse(notFound('The webhook could not be found'))
|
|
143
|
+
const result = await port.logs(ctx.params.id as string, page.value)
|
|
144
|
+
return Response.json(paged(result.items, result.total))
|
|
145
|
+
})
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from './audit.ts'
|
|
2
2
|
export * from './authorization.ts'
|
|
3
|
+
export * from './divergences.ts'
|
|
3
4
|
export * from './handlers/content-type.ts'
|
|
4
5
|
export * from './handlers/package.ts'
|
|
5
6
|
export * from './handlers/settings-stubs.ts'
|
|
@@ -7,12 +8,15 @@ export * from './handlers/template-query.ts'
|
|
|
7
8
|
export * from './handlers/user.ts'
|
|
8
9
|
export * from './ports.ts'
|
|
9
10
|
export * from './ports-content.ts'
|
|
11
|
+
export * from './ports-diagnostics.ts'
|
|
10
12
|
export * from './ports-files.ts'
|
|
11
13
|
export * from './ports-logs.ts'
|
|
12
14
|
export * from './ports-members.ts'
|
|
13
15
|
export * from './ports-models-builder.ts'
|
|
14
16
|
export * from './ports-packages.ts'
|
|
15
17
|
export * from './ports-users.ts'
|
|
18
|
+
export * from './ports-webhooks.ts'
|
|
16
19
|
export * from './router.ts'
|
|
17
20
|
export * from './routes.ts'
|
|
18
21
|
export * from './server-events.ts'
|
|
22
|
+
export * from './webhook-events.ts'
|
package/src/ports-content.ts
CHANGED
|
@@ -231,6 +231,10 @@ export interface DataTypePort extends TreePort {
|
|
|
231
231
|
paging: SkipTake,
|
|
232
232
|
): Promise<Page<DataTypeModel>>
|
|
233
233
|
search(query: string, paging: SkipTake): Promise<Page<DataTypeModel>>
|
|
234
|
+
/** Whether any current value is saved through it; undefined when it does not exist. */
|
|
235
|
+
isUsed?(key: string): Promise<boolean | undefined>
|
|
236
|
+
/** Whether any current value is saved for a property; undefined when there is no such property. */
|
|
237
|
+
propertyIsUsed?(contentTypeKey: string, alias: string): Promise<boolean | undefined>
|
|
234
238
|
referencedBy(key: string): Promise<
|
|
235
239
|
Array<{
|
|
236
240
|
propertyKey: string
|
|
@@ -262,6 +266,8 @@ export interface TemplatePort extends TreePort {
|
|
|
262
266
|
|
|
263
267
|
export interface DocumentPort extends TreePort {
|
|
264
268
|
byKey(key: string): Promise<DocumentAggregate | undefined>
|
|
269
|
+
/** Every property alias its type has, compositions included. */
|
|
270
|
+
propertyAliases?(key: string): Promise<string[] | undefined>
|
|
265
271
|
/** The published version's values; undefined when nothing is published. */
|
|
266
272
|
byKeyPublished(key: string): Promise<DocumentAggregate | undefined>
|
|
267
273
|
/** What the editor asks before a save: the rules the server would enforce. */
|
|
@@ -498,11 +504,34 @@ export interface MediaPort {
|
|
|
498
504
|
|
|
499
505
|
/** What refers to a document, media item or member — the Info tab's list. */
|
|
500
506
|
export interface ReferencePort {
|
|
507
|
+
/** What refers to anything of this kind in the recycle bin, as the empty-bin dialog asks. */
|
|
508
|
+
recycleBinReferencedBy?(
|
|
509
|
+
area: 'Document' | 'Media' | 'Element',
|
|
510
|
+
paging: SkipTake,
|
|
511
|
+
): Promise<Page<unknown>>
|
|
501
512
|
referencedBy(key: string, paging: SkipTake): Promise<Page<unknown>>
|
|
502
513
|
referencedDescendants(key: string, paging: SkipTake): Promise<Page<{ id: string }>>
|
|
503
514
|
areReferenced(keys: readonly string[], paging: SkipTake): Promise<Page<{ id: string }>>
|
|
504
515
|
}
|
|
505
516
|
|
|
517
|
+
/** A relation type as the wire carries it: `RelationTypeResponseModel`. */
|
|
518
|
+
export interface RelationTypeView {
|
|
519
|
+
id: string
|
|
520
|
+
alias: string
|
|
521
|
+
name: string
|
|
522
|
+
parentObject: { id: string; name: string } | null
|
|
523
|
+
childObject: { id: string; name: string } | null
|
|
524
|
+
isBidirectional: boolean
|
|
525
|
+
isDependency: boolean
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Umbraco's relation types, read-only, with relations derived for each (story 046). */
|
|
529
|
+
export interface RelationPort {
|
|
530
|
+
types(): readonly RelationTypeView[]
|
|
531
|
+
/** Undefined when there is no such type. */
|
|
532
|
+
relations(typeKey: string, paging: SkipTake): Promise<Page<unknown> | undefined>
|
|
533
|
+
}
|
|
534
|
+
|
|
506
535
|
/** One redirect as the dashboard and the Info tab read it. */
|
|
507
536
|
export interface RedirectListing {
|
|
508
537
|
key: string
|
|
@@ -562,3 +591,26 @@ export interface TemplateQueryPort {
|
|
|
562
591
|
documentTypeAliases(): Promise<string[]>
|
|
563
592
|
execute(request: TemplateQueryRequest): Promise<{ items: TemplateQueryItem[]; total: number }>
|
|
564
593
|
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Approval before publishing (story 028): asked before a publish, an unpublish
|
|
597
|
+
* or a save, so a page a workflow covers goes through it. Each answer is
|
|
598
|
+
* undefined to go ahead, or what to tell the person.
|
|
599
|
+
*/
|
|
600
|
+
export interface ApprovalGatePort {
|
|
601
|
+
publishRefusal(
|
|
602
|
+
principal: Principal,
|
|
603
|
+
key: string,
|
|
604
|
+
action: 'publish' | 'unpublish',
|
|
605
|
+
): Promise<string | undefined>
|
|
606
|
+
/** A branch publish: the root and every page below it. */
|
|
607
|
+
branchRefusal(principal: Principal, key: string): Promise<string | undefined>
|
|
608
|
+
/** A page created and published in one go, by where it will sit and its type. */
|
|
609
|
+
newPageRefusal(
|
|
610
|
+
principal: Principal,
|
|
611
|
+
parentKey: string | null,
|
|
612
|
+
contentTypeKey: string,
|
|
613
|
+
): Promise<string | undefined>
|
|
614
|
+
beforeSave(principal: Principal, key: string): Promise<string | undefined>
|
|
615
|
+
afterSave(principal: Principal, key: string): Promise<void>
|
|
616
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResponseOf, Schemas } from '@crombolo/contracts'
|
|
2
|
+
|
|
3
|
+
export type HealthCheckResult = Schemas['HealthCheckResultResponseModel']
|
|
4
|
+
|
|
5
|
+
export interface HealthCheckGroup {
|
|
6
|
+
name: string
|
|
7
|
+
checks: Schemas['HealthCheckModel'][]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The health checks, troubleshooting report and upgrade check. docs/diagnostics.md. */
|
|
11
|
+
export interface DiagnosticsPort {
|
|
12
|
+
groups(): HealthCheckGroup[]
|
|
13
|
+
/** Runs every check in the group; undefined when there is no such group. */
|
|
14
|
+
run(group: string): Promise<Schemas['HealthCheckWithResultPresentationModel'][] | undefined>
|
|
15
|
+
troubleshooting(): Promise<ResponseOf<'GetServerTroubleshooting'>>
|
|
16
|
+
upgradeCheck(): Promise<ResponseOf<'GetServerUpgradeCheck'>>
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Webhooks, as the Management API's eight operations and the item lookup need them. */
|
|
2
|
+
import type { Page, SkipTake } from '@crombolo/core'
|
|
3
|
+
|
|
4
|
+
/** One webhook as the backoffice edits it; a typed-in secret comes back masked. */
|
|
5
|
+
export interface WebhookInput {
|
|
6
|
+
id?: string
|
|
7
|
+
name: string | null
|
|
8
|
+
description: string | null
|
|
9
|
+
url: string
|
|
10
|
+
enabled: boolean
|
|
11
|
+
events: string[]
|
|
12
|
+
contentTypeKeys: string[]
|
|
13
|
+
headers: Record<string, string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WebhookPortView extends WebhookInput {
|
|
17
|
+
id: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type WebhookPortResult =
|
|
21
|
+
| { ok: true; id: string }
|
|
22
|
+
| { ok: false; status: 400 | 404 | 409; reason: string; problems?: string[] }
|
|
23
|
+
|
|
24
|
+
export interface WebhookPort {
|
|
25
|
+
list(): Promise<WebhookPortView[]>
|
|
26
|
+
get(id: string): Promise<WebhookPortView | undefined>
|
|
27
|
+
create(input: WebhookInput): Promise<WebhookPortResult>
|
|
28
|
+
update(id: string, input: WebhookInput): Promise<WebhookPortResult>
|
|
29
|
+
remove(id: string): Promise<boolean>
|
|
30
|
+
/** Already in the wire's `WebhookLogResponseModel` shape; one webhook's when `id` is given. */
|
|
31
|
+
logs(id: string | undefined, paging: SkipTake): Promise<Page<unknown>>
|
|
32
|
+
}
|
package/src/ports.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { ResponseOf } from '@crombolo/contracts'
|
|
11
11
|
import type { UploadSettings } from '@crombolo/core'
|
|
12
12
|
import type {
|
|
13
|
+
ApprovalGatePort,
|
|
13
14
|
BlueprintPort,
|
|
14
15
|
ContentTypePort,
|
|
15
16
|
DataTypePort,
|
|
@@ -18,15 +19,18 @@ import type {
|
|
|
18
19
|
MediaPort,
|
|
19
20
|
RedirectPort,
|
|
20
21
|
ReferencePort,
|
|
22
|
+
RelationPort,
|
|
21
23
|
TemplatePort,
|
|
22
24
|
TemplateQueryPort,
|
|
23
25
|
} from './ports-content.ts'
|
|
26
|
+
import type { DiagnosticsPort } from './ports-diagnostics.ts'
|
|
24
27
|
import type { FileSystemPort, Snippet } from './ports-files.ts'
|
|
25
28
|
import type { LogViewerPort } from './ports-logs.ts'
|
|
26
29
|
import type { MemberGroupPort, MemberPort, PublicAccessPort } from './ports-members.ts'
|
|
27
30
|
import type { ModelsBuilderPort } from './ports-models-builder.ts'
|
|
28
31
|
import type { PackagePort } from './ports-packages.ts'
|
|
29
32
|
import type { UserDataPort, UserGroupPort, UserPort } from './ports-users.ts'
|
|
33
|
+
import type { WebhookPort } from './ports-webhooks.ts'
|
|
30
34
|
import type { Principal } from './router.ts'
|
|
31
35
|
|
|
32
36
|
export interface ServerPort {
|
|
@@ -170,6 +174,16 @@ export interface OEmbedPort {
|
|
|
170
174
|
>
|
|
171
175
|
}
|
|
172
176
|
|
|
177
|
+
/** A content picker's start node, found from the document being edited. */
|
|
178
|
+
export interface DynamicRootPort {
|
|
179
|
+
resolve(query: {
|
|
180
|
+
currentKey: string | null
|
|
181
|
+
parentKey: string | null
|
|
182
|
+
origin: { alias: string; key: string | null }
|
|
183
|
+
steps: Array<{ alias: string; documentTypeKeys: string[] }>
|
|
184
|
+
}): Promise<string[]>
|
|
185
|
+
}
|
|
186
|
+
|
|
173
187
|
/** Preview mode is a cookie; the server owns its name and attributes. */
|
|
174
188
|
export interface PreviewPort {
|
|
175
189
|
/** `Set-Cookie` values that enter preview for the signed-in editor. */
|
|
@@ -233,11 +247,14 @@ export interface AuthPort {
|
|
|
233
247
|
|
|
234
248
|
export interface ManagementApiDeps {
|
|
235
249
|
server: ServerPort
|
|
250
|
+
/** Health checks, troubleshooting and the upgrade check; needs the database. */
|
|
251
|
+
diagnostics?: DiagnosticsPort
|
|
236
252
|
manifests: ManifestPort
|
|
237
253
|
localization: LocalizationPort
|
|
238
254
|
currentUser: CurrentUserPort
|
|
239
255
|
users?: UserItemPort
|
|
240
256
|
preview?: PreviewPort
|
|
257
|
+
dynamicRoots?: DynamicRootPort
|
|
241
258
|
temporaryFiles?: TemporaryFilePort
|
|
242
259
|
media?: MediaPort
|
|
243
260
|
tags?: TagPort
|
|
@@ -249,6 +266,8 @@ export interface ManagementApiDeps {
|
|
|
249
266
|
dataTypes?: DataTypePort
|
|
250
267
|
templates?: TemplatePort
|
|
251
268
|
documents?: DocumentPort
|
|
269
|
+
/** Approval workflows: the gate on publishing and saving a page a workflow covers. */
|
|
270
|
+
approvals?: ApprovalGatePort
|
|
252
271
|
dictionary?: DictionaryPort
|
|
253
272
|
userManagement?: UserPort
|
|
254
273
|
userGroups?: UserGroupPort
|
|
@@ -270,6 +289,8 @@ export interface ManagementApiDeps {
|
|
|
270
289
|
publicAccess?: PublicAccessPort
|
|
271
290
|
elements?: ElementPort
|
|
272
291
|
references?: ReferencePort
|
|
292
|
+
relations?: RelationPort
|
|
293
|
+
webhooks?: WebhookPort
|
|
273
294
|
redirects?: RedirectPort
|
|
274
295
|
packages?: PackagePort
|
|
275
296
|
}
|
package/src/router.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* so a path that is not in the contract cannot be served, and an operation in
|
|
4
4
|
* the contract without a handler answers 501 rather than 404.
|
|
5
5
|
*/
|
|
6
|
+
|
|
6
7
|
import { type HttpMethod, listOperations, loadSpec, type OperationInfo } from '@crombolo/contracts'
|
|
7
8
|
import {
|
|
8
9
|
applyNotifications,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
} from './audit.ts'
|
|
26
27
|
import { authorize, LOCAL_LOGIN_OPERATIONS, type NodeLookup } from './authorization.ts'
|
|
27
28
|
import { type ServerEvent, serverEventFor } from './server-events.ts'
|
|
29
|
+
import { type RaisedWebhookEvent, webhookEventFor } from './webhook-events.ts'
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Resolved back-office identity, assembled from a reference token plus the
|
|
@@ -88,6 +90,10 @@ export interface RouterOptions {
|
|
|
88
90
|
audit?: AuditSink
|
|
89
91
|
/** Where a request came from, for its audit entry. */
|
|
90
92
|
clientAddress?: (request: Request) => ClientAddress
|
|
93
|
+
/** Answers an error a handler threw; undefined lets it propagate. */
|
|
94
|
+
onError?: (error: unknown) => Response | undefined
|
|
95
|
+
/** Told about every webhook event a successful operation raises. */
|
|
96
|
+
onWebhookEvent?: (event: RaisedWebhookEvent) => void
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
export interface ClientAddress {
|
|
@@ -122,6 +128,8 @@ export class ManagementApiRouter {
|
|
|
122
128
|
readonly #allowLocalLogin: boolean
|
|
123
129
|
readonly #audit: AuditSink | undefined
|
|
124
130
|
readonly #clientAddress: (request: Request) => ClientAddress
|
|
131
|
+
readonly #onError: RouterOptions['onError']
|
|
132
|
+
readonly #onWebhookEvent: RouterOptions['onWebhookEvent']
|
|
125
133
|
|
|
126
134
|
constructor(options: RouterOptions = {}) {
|
|
127
135
|
const operations = options.operations ?? listOperations(loadSpec())
|
|
@@ -133,6 +141,8 @@ export class ManagementApiRouter {
|
|
|
133
141
|
this.#allowLocalLogin = options.allowLocalLogin ?? true
|
|
134
142
|
this.#audit = options.audit
|
|
135
143
|
this.#clientAddress = options.clientAddress ?? forwardedOnly
|
|
144
|
+
this.#onError = options.onError
|
|
145
|
+
this.#onWebhookEvent = options.onWebhookEvent
|
|
136
146
|
for (const operation of operations) {
|
|
137
147
|
const segments = operation.path.split('/').filter((s) => s.length > 0)
|
|
138
148
|
const paramNames = segments.map((s) =>
|
|
@@ -233,7 +243,7 @@ export class ManagementApiRouter {
|
|
|
233
243
|
const plan = handler && this.#audit ? auditPlanFor(operation.operationId) : undefined
|
|
234
244
|
const audit = plan ? await this.#beforeAudit(plan, params, request) : undefined
|
|
235
245
|
const response = handler
|
|
236
|
-
? await handler
|
|
246
|
+
? await this.#run(handler, { request, url, params, operation, notifications, principal })
|
|
237
247
|
: problemResponse(notImplemented(operation.operationId))
|
|
238
248
|
if (plan && audit && response.status >= 200 && response.status < 300)
|
|
239
249
|
await this.#recordAudit(plan, audit, params, request, response, principal)
|
|
@@ -241,12 +251,26 @@ export class ManagementApiRouter {
|
|
|
241
251
|
const event = serverEventFor(operation, params, response)
|
|
242
252
|
if (event) this.#onServerEvent(event)
|
|
243
253
|
}
|
|
254
|
+
if (handler && this.#onWebhookEvent) {
|
|
255
|
+
const event = webhookEventFor(operation.operationId, params, response)
|
|
256
|
+
if (event) this.#onWebhookEvent(event)
|
|
257
|
+
}
|
|
244
258
|
const headers = new Headers(response.headers)
|
|
245
259
|
applyNotifications(headers, request.method, notifications)
|
|
246
260
|
headers.set('cache-control', 'no-store')
|
|
247
261
|
return new Response(response.body, { status: response.status, headers })
|
|
248
262
|
}
|
|
249
263
|
|
|
264
|
+
async #run(handler: OperationHandler, ctx: RequestContext): Promise<Response> {
|
|
265
|
+
try {
|
|
266
|
+
return await handler(ctx)
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const answered = this.#onError?.(error)
|
|
269
|
+
if (answered) return answered
|
|
270
|
+
throw error
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
250
274
|
/** Reads what the entry needs before the handler consumes the body or removes the item. */
|
|
251
275
|
async #beforeAudit(
|
|
252
276
|
plan: AuditPlan,
|
package/src/routes.ts
CHANGED
|
@@ -5,9 +5,14 @@
|
|
|
5
5
|
import { registerAuditLogHandlers } from './handlers/audit-log.ts'
|
|
6
6
|
import { registerContentTypeHandlers } from './handlers/content-type.ts'
|
|
7
7
|
import { registerDataTypeHandlers } from './handlers/data-type.ts'
|
|
8
|
+
import {
|
|
9
|
+
registerDiagnosticsHandlers,
|
|
10
|
+
registerTelemetryAndProfilingHandlers,
|
|
11
|
+
} from './handlers/diagnostics.ts'
|
|
8
12
|
import { registerDictionaryHandlers } from './handlers/dictionary.ts'
|
|
9
13
|
import { registerDocumentHandlers } from './handlers/document.ts'
|
|
10
14
|
import { registerDocumentBlueprintHandlers } from './handlers/document-blueprint.ts'
|
|
15
|
+
import { registerDynamicRootHandlers } from './handlers/dynamic-root.ts'
|
|
11
16
|
import { registerEditorServiceHandlers } from './handlers/editor-services.ts'
|
|
12
17
|
import { registerElementHandlers } from './handlers/element.ts'
|
|
13
18
|
import { registerFileSystemHandlers } from './handlers/file-systems.ts'
|
|
@@ -23,14 +28,21 @@ import { registerPackageHandlers } from './handlers/package.ts'
|
|
|
23
28
|
import { registerPublicAccessHandlers } from './handlers/public-access.ts'
|
|
24
29
|
import { registerPublishedCacheHandlers } from './handlers/published-cache.ts'
|
|
25
30
|
import { registerRedirectHandlers } from './handlers/redirects.ts'
|
|
31
|
+
import { registerReferenceDataHandlers } from './handlers/reference-data.ts'
|
|
26
32
|
import { registerReferenceHandlers } from './handlers/references.ts'
|
|
33
|
+
import {
|
|
34
|
+
registerRecycleBinReferenceHandlers,
|
|
35
|
+
registerRelationHandlers,
|
|
36
|
+
} from './handlers/relations.ts'
|
|
27
37
|
import { registerSearchIndexHandlers } from './handlers/search-index.ts'
|
|
28
38
|
import { registerServerHandlers } from './handlers/server.ts'
|
|
29
39
|
import { registerSettingsStubHandlers } from './handlers/settings-stubs.ts'
|
|
40
|
+
import { createStaticFiles, registerStaticFileHandlers } from './handlers/static-files.ts'
|
|
30
41
|
import { registerTemplateHandlers } from './handlers/template.ts'
|
|
31
42
|
import { registerTemplateQueryHandlers } from './handlers/template-query.ts'
|
|
32
43
|
import { registerTemporaryFileHandlers } from './handlers/temporary-file.ts'
|
|
33
44
|
import { registerUserHandlers } from './handlers/user.ts'
|
|
45
|
+
import { registerWebhookHandlers } from './handlers/webhook.ts'
|
|
34
46
|
import type { ManagementApiDeps } from './ports.ts'
|
|
35
47
|
import { type Authenticator, ManagementApiRouter, type RouterOptions } from './router.ts'
|
|
36
48
|
|
|
@@ -43,6 +55,8 @@ export interface ManagementApiOptions {
|
|
|
43
55
|
allowLocalLogin?: boolean
|
|
44
56
|
audit?: RouterOptions['audit']
|
|
45
57
|
clientAddress?: RouterOptions['clientAddress']
|
|
58
|
+
onError?: RouterOptions['onError']
|
|
59
|
+
onWebhookEvent?: RouterOptions['onWebhookEvent']
|
|
46
60
|
}
|
|
47
61
|
|
|
48
62
|
export function createManagementApiRouter(options: ManagementApiOptions = {}): ManagementApiRouter {
|
|
@@ -54,6 +68,8 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
|
|
|
54
68
|
allowLocalLogin: options.allowLocalLogin,
|
|
55
69
|
audit: options.audit,
|
|
56
70
|
clientAddress: options.clientAddress,
|
|
71
|
+
onError: options.onError,
|
|
72
|
+
onWebhookEvent: options.onWebhookEvent,
|
|
57
73
|
})
|
|
58
74
|
const { deps } = options
|
|
59
75
|
if (!deps) return router
|
|
@@ -67,6 +83,10 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
|
|
|
67
83
|
userData: deps.userData,
|
|
68
84
|
})
|
|
69
85
|
registerSettingsStubHandlers(router)
|
|
86
|
+
registerTelemetryAndProfilingHandlers(router)
|
|
87
|
+
registerReferenceDataHandlers(router)
|
|
88
|
+
if (deps.dynamicRoots) registerDynamicRootHandlers(router, deps.dynamicRoots)
|
|
89
|
+
if (deps.diagnostics) registerDiagnosticsHandlers(router, deps.diagnostics)
|
|
70
90
|
if (deps.contentTypes)
|
|
71
91
|
registerContentTypeHandlers(router, deps.contentTypes, 'document', deps.temporaryFiles)
|
|
72
92
|
if (deps.mediaTypes)
|
|
@@ -76,7 +96,7 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
|
|
|
76
96
|
if (deps.dataTypes) registerDataTypeHandlers(router, deps.dataTypes)
|
|
77
97
|
if (deps.templates) registerTemplateHandlers(router, deps.templates)
|
|
78
98
|
if (deps.templateQuery) registerTemplateQueryHandlers(router, deps.templateQuery)
|
|
79
|
-
if (deps.documents) registerDocumentHandlers(router, deps.documents, deps.preview)
|
|
99
|
+
if (deps.documents) registerDocumentHandlers(router, deps.documents, deps.preview, deps.approvals)
|
|
80
100
|
if (deps.blueprints) registerDocumentBlueprintHandlers(router, deps.blueprints)
|
|
81
101
|
if (deps.temporaryFiles) registerTemporaryFileHandlers(router, deps.temporaryFiles)
|
|
82
102
|
if (deps.media) registerMediaHandlers(router, deps.media)
|
|
@@ -87,6 +107,11 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
|
|
|
87
107
|
registerFileSystemHandlers(router, 'PartialView', deps.components, deps.componentSnippets)
|
|
88
108
|
if (deps.stylesheets) registerFileSystemHandlers(router, 'Stylesheet', deps.stylesheets)
|
|
89
109
|
if (deps.scripts) registerFileSystemHandlers(router, 'Script', deps.scripts)
|
|
110
|
+
if (deps.stylesheets && deps.scripts)
|
|
111
|
+
registerStaticFileHandlers(
|
|
112
|
+
router,
|
|
113
|
+
createStaticFiles({ css: deps.stylesheets, scripts: deps.scripts }),
|
|
114
|
+
)
|
|
90
115
|
if (deps.logViewer) registerLogViewerHandlers(router, deps.logViewer)
|
|
91
116
|
if (deps.modelsBuilder) registerModelsBuilderHandlers(router, deps.modelsBuilder)
|
|
92
117
|
if (deps.publishedCache) registerPublishedCacheHandlers(router, deps.publishedCache)
|
|
@@ -108,9 +133,13 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
|
|
|
108
133
|
)
|
|
109
134
|
if (deps.redirects) registerRedirectHandlers(router, deps.redirects)
|
|
110
135
|
if (deps.packages) registerPackageHandlers(router, deps.packages)
|
|
111
|
-
if (deps.references)
|
|
136
|
+
if (deps.references) {
|
|
112
137
|
for (const area of ['Document', 'Media', 'Member', 'Element'] as const)
|
|
113
138
|
registerReferenceHandlers(router, area, deps.references)
|
|
139
|
+
registerRecycleBinReferenceHandlers(router, deps.references)
|
|
140
|
+
}
|
|
141
|
+
if (deps.relations) registerRelationHandlers(router, deps.relations)
|
|
142
|
+
if (deps.webhooks) registerWebhookHandlers(router, deps.webhooks)
|
|
114
143
|
registerEditorServiceHandlers(router, { tags: deps.tags, oembed: deps.oembed })
|
|
115
144
|
return router
|
|
116
145
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The webhook events this site raises, and which Management API operation
|
|
3
|
+
* raises each. Aliases are Umbraco's, so the backoffice's event picker and an
|
|
4
|
+
* imported site's choices mean the same thing. docs/webhooks.md.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type WebhookEventType = 'Content' | 'Media'
|
|
8
|
+
|
|
9
|
+
export interface WebhookEventInfo {
|
|
10
|
+
alias: string
|
|
11
|
+
eventName: string
|
|
12
|
+
eventType: WebhookEventType
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const WEBHOOK_EVENTS: readonly WebhookEventInfo[] = [
|
|
16
|
+
{ alias: 'Umbraco.ContentSaved', eventName: 'Content Saved', eventType: 'Content' },
|
|
17
|
+
{ alias: 'Umbraco.ContentPublish', eventName: 'Content Published', eventType: 'Content' },
|
|
18
|
+
{ alias: 'Umbraco.ContentUnpublish', eventName: 'Content Unpublished', eventType: 'Content' },
|
|
19
|
+
{ alias: 'Umbraco.ContentDelete', eventName: 'Content Deleted', eventType: 'Content' },
|
|
20
|
+
{
|
|
21
|
+
alias: 'Umbraco.ContentMovedToRecycleBin',
|
|
22
|
+
eventName: 'Content Moved to Recycle Bin',
|
|
23
|
+
eventType: 'Content',
|
|
24
|
+
},
|
|
25
|
+
{ alias: 'Umbraco.ContentMoved', eventName: 'Content Moved', eventType: 'Content' },
|
|
26
|
+
{ alias: 'Umbraco.ContentCopied', eventName: 'Content Copied', eventType: 'Content' },
|
|
27
|
+
{ alias: 'Umbraco.MediaSave', eventName: 'Media Saved', eventType: 'Media' },
|
|
28
|
+
{ alias: 'Umbraco.MediaDelete', eventName: 'Media Deleted', eventType: 'Media' },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export const WEBHOOK_EVENT_ALIASES: ReadonlySet<string> = new Set(
|
|
32
|
+
WEBHOOK_EVENTS.map((event) => event.alias),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
/** Something happened to one item: what, and to which. */
|
|
36
|
+
export interface RaisedWebhookEvent {
|
|
37
|
+
alias: string
|
|
38
|
+
key: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const BY_OPERATION: Record<string, string> = {
|
|
42
|
+
PostDocument: 'Umbraco.ContentSaved',
|
|
43
|
+
PutDocumentById: 'Umbraco.ContentSaved',
|
|
44
|
+
PutDocumentByIdPublish: 'Umbraco.ContentPublish',
|
|
45
|
+
PutDocumentByIdPublishWithDescendants: 'Umbraco.ContentPublish',
|
|
46
|
+
PostDocumentCreateAndPublish: 'Umbraco.ContentPublish',
|
|
47
|
+
PutDocumentByIdUpdateAndPublish: 'Umbraco.ContentPublish',
|
|
48
|
+
PutDocumentByIdUnpublish: 'Umbraco.ContentUnpublish',
|
|
49
|
+
DeleteDocumentById: 'Umbraco.ContentDelete',
|
|
50
|
+
DeleteRecycleBinDocumentById: 'Umbraco.ContentDelete',
|
|
51
|
+
PutDocumentByIdMoveToRecycleBin: 'Umbraco.ContentMovedToRecycleBin',
|
|
52
|
+
PutDocumentByIdMove: 'Umbraco.ContentMoved',
|
|
53
|
+
PostDocumentByIdCopy: 'Umbraco.ContentCopied',
|
|
54
|
+
PostMedia: 'Umbraco.MediaSave',
|
|
55
|
+
PutMediaById: 'Umbraco.MediaSave',
|
|
56
|
+
DeleteMediaById: 'Umbraco.MediaDelete',
|
|
57
|
+
DeleteRecycleBinMediaById: 'Umbraco.MediaDelete',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The event a successful operation raises, if any. Pure. The item is the one
|
|
62
|
+
* the operation created (its generated-resource header) or the one it names.
|
|
63
|
+
*/
|
|
64
|
+
export function webhookEventFor(
|
|
65
|
+
operationId: string,
|
|
66
|
+
params: Readonly<Record<string, string>>,
|
|
67
|
+
response: Pick<Response, 'status' | 'headers'>,
|
|
68
|
+
): RaisedWebhookEvent | undefined {
|
|
69
|
+
if (response.status < 200 || response.status >= 300) return undefined
|
|
70
|
+
const alias = BY_OPERATION[operationId]
|
|
71
|
+
if (!alias) return undefined
|
|
72
|
+
const key = response.headers.get('umb-generated-resource') ?? params.id
|
|
73
|
+
return key ? { alias, key: key.toLowerCase() } : undefined
|
|
74
|
+
}
|