@crombolo/api-management 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/authorization.ts +453 -0
- package/src/handlers/content-type.ts +784 -0
- package/src/handlers/data-type.ts +267 -0
- package/src/handlers/dictionary.ts +245 -0
- package/src/handlers/document-blueprint.ts +193 -0
- package/src/handlers/document.ts +961 -0
- package/src/handlers/editor-services.ts +59 -0
- package/src/handlers/element.ts +492 -0
- package/src/handlers/file-systems.ts +207 -0
- package/src/handlers/import.ts +46 -0
- package/src/handlers/localization.ts +116 -0
- package/src/handlers/log-viewer.ts +125 -0
- package/src/handlers/manifest.ts +15 -0
- package/src/handlers/media.ts +453 -0
- package/src/handlers/member-group.ts +87 -0
- package/src/handlers/member.ts +189 -0
- package/src/handlers/models-builder.ts +29 -0
- package/src/handlers/package.ts +189 -0
- package/src/handlers/public-access.ts +107 -0
- package/src/handlers/published-cache.ts +27 -0
- package/src/handlers/redirects.ts +78 -0
- package/src/handlers/references.ts +44 -0
- package/src/handlers/server.ts +25 -0
- package/src/handlers/settings-stubs.ts +76 -0
- package/src/handlers/start-node-trees.ts +84 -0
- package/src/handlers/template-query.ts +191 -0
- package/src/handlers/template.ts +150 -0
- package/src/handlers/temporary-file.ts +62 -0
- package/src/handlers/user.ts +604 -0
- package/src/index.ts +17 -0
- package/src/ports-content.ts +564 -0
- package/src/ports-files.ts +51 -0
- package/src/ports-logs.ts +38 -0
- package/src/ports-members.ts +111 -0
- package/src/ports-models-builder.ts +26 -0
- package/src/ports-packages.ts +58 -0
- package/src/ports-users.ts +230 -0
- package/src/ports.ts +225 -0
- package/src/router.ts +216 -0
- package/src/routes.ts +99 -0
- package/src/server-events.ts +71 -0
- package/README.md +0 -3
package/src/router.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract-first router: every route comes from the vendored OpenAPI document,
|
|
3
|
+
* so a path that is not in the contract cannot be served, and an operation in
|
|
4
|
+
* the contract without a handler answers 501 rather than 404.
|
|
5
|
+
*/
|
|
6
|
+
import { type HttpMethod, listOperations, loadSpec, type OperationInfo } from '@crombolo/contracts'
|
|
7
|
+
import {
|
|
8
|
+
applyNotifications,
|
|
9
|
+
type EventMessage,
|
|
10
|
+
type GroupGrant,
|
|
11
|
+
notFound,
|
|
12
|
+
notImplemented,
|
|
13
|
+
problemResponse,
|
|
14
|
+
type StartNodes,
|
|
15
|
+
unauthorized,
|
|
16
|
+
} from '@crombolo/core'
|
|
17
|
+
import { authorize, LOCAL_LOGIN_OPERATIONS, type NodeLookup } from './authorization.ts'
|
|
18
|
+
import { type ServerEvent, serverEventFor } from './server-events.ts'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolved back-office identity, assembled from a reference token plus the
|
|
22
|
+
* user's group membership. Everything needed to authorise a request is here, so
|
|
23
|
+
* a handler never queries the identity tables itself.
|
|
24
|
+
*/
|
|
25
|
+
export interface Principal {
|
|
26
|
+
/** The user's key (a uuid), which is what the API exposes as its id. */
|
|
27
|
+
id: string
|
|
28
|
+
userName: string
|
|
29
|
+
name: string
|
|
30
|
+
email: string
|
|
31
|
+
isAdmin: boolean
|
|
32
|
+
languageIsoCode: string | undefined
|
|
33
|
+
avatarUrls: string[]
|
|
34
|
+
/** Section manifest aliases, e.g. `Umb.Section.Content`. */
|
|
35
|
+
allowedSections: string[]
|
|
36
|
+
/** Permission verbs granted globally by the user's groups. */
|
|
37
|
+
permissions: string[]
|
|
38
|
+
groupKeys: string[]
|
|
39
|
+
hasAccessToAllLanguages: boolean
|
|
40
|
+
/** Content languages the user may work in; everything when `hasAccessToAllLanguages`. */
|
|
41
|
+
languages: string[]
|
|
42
|
+
/** Where the user may work in each tree, groups' and own start nodes combined. */
|
|
43
|
+
startNodes: { document: StartNodes; media: StartNodes; element: StartNodes }
|
|
44
|
+
/** Each group's verbs, for per-node permission checks. */
|
|
45
|
+
groups: GroupGrant[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RequestContext {
|
|
49
|
+
request: Request
|
|
50
|
+
url: URL
|
|
51
|
+
/** Path parameters, keyed by the name used in the contract. */
|
|
52
|
+
params: Readonly<Record<string, string>>
|
|
53
|
+
operation: OperationInfo
|
|
54
|
+
/** Messages surfaced to the editor through the Umb-Notifications header. */
|
|
55
|
+
notifications: EventMessage[]
|
|
56
|
+
/**
|
|
57
|
+
* Present only when the request authenticated. The router already rejected
|
|
58
|
+
* unauthenticated requests to secured operations, so a handler for a secured
|
|
59
|
+
* operation can rely on this being set.
|
|
60
|
+
*/
|
|
61
|
+
principal: Principal | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type Authenticator = (request: Request) => Promise<Principal | undefined>
|
|
65
|
+
|
|
66
|
+
export interface RouterOptions {
|
|
67
|
+
operations?: readonly OperationInfo[]
|
|
68
|
+
/** Resolves the caller's identity. Omitted means every request is anonymous. */
|
|
69
|
+
authenticate?: Authenticator
|
|
70
|
+
/** Told about every request that reached an operation with no handler. */
|
|
71
|
+
onNotImplemented?: (operation: OperationInfo, request: Request) => void
|
|
72
|
+
/** Told about every change a handler made, before its response is sent. */
|
|
73
|
+
onServerEvent?: (event: ServerEvent) => void
|
|
74
|
+
/** Node positions for per-node permission checks; without it only section access is enforced. */
|
|
75
|
+
nodes?: NodeLookup
|
|
76
|
+
/** Whether users may sign in with a password, which opens the password-reset endpoints to everyone. */
|
|
77
|
+
allowLocalLogin?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type OperationHandler = (ctx: RequestContext) => Response | Promise<Response>
|
|
81
|
+
|
|
82
|
+
interface CompiledRoute {
|
|
83
|
+
operation: OperationInfo
|
|
84
|
+
segments: string[]
|
|
85
|
+
paramNames: (string | undefined)[]
|
|
86
|
+
staticCount: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class ManagementApiRouter {
|
|
90
|
+
readonly operations: readonly OperationInfo[]
|
|
91
|
+
readonly #handlers = new Map<string, OperationHandler>()
|
|
92
|
+
readonly #byMethodAndLength = new Map<string, CompiledRoute[]>()
|
|
93
|
+
readonly #authenticate: Authenticator | undefined
|
|
94
|
+
readonly #onNotImplemented: RouterOptions['onNotImplemented']
|
|
95
|
+
readonly #onServerEvent: RouterOptions['onServerEvent']
|
|
96
|
+
readonly #nodes: NodeLookup | undefined
|
|
97
|
+
readonly #allowLocalLogin: boolean
|
|
98
|
+
|
|
99
|
+
constructor(options: RouterOptions = {}) {
|
|
100
|
+
const operations = options.operations ?? listOperations(loadSpec())
|
|
101
|
+
this.operations = operations
|
|
102
|
+
this.#authenticate = options.authenticate
|
|
103
|
+
this.#onNotImplemented = options.onNotImplemented
|
|
104
|
+
this.#onServerEvent = options.onServerEvent
|
|
105
|
+
this.#nodes = options.nodes
|
|
106
|
+
this.#allowLocalLogin = options.allowLocalLogin ?? true
|
|
107
|
+
for (const operation of operations) {
|
|
108
|
+
const segments = operation.path.split('/').filter((s) => s.length > 0)
|
|
109
|
+
const paramNames = segments.map((s) =>
|
|
110
|
+
s.startsWith('{') && s.endsWith('}') ? s.slice(1, -1) : undefined,
|
|
111
|
+
)
|
|
112
|
+
const route: CompiledRoute = {
|
|
113
|
+
operation,
|
|
114
|
+
segments,
|
|
115
|
+
paramNames,
|
|
116
|
+
staticCount: paramNames.filter((p) => p === undefined).length,
|
|
117
|
+
}
|
|
118
|
+
const key = `${operation.method}:${segments.length}`
|
|
119
|
+
const bucket = this.#byMethodAndLength.get(key)
|
|
120
|
+
if (bucket) bucket.push(route)
|
|
121
|
+
else this.#byMethodAndLength.set(key, [route])
|
|
122
|
+
}
|
|
123
|
+
// Prefer the most literal route when several shapes match.
|
|
124
|
+
for (const bucket of this.#byMethodAndLength.values()) {
|
|
125
|
+
bucket.sort((a, b) => b.staticCount - a.staticCount)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Register the implementation of a contract operation. */
|
|
130
|
+
handle(operationId: string, handler: OperationHandler): this {
|
|
131
|
+
if (!this.operations.some((o) => o.operationId === operationId)) {
|
|
132
|
+
throw new Error(`Unknown operationId '${operationId}' — it is not in the contract.`)
|
|
133
|
+
}
|
|
134
|
+
this.#handlers.set(operationId, handler)
|
|
135
|
+
return this
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
has(operationId: string): boolean {
|
|
139
|
+
return this.#handlers.has(operationId)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get implementedCount(): number {
|
|
143
|
+
return this.#handlers.size
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
match(
|
|
147
|
+
method: string,
|
|
148
|
+
pathname: string,
|
|
149
|
+
): { operation: OperationInfo; params: Record<string, string> } | undefined {
|
|
150
|
+
const segments = pathname.split('/').filter((s) => s.length > 0)
|
|
151
|
+
const bucket = this.#byMethodAndLength.get(
|
|
152
|
+
`${method.toLowerCase() as HttpMethod}:${segments.length}`,
|
|
153
|
+
)
|
|
154
|
+
if (!bucket) return undefined
|
|
155
|
+
for (const route of bucket) {
|
|
156
|
+
const params: Record<string, string> = {}
|
|
157
|
+
let matched = true
|
|
158
|
+
for (let i = 0; i < segments.length; i++) {
|
|
159
|
+
const name = route.paramNames[i]
|
|
160
|
+
const segment = segments[i] as string
|
|
161
|
+
if (name === undefined) {
|
|
162
|
+
if (route.segments[i] !== segment) {
|
|
163
|
+
matched = false
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
params[name] = decodeURIComponent(segment)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (matched) return { operation: route.operation, params }
|
|
171
|
+
}
|
|
172
|
+
return undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async dispatch(request: Request): Promise<Response> {
|
|
176
|
+
const url = new URL(request.url)
|
|
177
|
+
const matched = this.match(request.method, url.pathname)
|
|
178
|
+
if (!matched) {
|
|
179
|
+
return problemResponse(
|
|
180
|
+
notFound(
|
|
181
|
+
'Not Found',
|
|
182
|
+
`No operation in the contract matches ${request.method} ${url.pathname}`,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
const { operation, params } = matched
|
|
187
|
+
const notifications: EventMessage[] = []
|
|
188
|
+
|
|
189
|
+
// Authorisation is driven by the contract: the document requires a
|
|
190
|
+
// back-office user globally, and 10 operations opt out with `security: []`.
|
|
191
|
+
const principal = await this.#authenticate?.(request)
|
|
192
|
+
const anonymous =
|
|
193
|
+
operation.anonymous ||
|
|
194
|
+
(this.#allowLocalLogin && LOCAL_LOGIN_OPERATIONS.has(operation.operationId))
|
|
195
|
+
if (!anonymous && !principal) {
|
|
196
|
+
return problemResponse(unauthorized())
|
|
197
|
+
}
|
|
198
|
+
// Umbraco answers a failed authorization policy with a bare 403.
|
|
199
|
+
if (principal && !(await authorize(operation, params, request, principal, this.#nodes)))
|
|
200
|
+
return new Response(null, { status: 403, headers: { 'cache-control': 'no-store' } })
|
|
201
|
+
|
|
202
|
+
const handler = this.#handlers.get(operation.operationId)
|
|
203
|
+
if (!handler) this.#onNotImplemented?.(operation, request)
|
|
204
|
+
const response = handler
|
|
205
|
+
? await handler({ request, url, params, operation, notifications, principal })
|
|
206
|
+
: problemResponse(notImplemented(operation.operationId))
|
|
207
|
+
if (handler && this.#onServerEvent) {
|
|
208
|
+
const event = serverEventFor(operation, params, response)
|
|
209
|
+
if (event) this.#onServerEvent(event)
|
|
210
|
+
}
|
|
211
|
+
const headers = new Headers(response.headers)
|
|
212
|
+
applyNotifications(headers, request.method, notifications)
|
|
213
|
+
headers.set('cache-control', 'no-store')
|
|
214
|
+
return new Response(response.body, { status: response.status, headers })
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition root for the Management API. Handlers are registered here as they
|
|
3
|
+
* are implemented; every other operation in the contract answers 501.
|
|
4
|
+
*/
|
|
5
|
+
import { registerContentTypeHandlers } from './handlers/content-type.ts'
|
|
6
|
+
import { registerDataTypeHandlers } from './handlers/data-type.ts'
|
|
7
|
+
import { registerDictionaryHandlers } from './handlers/dictionary.ts'
|
|
8
|
+
import { registerDocumentHandlers } from './handlers/document.ts'
|
|
9
|
+
import { registerDocumentBlueprintHandlers } from './handlers/document-blueprint.ts'
|
|
10
|
+
import { registerEditorServiceHandlers } from './handlers/editor-services.ts'
|
|
11
|
+
import { registerElementHandlers } from './handlers/element.ts'
|
|
12
|
+
import { registerFileSystemHandlers } from './handlers/file-systems.ts'
|
|
13
|
+
import { registerImportHandlers } from './handlers/import.ts'
|
|
14
|
+
import { registerLocalizationHandlers } from './handlers/localization.ts'
|
|
15
|
+
import { registerLogViewerHandlers } from './handlers/log-viewer.ts'
|
|
16
|
+
import { registerManifestHandlers } from './handlers/manifest.ts'
|
|
17
|
+
import { registerMediaHandlers } from './handlers/media.ts'
|
|
18
|
+
import { registerMemberHandlers } from './handlers/member.ts'
|
|
19
|
+
import { registerMemberGroupHandlers } from './handlers/member-group.ts'
|
|
20
|
+
import { registerModelsBuilderHandlers } from './handlers/models-builder.ts'
|
|
21
|
+
import { registerPackageHandlers } from './handlers/package.ts'
|
|
22
|
+
import { registerPublicAccessHandlers } from './handlers/public-access.ts'
|
|
23
|
+
import { registerPublishedCacheHandlers } from './handlers/published-cache.ts'
|
|
24
|
+
import { registerRedirectHandlers } from './handlers/redirects.ts'
|
|
25
|
+
import { registerReferenceHandlers } from './handlers/references.ts'
|
|
26
|
+
import { registerServerHandlers } from './handlers/server.ts'
|
|
27
|
+
import { registerSettingsStubHandlers } from './handlers/settings-stubs.ts'
|
|
28
|
+
import { registerTemplateHandlers } from './handlers/template.ts'
|
|
29
|
+
import { registerTemplateQueryHandlers } from './handlers/template-query.ts'
|
|
30
|
+
import { registerTemporaryFileHandlers } from './handlers/temporary-file.ts'
|
|
31
|
+
import { registerUserHandlers } from './handlers/user.ts'
|
|
32
|
+
import type { ManagementApiDeps } from './ports.ts'
|
|
33
|
+
import { type Authenticator, ManagementApiRouter, type RouterOptions } from './router.ts'
|
|
34
|
+
|
|
35
|
+
export interface ManagementApiOptions {
|
|
36
|
+
deps?: ManagementApiDeps
|
|
37
|
+
authenticate?: Authenticator
|
|
38
|
+
onNotImplemented?: RouterOptions['onNotImplemented']
|
|
39
|
+
onServerEvent?: RouterOptions['onServerEvent']
|
|
40
|
+
nodes?: RouterOptions['nodes']
|
|
41
|
+
allowLocalLogin?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createManagementApiRouter(options: ManagementApiOptions = {}): ManagementApiRouter {
|
|
45
|
+
const router = new ManagementApiRouter({
|
|
46
|
+
authenticate: options.authenticate,
|
|
47
|
+
onNotImplemented: options.onNotImplemented,
|
|
48
|
+
onServerEvent: options.onServerEvent,
|
|
49
|
+
nodes: options.nodes,
|
|
50
|
+
allowLocalLogin: options.allowLocalLogin,
|
|
51
|
+
})
|
|
52
|
+
const { deps } = options
|
|
53
|
+
if (!deps) return router
|
|
54
|
+
|
|
55
|
+
registerServerHandlers(router, deps.server)
|
|
56
|
+
registerManifestHandlers(router, deps.manifests)
|
|
57
|
+
registerLocalizationHandlers(router, deps.localization)
|
|
58
|
+
registerUserHandlers(router, deps.currentUser, deps.users, {
|
|
59
|
+
users: deps.userManagement,
|
|
60
|
+
groups: deps.userGroups,
|
|
61
|
+
userData: deps.userData,
|
|
62
|
+
})
|
|
63
|
+
registerSettingsStubHandlers(router)
|
|
64
|
+
if (deps.contentTypes)
|
|
65
|
+
registerContentTypeHandlers(router, deps.contentTypes, 'document', deps.temporaryFiles)
|
|
66
|
+
if (deps.mediaTypes)
|
|
67
|
+
registerContentTypeHandlers(router, deps.mediaTypes, 'media', deps.temporaryFiles)
|
|
68
|
+
if (deps.memberTypes)
|
|
69
|
+
registerContentTypeHandlers(router, deps.memberTypes, 'member', deps.temporaryFiles)
|
|
70
|
+
if (deps.dataTypes) registerDataTypeHandlers(router, deps.dataTypes)
|
|
71
|
+
if (deps.templates) registerTemplateHandlers(router, deps.templates)
|
|
72
|
+
if (deps.templateQuery) registerTemplateQueryHandlers(router, deps.templateQuery)
|
|
73
|
+
if (deps.documents) registerDocumentHandlers(router, deps.documents, deps.preview)
|
|
74
|
+
if (deps.blueprints) registerDocumentBlueprintHandlers(router, deps.blueprints)
|
|
75
|
+
if (deps.temporaryFiles) registerTemporaryFileHandlers(router, deps.temporaryFiles)
|
|
76
|
+
if (deps.media) registerMediaHandlers(router, deps.media)
|
|
77
|
+
if (deps.dictionary) registerDictionaryHandlers(router, deps.dictionary)
|
|
78
|
+
// The components tree is served through the PartialView file API: it is the
|
|
79
|
+
// path-addressed one, so it is the only one that can express a folder.
|
|
80
|
+
if (deps.components)
|
|
81
|
+
registerFileSystemHandlers(router, 'PartialView', deps.components, deps.componentSnippets)
|
|
82
|
+
if (deps.stylesheets) registerFileSystemHandlers(router, 'Stylesheet', deps.stylesheets)
|
|
83
|
+
if (deps.scripts) registerFileSystemHandlers(router, 'Script', deps.scripts)
|
|
84
|
+
if (deps.logViewer) registerLogViewerHandlers(router, deps.logViewer)
|
|
85
|
+
if (deps.modelsBuilder) registerModelsBuilderHandlers(router, deps.modelsBuilder)
|
|
86
|
+
if (deps.publishedCache) registerPublishedCacheHandlers(router, deps.publishedCache)
|
|
87
|
+
if (deps.temporaryFiles) registerImportHandlers(router, deps.temporaryFiles)
|
|
88
|
+
if (deps.memberGroups) registerMemberGroupHandlers(router, deps.memberGroups)
|
|
89
|
+
if (deps.members) registerMemberHandlers(router, deps.members)
|
|
90
|
+
if (deps.publicAccess) registerPublicAccessHandlers(router, deps.publicAccess)
|
|
91
|
+
if (deps.elements) registerElementHandlers(router, deps.elements, deps.references)
|
|
92
|
+
if (deps.redirects) registerRedirectHandlers(router, deps.redirects)
|
|
93
|
+
if (deps.packages) registerPackageHandlers(router, deps.packages)
|
|
94
|
+
if (deps.references)
|
|
95
|
+
for (const area of ['Document', 'Media', 'Member', 'Element'] as const)
|
|
96
|
+
registerReferenceHandlers(router, area, deps.references)
|
|
97
|
+
registerEditorServiceHandlers(router, { tags: deps.tags, oembed: deps.oembed })
|
|
98
|
+
return router
|
|
99
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server events a successful change announces, as Umbraco's notification
|
|
3
|
+
* handlers do. The backoffice caches entity details while connected to the
|
|
4
|
+
* event hub and drops a cached entity only when an event names it, so a change
|
|
5
|
+
* that announces nothing leaves every open editor, including the one that made
|
|
6
|
+
* it, showing the old copy.
|
|
7
|
+
*/
|
|
8
|
+
import type { OperationInfo } from '@crombolo/contracts'
|
|
9
|
+
|
|
10
|
+
export type ServerEventType = 'Created' | 'Updated' | 'Deleted' | 'Trashed'
|
|
11
|
+
|
|
12
|
+
export interface ServerEvent {
|
|
13
|
+
eventSource: string
|
|
14
|
+
eventType: ServerEventType
|
|
15
|
+
key: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Operation-name entity prefixes, longest first so `DocumentType` wins over `Document`. */
|
|
19
|
+
const ENTITIES = [
|
|
20
|
+
'DocumentBlueprint',
|
|
21
|
+
'DocumentType',
|
|
22
|
+
'Document',
|
|
23
|
+
'MediaType',
|
|
24
|
+
'Media',
|
|
25
|
+
'MemberGroup',
|
|
26
|
+
'MemberType',
|
|
27
|
+
'Member',
|
|
28
|
+
'DataType',
|
|
29
|
+
'Language',
|
|
30
|
+
'Script',
|
|
31
|
+
'Stylesheet',
|
|
32
|
+
'Template',
|
|
33
|
+
'DictionaryItem',
|
|
34
|
+
'PartialView',
|
|
35
|
+
'UserGroup',
|
|
36
|
+
'User',
|
|
37
|
+
'Webhook',
|
|
38
|
+
] as const
|
|
39
|
+
|
|
40
|
+
/** Operations on an entity that change nothing about it, or are about something else. */
|
|
41
|
+
const NOT_A_CHANGE = /^(Folder|Validate|ByIdValidate|Item|Tree|Filter|Search|Current|Configuration)/
|
|
42
|
+
|
|
43
|
+
export function serverEventFor(
|
|
44
|
+
operation: OperationInfo,
|
|
45
|
+
params: Readonly<Record<string, string>>,
|
|
46
|
+
response: Response,
|
|
47
|
+
): ServerEvent | undefined {
|
|
48
|
+
if (response.status < 200 || response.status >= 300) return undefined
|
|
49
|
+
const verb = /^(Post|Put|Delete)(.+)$/.exec(operation.operationId)
|
|
50
|
+
if (!verb) return undefined
|
|
51
|
+
const [, method, verbRest] = verb as unknown as [string, string, string]
|
|
52
|
+
// `DeleteRecycleBinMediaById` deletes a media item; `…ByIdRestore` updates one.
|
|
53
|
+
const rest = verbRest.startsWith('RecycleBin') ? verbRest.slice('RecycleBin'.length) : verbRest
|
|
54
|
+
const entity = ENTITIES.find((name) => rest.startsWith(name))
|
|
55
|
+
if (!entity) return undefined
|
|
56
|
+
const after = rest.slice(entity.length)
|
|
57
|
+
if (NOT_A_CHANGE.test(after)) return undefined
|
|
58
|
+
|
|
59
|
+
const generated = response.headers.get('umb-generated-resource')
|
|
60
|
+
const key = generated ?? params.id ?? params.isoCode ?? params.path
|
|
61
|
+
if (!key) return undefined
|
|
62
|
+
const eventType: ServerEventType =
|
|
63
|
+
method === 'Delete'
|
|
64
|
+
? 'Deleted'
|
|
65
|
+
: after.endsWith('MoveToRecycleBin')
|
|
66
|
+
? 'Trashed'
|
|
67
|
+
: generated
|
|
68
|
+
? 'Created'
|
|
69
|
+
: 'Updated'
|
|
70
|
+
return { eventSource: `Umbraco:CMS:${entity}`, eventType, key }
|
|
71
|
+
}
|
package/README.md
DELETED