@crombolo/api-delivery 0.0.0-stage → 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/src/handler.ts ADDED
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Umbraco's Delivery API v2 over the published cache: content and media
3
+ * items, paths and queries, with its access, culture and Start-Item rules.
4
+ * Every answer is read from memory, so it scales with the nodes that render.
5
+ * docs/delivery-api.md.
6
+ */
7
+ import type { PublishedCache, PublishedContent, PublishedMedia } from '@crombolo/render'
8
+ import { isRightToLeft } from '@crombolo/render'
9
+ import {
10
+ ALL_FIELDS,
11
+ type ExpansionNode,
12
+ ExpansionSyntaxError,
13
+ parseExpansion,
14
+ } from './expansion.ts'
15
+ import {
16
+ type ApiRoute,
17
+ apiContentResponse,
18
+ apiMediaResponse,
19
+ type OutputContext,
20
+ } from './output.ts'
21
+ import { accessTo, type DeliveryMember, type ProtectionRule } from './protection.ts'
22
+ import {
23
+ compareBy,
24
+ parseContentFilters,
25
+ parseContentSelector,
26
+ parseContentSorts,
27
+ parseMediaQuery,
28
+ passesFilters,
29
+ QUERY_ERRORS,
30
+ type QueryError,
31
+ } from './query.ts'
32
+ import {
33
+ type DeliveryApiSettings,
34
+ isAllowedContentType,
35
+ memberAuthorizationEnabled,
36
+ } from './settings.ts'
37
+
38
+ export interface DeliveryApiPorts {
39
+ settings: DeliveryApiSettings
40
+ cache: PublishedCache
41
+ /** Where the API answers: `/umbraco/delivery/api`, as in Umbraco. */
42
+ prefix: string
43
+ /** Every protected node and who may see it; read once a request, and only when needed. */
44
+ protection(): Promise<ProtectionRule[]>
45
+ /** The member an access token names; undefined for none, or one not valid now. */
46
+ member(token: string): Promise<DeliveryMember | undefined>
47
+ /** The member sign-in endpoints under `v1/security/member/`, when built. */
48
+ security?(request: Request, action: string): Promise<Response | undefined>
49
+ }
50
+
51
+ export interface DeliveryApi {
52
+ handle(request: Request): Promise<Response | undefined>
53
+ }
54
+
55
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
56
+ const ROOT_KEY = '00000000-0000-0000-0000-000000000000'
57
+ const CONTENT_VARY = 'Accept-Language, Accept-Segment, Preview, Start-Item'
58
+
59
+ /** 401, 403 and 404 carry no body in Umbraco's Delivery API. */
60
+ const bare = (status: number) => new Response(null, { status })
61
+
62
+ const problem = (error: QueryError) =>
63
+ Response.json(
64
+ {
65
+ type: 'Error',
66
+ title: QUERY_ERRORS[error].title,
67
+ status: 400,
68
+ detail: QUERY_ERRORS[error].detail,
69
+ },
70
+ { status: 400, headers: { 'content-type': 'application/problem+json' } },
71
+ )
72
+
73
+ const invalid = (field: string, value: string) =>
74
+ Response.json(
75
+ {
76
+ type: 'https://tools.ietf.org/html/rfc9110#section-15.5.1',
77
+ title: 'One or more validation errors occurred.',
78
+ status: 400,
79
+ errors: { [field]: [`The value '${value}' is not valid.`] },
80
+ },
81
+ { status: 400, headers: { 'content-type': 'application/problem+json' } },
82
+ )
83
+
84
+ /** The caller's key, compared exactly; a site without one has none to match. Pure. */
85
+ export const hasValidApiKey = (settings: DeliveryApiSettings, request: Request): boolean =>
86
+ settings.apiKey !== undefined && request.headers.get('api-key') === settings.apiKey
87
+
88
+ export const isPreview = (request: Request): boolean =>
89
+ request.headers.get('preview')?.toLowerCase() === 'true'
90
+
91
+ /** Umbraco's `ApiAccessService`. Pure. */
92
+ export function contentAccess(settings: DeliveryApiSettings, request: Request): boolean {
93
+ if (!settings.enabled) return false
94
+ if (isPreview(request)) return hasValidApiKey(settings, request)
95
+ return settings.publicAccess || hasValidApiKey(settings, request)
96
+ }
97
+
98
+ export function mediaAccess(settings: DeliveryApiSettings, request: Request): boolean {
99
+ return (
100
+ settings.enabled &&
101
+ settings.media.enabled &&
102
+ ((settings.publicAccess && settings.media.publicAccess) || hasValidApiKey(settings, request))
103
+ )
104
+ }
105
+
106
+ /** `Accept-Language` only when it is one plain culture name; otherwise none. Pure. */
107
+ export function requestedCulture(header: string | null): string | undefined {
108
+ const value = header?.trim()
109
+ return value && /^[\w-]*$/.test(value) ? value : undefined
110
+ }
111
+
112
+ /** The bearer token from the `Authorization` header only — never the query string. Pure. */
113
+ export function bearerToken(request: Request): string | undefined {
114
+ const match = /^Bearer\s+(.+)$/i.exec(request.headers.get('authorization') ?? '')
115
+ return match?.[1]?.trim() || undefined
116
+ }
117
+
118
+ const intParam = (url: URL, name: string, fallback: number): number | Response => {
119
+ const raw = url.searchParams.get(name)
120
+ if (raw === null || raw === '') return fallback
121
+ const value = Number(raw)
122
+ return Number.isInteger(value) && value >= 0 ? value : invalid(name, raw)
123
+ }
124
+
125
+ const expansionOf = (url: URL): { expand: ExpansionNode; include: ExpansionNode } | Response => {
126
+ try {
127
+ const fields = url.searchParams.get('fields')
128
+ return {
129
+ expand: parseExpansion(url.searchParams.get('expand') ?? ''),
130
+ include: fields ? parseExpansion(fields) : ALL_FIELDS,
131
+ }
132
+ } catch (error) {
133
+ if (!(error instanceof ExpansionSyntaxError)) throw error
134
+ return Response.json(
135
+ { type: 'Error', title: 'Invalid expansion', status: 400, detail: error.message },
136
+ { status: 400, headers: { 'content-type': 'application/problem+json' } },
137
+ )
138
+ }
139
+ }
140
+
141
+ export function createDeliveryApi(ports: DeliveryApiPorts): DeliveryApi {
142
+ const { settings, cache } = ports
143
+ const prefix = ports.prefix.replace(/\/$/, '')
144
+
145
+ /** Everything a content request decides once: culture, member, rules, routes. */
146
+ async function contentRequest(request: Request) {
147
+ const asked = requestedCulture(request.headers.get('accept-language'))
148
+ const culture = asked ?? (await cache.defaultCulture())
149
+ const known = asked === undefined || (await cache.hasCulture(asked))
150
+ const nav = await cache.navigation(culture)
151
+ const hide = await cache.hidesTopLevel()
152
+ const roots = nav.root()
153
+ const rtl = isRightToLeft(culture)
154
+
155
+ let member: DeliveryMember | undefined
156
+ const token = bearerToken(request)
157
+ if (token && memberAuthorizationEnabled(settings)) member = await ports.member(token)
158
+ const loadedRules = new Map((await ports.protection()).map((rule) => [rule.nodeId, rule]))
159
+
160
+ const routeIn = async (inCulture: string | null) => {
161
+ const view = await cache.navigation(inCulture)
162
+ const first = view.root()[0]
163
+ const cultureRtl = isRightToLeft(inCulture)
164
+ return (content: PublishedContent): ApiRoute | undefined => {
165
+ const here = view.byKey(content.key)
166
+ if (!here) return undefined
167
+ const ancestors = view.ancestors(here)
168
+ const root = ancestors[ancestors.length - 1] ?? here
169
+ let path: string
170
+ if (ancestors.length === 0)
171
+ path = hide && first && here.id !== first.id ? `/${here.urlSegment}` : '/'
172
+ else {
173
+ const below = [...ancestors.slice(0, -1).reverse(), here].map((c) => c.urlSegment)
174
+ path = `/${(cultureRtl ? below.reverse() : below).join('/')}`
175
+ }
176
+ return { path, queryString: null, startItem: { id: root.key, path: root.urlSegment } }
177
+ }
178
+ }
179
+ const route = await routeIn(culture)
180
+
181
+ const ctx: OutputContext = {
182
+ route,
183
+ content: (key) => nav.byKey(key),
184
+ allowedType: (alias) => isAllowedContentType(settings, alias),
185
+ access: (content) => accessTo(content.path, loadedRules, member),
186
+ }
187
+
188
+ /** Below a node by URL segments, exactly as stored. */
189
+ const walk = (
190
+ from: readonly PublishedContent[],
191
+ segments: readonly string[],
192
+ ): PublishedContent | undefined => {
193
+ let level = from
194
+ let found: PublishedContent | undefined
195
+ for (const segment of segments) {
196
+ found = level.find((c) => c.urlSegment === segment)
197
+ if (!found) return undefined
198
+ level = nav.children(found)
199
+ }
200
+ return found
201
+ }
202
+
203
+ /** `Start-Item`: a root's key, or a root's segment in any case. */
204
+ const startItemHeader = request.headers.get('start-item')?.replace(/^\/+|\/+$/g, '')
205
+ const startItem = startItemHeader
206
+ ? roots.find((r) =>
207
+ GUID.test(startItemHeader)
208
+ ? r.key === startItemHeader.toLowerCase()
209
+ : r.urlSegment.toLowerCase() === startItemHeader.toLowerCase(),
210
+ )
211
+ : undefined
212
+ const badStartItem = Boolean(startItemHeader) && !startItem
213
+
214
+ const byPath = (raw: string): PublishedContent | undefined => {
215
+ const decoded = raw.includes('%2F') || raw.includes('%2f') ? decodeURIComponent(raw) : raw
216
+ let segments = decoded.split('/').filter(Boolean)
217
+ if (rtl) segments = [...segments].reverse()
218
+ if (startItem)
219
+ return segments.length === 0 ? startItem : walk(nav.children(startItem), segments)
220
+ if (segments.length === 0) return roots[0]
221
+ if (!hide) return walk(roots, segments)
222
+ if (segments.length === 1) {
223
+ const root = roots.find((r) => r.urlSegment === segments[0])
224
+ if (root) return root
225
+ }
226
+ for (const root of roots) {
227
+ const found = walk(nav.children(root), segments)
228
+ if (found) return found
229
+ }
230
+ return undefined
231
+ }
232
+
233
+ const servable = (content: PublishedContent | undefined) =>
234
+ content &&
235
+ known &&
236
+ isAllowedContentType(settings, content.contentType.alias) &&
237
+ route(content)
238
+ ? content
239
+ : undefined
240
+
241
+ const respond = async (content: PublishedContent, url: URL) => {
242
+ const expansion = expansionOf(url)
243
+ if (expansion instanceof Response) return expansion
244
+ const routes = new Map<string, (c: PublishedContent) => ApiRoute | undefined>()
245
+ for (const c of content.cultures) routes.set(c, await routeIn(c))
246
+ return apiContentResponse(
247
+ content,
248
+ ctx,
249
+ (c) => routes.get(c)?.(content),
250
+ expansion.expand,
251
+ expansion.include,
252
+ )
253
+ }
254
+
255
+ return {
256
+ culture,
257
+ known,
258
+ nav,
259
+ roots,
260
+ member,
261
+ ctx,
262
+ byPath,
263
+ startItem,
264
+ badStartItem,
265
+ servable,
266
+ respond,
267
+ }
268
+ }
269
+
270
+ type ContentRequest = Awaited<ReturnType<typeof contentRequest>>
271
+
272
+ const json = (body: unknown) => Response.json(body, { headers: { vary: CONTENT_VARY } })
273
+
274
+ async function contentItem(r: ContentRequest, content: PublishedContent | undefined, url: URL) {
275
+ const found = r.servable(content)
276
+ if (!found) return bare(404)
277
+ const access = r.ctx.access(found)
278
+ if (access !== 'allowed') return bare(access === 'forbidden' ? 403 : 401)
279
+ const body = await r.respond(found, url)
280
+ if (body instanceof Response) return body
281
+ return body ? json(body) : bare(404)
282
+ }
283
+
284
+ async function contentItems(r: ContentRequest, url: URL) {
285
+ const ids = [...new Set(url.searchParams.getAll('id').map((id) => id.toLowerCase()))]
286
+ for (const id of ids) if (!GUID.test(id)) return invalid('id', id)
287
+ const found = ids.flatMap((id) => {
288
+ const content = r.servable(r.nav.byKey(id))
289
+ return content ? [content] : []
290
+ })
291
+ for (const content of found) {
292
+ const access = r.ctx.access(content)
293
+ if (access !== 'allowed') return bare(access === 'forbidden' ? 403 : 401)
294
+ }
295
+ const out = []
296
+ for (const content of found) {
297
+ const body = await r.respond(content, url)
298
+ if (body instanceof Response) return body
299
+ if (body) out.push(body)
300
+ }
301
+ return json(out)
302
+ }
303
+
304
+ async function contentQuery(r: ContentRequest, url: URL) {
305
+ const selector = parseContentSelector(url.searchParams.get('fetch'))
306
+ if (typeof selector === 'string') return problem(selector)
307
+ const filters = parseContentFilters(url.searchParams.getAll('filter'))
308
+ if (typeof filters === 'string') return problem(filters)
309
+ const sorts = parseContentSorts(url.searchParams.getAll('sort'))
310
+ if (typeof sorts === 'string') return problem(sorts)
311
+ const skip = intParam(url, 'skip', 0)
312
+ if (skip instanceof Response) return skip
313
+ const take = intParam(url, 'take', 10)
314
+ if (take instanceof Response) return take
315
+
316
+ const all = r.known ? await cache.contentIn(r.culture) : []
317
+ const resolveTarget = (target: string): PublishedContent | 'roots' | undefined => {
318
+ if (!target) return undefined
319
+ if (target === ROOT_KEY) return 'roots'
320
+ if (GUID.test(target)) return r.nav.byKey(target)
321
+ return r.byPath(target)
322
+ }
323
+ let candidates: PublishedContent[]
324
+ if (selector.kind === 'all')
325
+ candidates = r.startItem
326
+ ? all.filter(
327
+ (c) => c.id !== r.startItem?.id && c.path.split(',').includes(String(r.startItem?.id)),
328
+ )
329
+ : all
330
+ else {
331
+ const target = resolveTarget(selector.target)
332
+ if (!target) candidates = []
333
+ else if (selector.kind === 'children')
334
+ candidates = target === 'roots' ? r.roots : r.nav.children(target)
335
+ else if (target === 'roots') candidates = selector.kind === 'descendants' ? all : []
336
+ else if (selector.kind === 'ancestors') candidates = [...r.nav.ancestors(target)].reverse()
337
+ else
338
+ candidates = all.filter(
339
+ (c) => c.id !== target.id && c.path.split(',').includes(String(target.id)),
340
+ )
341
+ }
342
+
343
+ // A members-only page is never listed for a caller who cannot open it,
344
+ // whether or not member sign-in is on (Umbraco lists them when it is off).
345
+ const visible = candidates.filter(
346
+ (c) =>
347
+ isAllowedContentType(settings, c.contentType.alias) &&
348
+ r.ctx.access(c) === 'allowed' &&
349
+ passesFilters(
350
+ {
351
+ contentType: c.contentType.alias,
352
+ name: c.name,
353
+ createDate: c.createDate,
354
+ updateDate: c.updateDate,
355
+ },
356
+ filters,
357
+ ),
358
+ )
359
+ const ordered =
360
+ sorts.length > 0
361
+ ? [...visible].sort(
362
+ compareBy(sorts) as (a: PublishedContent, b: PublishedContent) => number,
363
+ )
364
+ : visible
365
+ const items = []
366
+ for (const content of ordered.slice(skip, skip + take)) {
367
+ const body = await r.respond(content, url)
368
+ if (body instanceof Response) return body
369
+ if (body) items.push(body)
370
+ }
371
+ return json({ total: ordered.length, items })
372
+ }
373
+
374
+ /** Media's tree, by parent, and each item's path of lowercased names. */
375
+ async function mediaTree() {
376
+ const media = await cache.allMedia()
377
+ const byId = new Map(media.map((m) => [m.id, m]))
378
+ const childrenOf = new Map<number, PublishedMedia[]>()
379
+ for (const m of media) childrenOf.set(m.parentId, [...(childrenOf.get(m.parentId) ?? []), m])
380
+ for (const list of childrenOf.values()) list.sort((a, b) => a.sortOrder - b.sortOrder)
381
+ const pathOf = (m: PublishedMedia): string => {
382
+ const names: string[] = []
383
+ for (let at: PublishedMedia | undefined = m; at; at = byId.get(at.parentId))
384
+ names.unshift(at.name.toLowerCase())
385
+ return `/${names.join('/')}/`
386
+ }
387
+ const byPath = (raw: string): PublishedMedia | undefined => {
388
+ let level = childrenOf.get(-1) ?? []
389
+ let found: PublishedMedia | undefined
390
+ for (const segment of decodeURIComponent(raw).split('/').filter(Boolean)) {
391
+ found = level.find((m) => m.name.toLowerCase() === segment.toLowerCase())
392
+ if (!found) return undefined
393
+ level = childrenOf.get(found.id) ?? []
394
+ }
395
+ return found
396
+ }
397
+ const byKey = new Map(media.map((m) => [m.key.toLowerCase(), m]))
398
+ return { media, childrenOf, pathOf, byPath, byKey }
399
+ }
400
+
401
+ async function handleMedia(request: Request, url: URL, rest: string) {
402
+ if (!mediaAccess(settings, request)) return bare(401)
403
+ const tree = await mediaTree()
404
+ const answer = (m: PublishedMedia) => apiMediaResponse(m, tree.pathOf(m))
405
+ if (rest === 'items') {
406
+ const ids = [...new Set(url.searchParams.getAll('id').map((id) => id.toLowerCase()))]
407
+ for (const id of ids) if (!GUID.test(id)) return invalid('id', id)
408
+ return Response.json(
409
+ ids.flatMap((id) =>
410
+ tree.byKey.has(id) ? [answer(tree.byKey.get(id) as PublishedMedia)] : [],
411
+ ),
412
+ )
413
+ }
414
+ if (rest.startsWith('item/')) {
415
+ const target = rest.slice('item/'.length)
416
+ const found = GUID.test(target) ? tree.byKey.get(target.toLowerCase()) : tree.byPath(target)
417
+ return found ? Response.json(answer(found)) : bare(404)
418
+ }
419
+ if (rest !== '') return undefined
420
+ const query = parseMediaQuery({
421
+ fetch: url.searchParams.get('fetch'),
422
+ filters: url.searchParams.getAll('filter'),
423
+ sorts: url.searchParams.getAll('sort'),
424
+ })
425
+ if (typeof query === 'string') return problem(query)
426
+ const skip = intParam(url, 'skip', 0)
427
+ if (skip instanceof Response) return skip
428
+ const take = intParam(url, 'take', 10)
429
+ if (take instanceof Response) return take
430
+ let parent: PublishedMedia | 'root' | undefined
431
+ if (query.parent === '') parent = undefined
432
+ else if (/^\/+$/.test(query.parent)) parent = 'root'
433
+ else if (GUID.test(query.parent)) parent = tree.byKey.get(query.parent.toLowerCase())
434
+ else parent = tree.byPath(query.parent)
435
+ const children = parent ? (tree.childrenOf.get(parent === 'root' ? -1 : parent.id) ?? []) : []
436
+ const filtered = children.filter((m) =>
437
+ query.filters.every((f) =>
438
+ f.kind === 'mediaType'
439
+ ? m.mediaType.alias === f.value
440
+ : m.name.toLowerCase().includes(f.value.toLowerCase()),
441
+ ),
442
+ )
443
+ const ordered = query.sort
444
+ ? [...filtered].sort(
445
+ compareBy([query.sort]) as unknown as (a: PublishedMedia, b: PublishedMedia) => number,
446
+ )
447
+ : filtered
448
+ return Response.json({
449
+ total: ordered.length,
450
+ items: ordered.slice(skip, skip + take).map(answer),
451
+ })
452
+ }
453
+
454
+ return {
455
+ async handle(request) {
456
+ const url = new URL(request.url)
457
+ if (!url.pathname.startsWith(`${prefix}/`)) return undefined
458
+ const rest = url.pathname.slice(prefix.length + 1)
459
+
460
+ const security = /^v1\/security\/member\/(.+)$/.exec(rest)
461
+ if (security) return ports.security?.(request, security[1] as string)
462
+
463
+ const match = /^v2\/(content|media)(?:\/(.*))?$/.exec(rest)
464
+ if (!match) return undefined
465
+ if (request.method !== 'GET' && request.method !== 'HEAD') return bare(405)
466
+ const area = match[1]
467
+ const tail = (match[2] ?? '').replace(/\/$/, '')
468
+
469
+ if (area === 'media')
470
+ return (
471
+ (await handleMedia(request, url, tail === '' && match[2] === undefined ? '' : tail)) ??
472
+ bare(404)
473
+ )
474
+
475
+ if (!contentAccess(settings, request)) return bare(401)
476
+ const r = await contentRequest(request)
477
+ if (r.badStartItem) return Response.json('The Start-Item could not be found', { status: 404 })
478
+ if (tail === '' && match[2] === undefined) return contentQuery(r, url)
479
+ if (tail === 'items') return contentItems(r, url)
480
+ if (match[2]?.startsWith('item')) {
481
+ const target = (match[2] as string).replace(/^item\/?/, '')
482
+ return contentItem(r, GUID.test(target) ? r.nav.byKey(target) : r.byPath(target), url)
483
+ }
484
+ return bare(404)
485
+ },
486
+ }
487
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './coverage.ts'
2
+ export * from './expansion.ts'
3
+ export * from './handler.ts'
4
+ export * from './output.ts'
5
+ export * from './protection.ts'
6
+ export * from './query.ts'
7
+ export * from './settings.ts'