@manablox/api-public 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -0
- package/src/context.ts +22 -0
- package/src/index.ts +3 -0
- package/src/router.ts +298 -0
- package/src/serialize.ts +333 -0
- package/test/serialize.test.ts +177 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manablox/api-public",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"default": "./src/index.ts"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/index.ts",
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@manablox/core": "0.1.0",
|
|
15
|
+
"@manablox/db": "0.1.0",
|
|
16
|
+
"@manablox/media": "0.1.0",
|
|
17
|
+
"@manablox/services": "0.1.0",
|
|
18
|
+
"@orpc/server": "^1.15.0",
|
|
19
|
+
"zod": "^4.5.4"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@manablox/config-typescript": "0.0.0",
|
|
23
|
+
"@manablox/fields": "0.1.0",
|
|
24
|
+
"@types/node": "^26.4.1",
|
|
25
|
+
"typescript": "^7.0.2",
|
|
26
|
+
"vitest": "^5.0.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Manablox } from '@manablox/core';
|
|
2
|
+
import type { Repositories } from '@manablox/db';
|
|
3
|
+
import type { MediaService } from '@manablox/media';
|
|
4
|
+
import type { Loaders } from '@manablox/services';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The delivery context.
|
|
8
|
+
*
|
|
9
|
+
* Compare `RpcContext`: no `auth`, no `apiKeys`, no `principal`, no `content` service.
|
|
10
|
+
* A procedure here cannot authenticate a caller or write, because the context it runs
|
|
11
|
+
* in has nothing to do it with — the read-only guarantee is a property of the type, not
|
|
12
|
+
* a rule contributors have to remember.
|
|
13
|
+
*/
|
|
14
|
+
export interface PublicContext {
|
|
15
|
+
manablox: Manablox;
|
|
16
|
+
repos: Repositories;
|
|
17
|
+
media: MediaService;
|
|
18
|
+
loaders: Loaders;
|
|
19
|
+
/** The one space this instance serves. Never taken from the request. */
|
|
20
|
+
spaceId: string;
|
|
21
|
+
locale: string;
|
|
22
|
+
}
|
package/src/index.ts
ADDED
package/src/router.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { ManabloxError, resolveGraphQL } from '@manablox/core';
|
|
2
|
+
import { ORPCError, os } from '@orpc/server';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { PublicContext } from './context.js';
|
|
5
|
+
import {
|
|
6
|
+
type PublicAsset,
|
|
7
|
+
type PublicContent,
|
|
8
|
+
parseExpand,
|
|
9
|
+
serializeAsset,
|
|
10
|
+
serializeContent,
|
|
11
|
+
serializeContents,
|
|
12
|
+
} from './serialize.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The base procedure.
|
|
16
|
+
*
|
|
17
|
+
* There is no `authed` and no `scoped` sibling here, and that is the point: a public
|
|
18
|
+
* procedure has no principal to check, so the only middleware is the error mapping.
|
|
19
|
+
*/
|
|
20
|
+
const base = os.$context<PublicContext>().use(async ({ next }) => {
|
|
21
|
+
try {
|
|
22
|
+
return await next();
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (!ManabloxError.is(error)) throw error;
|
|
25
|
+
const code = (
|
|
26
|
+
{
|
|
27
|
+
validation: 'BAD_REQUEST',
|
|
28
|
+
bad_request: 'BAD_REQUEST',
|
|
29
|
+
not_found: 'NOT_FOUND',
|
|
30
|
+
conflict: 'CONFLICT',
|
|
31
|
+
forbidden: 'FORBIDDEN',
|
|
32
|
+
unauthorized: 'UNAUTHORIZED',
|
|
33
|
+
internal: 'INTERNAL_SERVER_ERROR',
|
|
34
|
+
} as const
|
|
35
|
+
)[error.kind];
|
|
36
|
+
throw new ORPCError(code, { message: error.key, data: { key: error.key } });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const uuid = z.string().uuid();
|
|
41
|
+
const expandArg = z
|
|
42
|
+
.string()
|
|
43
|
+
.max(200)
|
|
44
|
+
.optional()
|
|
45
|
+
.describe('Comma-separated relation fields to inline, e.g. `hero,author`.');
|
|
46
|
+
|
|
47
|
+
const contentSchema: z.ZodType<PublicContent> = z.object({
|
|
48
|
+
id: z.string(),
|
|
49
|
+
type: z.string(),
|
|
50
|
+
title: z.string(),
|
|
51
|
+
slug: z.string(),
|
|
52
|
+
permalink: z.string().nullable(),
|
|
53
|
+
locale: z.string(),
|
|
54
|
+
parentId: z.string().nullable(),
|
|
55
|
+
publishedAt: z.string().nullable(),
|
|
56
|
+
updatedAt: z.string(),
|
|
57
|
+
fields: z.record(z.string(), z.unknown()),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const assetSchema: z.ZodType<PublicAsset> = z.object({
|
|
61
|
+
id: z.string(),
|
|
62
|
+
url: z.string(),
|
|
63
|
+
filename: z.string(),
|
|
64
|
+
mimeType: z.string(),
|
|
65
|
+
size: z.number(),
|
|
66
|
+
width: z.number().nullable(),
|
|
67
|
+
height: z.number().nullable(),
|
|
68
|
+
alt: z.string().nullable(),
|
|
69
|
+
title: z.string().nullable(),
|
|
70
|
+
variants: z.record(z.string(), z.string()),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const listSchema = z.object({
|
|
74
|
+
items: z.array(contentSchema),
|
|
75
|
+
total: z.number(),
|
|
76
|
+
limit: z.number(),
|
|
77
|
+
offset: z.number(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The public delivery API: one definition yielding REST, an OpenAPI document and the
|
|
82
|
+
* SDK's types. No `spaceId` appears anywhere — the space is pinned in the context.
|
|
83
|
+
*/
|
|
84
|
+
export const publicRouter = {
|
|
85
|
+
list: base
|
|
86
|
+
.route({
|
|
87
|
+
method: 'GET',
|
|
88
|
+
path: '/content',
|
|
89
|
+
summary: 'List published documents',
|
|
90
|
+
tags: ['content'],
|
|
91
|
+
})
|
|
92
|
+
.input(
|
|
93
|
+
z.object({
|
|
94
|
+
type: z.string().max(100).optional(),
|
|
95
|
+
parentId: uuid.optional(),
|
|
96
|
+
under: uuid.optional(),
|
|
97
|
+
search: z.string().max(200).optional(),
|
|
98
|
+
locale: z.string().max(10).optional(),
|
|
99
|
+
limit: z.coerce.number().int().min(1).max(100).default(25),
|
|
100
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
101
|
+
expand: expandArg,
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
.output(listSchema)
|
|
105
|
+
.handler(async ({ input, context }) => {
|
|
106
|
+
const typeIds = input.type
|
|
107
|
+
? [context.manablox.contentTypes.getByName(input.type, context.spaceId).id]
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
const page = await context.repos.content.list(
|
|
111
|
+
{
|
|
112
|
+
spaceId: context.spaceId,
|
|
113
|
+
locale: input.locale ?? context.locale,
|
|
114
|
+
...(typeIds ? { typeIds } : {}),
|
|
115
|
+
...(input.parentId ? { parentId: input.parentId } : {}),
|
|
116
|
+
...(input.under ? { under: input.under } : {}),
|
|
117
|
+
...(input.search ? { search: input.search } : {}),
|
|
118
|
+
},
|
|
119
|
+
{ limit: input.limit, offset: input.offset },
|
|
120
|
+
[],
|
|
121
|
+
// The published projection, always. There is no other option on this router.
|
|
122
|
+
true,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
items: await serializeContents(page.items, context, parseExpand(input.expand)),
|
|
127
|
+
total: page.total,
|
|
128
|
+
limit: page.limit,
|
|
129
|
+
offset: page.offset,
|
|
130
|
+
};
|
|
131
|
+
}),
|
|
132
|
+
|
|
133
|
+
get: base
|
|
134
|
+
.route({
|
|
135
|
+
method: 'GET',
|
|
136
|
+
path: '/content/{id}',
|
|
137
|
+
summary: 'Fetch one published document by id',
|
|
138
|
+
tags: ['content'],
|
|
139
|
+
})
|
|
140
|
+
.input(z.object({ id: uuid, expand: expandArg }))
|
|
141
|
+
.output(contentSchema)
|
|
142
|
+
.handler(async ({ input, context }) => {
|
|
143
|
+
// The loader is space-scoped, so an id from another tenant simply does not resolve.
|
|
144
|
+
const row = await context.loaders.publishedContent.load(input.id);
|
|
145
|
+
if (!row) throw ManabloxError.notFound('content.notFound', { id: input.id });
|
|
146
|
+
return serializeContent(row, context, parseExpand(input.expand));
|
|
147
|
+
}),
|
|
148
|
+
|
|
149
|
+
byPermalink: base
|
|
150
|
+
.route({
|
|
151
|
+
method: 'GET',
|
|
152
|
+
path: '/permalink/{+path}',
|
|
153
|
+
summary: 'Resolve a URL path to a document',
|
|
154
|
+
tags: ['content'],
|
|
155
|
+
})
|
|
156
|
+
.input(
|
|
157
|
+
z.object({
|
|
158
|
+
path: z.string().max(2000),
|
|
159
|
+
locale: z.string().max(10).optional(),
|
|
160
|
+
expand: expandArg,
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
.output(contentSchema)
|
|
164
|
+
.handler(async ({ input, context }) => {
|
|
165
|
+
const permalink = input.path.replace(/^\/+|\/+$/g, '');
|
|
166
|
+
const row = await context.repos.content.findByPermalink(
|
|
167
|
+
context.spaceId,
|
|
168
|
+
input.locale ?? context.locale,
|
|
169
|
+
permalink,
|
|
170
|
+
true,
|
|
171
|
+
);
|
|
172
|
+
if (!row) throw ManabloxError.notFound('content.notFound', { permalink });
|
|
173
|
+
return serializeContent(row, context, parseExpand(input.expand));
|
|
174
|
+
}),
|
|
175
|
+
|
|
176
|
+
menu: base
|
|
177
|
+
.route({ method: 'GET', path: '/menu', summary: 'The navigation tree', tags: ['content'] })
|
|
178
|
+
.input(
|
|
179
|
+
z.object({
|
|
180
|
+
rootId: uuid.optional(),
|
|
181
|
+
locale: z.string().max(10).optional(),
|
|
182
|
+
expand: expandArg,
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
.output(z.object({ items: z.array(contentSchema) }))
|
|
186
|
+
.handler(async ({ input, context }) => {
|
|
187
|
+
const page = await context.repos.content.list(
|
|
188
|
+
{
|
|
189
|
+
spaceId: context.spaceId,
|
|
190
|
+
locale: input.locale ?? context.locale,
|
|
191
|
+
visibleInMenu: true,
|
|
192
|
+
...(input.rootId ? { under: input.rootId } : {}),
|
|
193
|
+
},
|
|
194
|
+
{ limit: 500, offset: 0 },
|
|
195
|
+
[{ by: 'position', direction: 'asc' }],
|
|
196
|
+
true,
|
|
197
|
+
);
|
|
198
|
+
return { items: await serializeContents(page.items, context, parseExpand(input.expand)) };
|
|
199
|
+
}),
|
|
200
|
+
|
|
201
|
+
asset: base
|
|
202
|
+
.route({
|
|
203
|
+
method: 'GET',
|
|
204
|
+
path: '/assets/{id}',
|
|
205
|
+
summary: 'Fetch asset metadata',
|
|
206
|
+
tags: ['media'],
|
|
207
|
+
})
|
|
208
|
+
.input(z.object({ id: uuid }))
|
|
209
|
+
.output(assetSchema)
|
|
210
|
+
.handler(async ({ input, context }) => {
|
|
211
|
+
// Space-scoped and publication-gated by the loader the public instance builds.
|
|
212
|
+
const asset = await context.loaders.asset.load(input.id);
|
|
213
|
+
if (!asset) throw ManabloxError.notFound('asset.notFound', { id: input.id });
|
|
214
|
+
return serializeAsset(asset, context);
|
|
215
|
+
}),
|
|
216
|
+
|
|
217
|
+
types: base
|
|
218
|
+
.route({
|
|
219
|
+
method: 'GET',
|
|
220
|
+
path: '/types',
|
|
221
|
+
summary: 'The space content model, for SDK type generation',
|
|
222
|
+
tags: ['schema'],
|
|
223
|
+
})
|
|
224
|
+
.input(z.object({}).optional())
|
|
225
|
+
.output(
|
|
226
|
+
z.object({
|
|
227
|
+
types: z.array(
|
|
228
|
+
z.object({
|
|
229
|
+
name: z.string(),
|
|
230
|
+
label: z.string(),
|
|
231
|
+
kind: z.enum(['content', 'block']),
|
|
232
|
+
fields: z.array(
|
|
233
|
+
z.object({
|
|
234
|
+
name: z.string(),
|
|
235
|
+
type: z.string(),
|
|
236
|
+
required: z.boolean(),
|
|
237
|
+
list: z.boolean(),
|
|
238
|
+
/** `scalar` carries a GraphQL scalar name, `ref` a target, `block` neither. */
|
|
239
|
+
kind: z.enum(['scalar', 'ref', 'block']),
|
|
240
|
+
scalar: z.string().optional(),
|
|
241
|
+
target: z.enum(['content', 'asset', 'user']).optional(),
|
|
242
|
+
blockTypes: z.array(z.string()).optional(),
|
|
243
|
+
}),
|
|
244
|
+
),
|
|
245
|
+
}),
|
|
246
|
+
),
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
.handler(({ context }) => {
|
|
250
|
+
const registry = context.manablox.contentTypes;
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
types: registry.all
|
|
254
|
+
.filter((type) => type.spaceId === null || type.spaceId === context.spaceId)
|
|
255
|
+
.map((type) => ({
|
|
256
|
+
name: type.name,
|
|
257
|
+
label: type.label,
|
|
258
|
+
kind: type.kind,
|
|
259
|
+
fields: type.fields
|
|
260
|
+
// Role-gated fields are absent from every public surface, so they must be
|
|
261
|
+
// absent from the generated types too — otherwise the SDK promises a field
|
|
262
|
+
// that never arrives.
|
|
263
|
+
.filter((field) => !field.readRoles?.length)
|
|
264
|
+
.flatMap((field) => {
|
|
265
|
+
const fieldType = registry.fieldTypes.tryGet(field.type);
|
|
266
|
+
if (!fieldType) return [];
|
|
267
|
+
const spec = resolveGraphQL(fieldType, field.settings);
|
|
268
|
+
const blockTypes = blockTypeNames(context, field.settings);
|
|
269
|
+
|
|
270
|
+
return [
|
|
271
|
+
{
|
|
272
|
+
name: field.name,
|
|
273
|
+
type: field.type,
|
|
274
|
+
required: field.required ?? false,
|
|
275
|
+
list: spec.list ?? false,
|
|
276
|
+
kind: spec.type.kind,
|
|
277
|
+
...(spec.type.kind === 'scalar' ? { scalar: spec.type.name } : {}),
|
|
278
|
+
...(spec.type.kind === 'ref' ? { target: spec.type.target } : {}),
|
|
279
|
+
...(spec.type.kind === 'block' && blockTypes ? { blockTypes } : {}),
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
}),
|
|
283
|
+
})),
|
|
284
|
+
};
|
|
285
|
+
}),
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export type PublicRouter = typeof publicRouter;
|
|
289
|
+
|
|
290
|
+
/** A `blocks` field names its allowed types by id; the SDK needs their names. */
|
|
291
|
+
function blockTypeNames(context: PublicContext, settings: unknown): string[] | undefined {
|
|
292
|
+
const types = (settings as { types?: unknown } | undefined)?.types;
|
|
293
|
+
if (!Array.isArray(types)) return undefined;
|
|
294
|
+
return types
|
|
295
|
+
.filter((id): id is string => typeof id === 'string')
|
|
296
|
+
.map((id) => context.manablox.contentTypes.tryGet(id)?.name)
|
|
297
|
+
.filter((name): name is string => Boolean(name));
|
|
298
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ContentTypeDefinition,
|
|
3
|
+
fieldBlocks,
|
|
4
|
+
type Manablox,
|
|
5
|
+
resolveGraphQL,
|
|
6
|
+
} from '@manablox/core';
|
|
7
|
+
import type { AssetRow, ContentRow, UserRow } from '@manablox/db';
|
|
8
|
+
import { absoluteMediaUrl } from '@manablox/media';
|
|
9
|
+
import type { PublicContext } from './context.js';
|
|
10
|
+
|
|
11
|
+
export interface PublicAsset {
|
|
12
|
+
id: string;
|
|
13
|
+
url: string;
|
|
14
|
+
filename: string;
|
|
15
|
+
mimeType: string;
|
|
16
|
+
size: number;
|
|
17
|
+
width: number | null;
|
|
18
|
+
height: number | null;
|
|
19
|
+
alt: string | null;
|
|
20
|
+
title: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* Signed transform URLs, keyed by preset name.
|
|
23
|
+
*
|
|
24
|
+
* The signature is an HMAC of the instance's media secret, so a client cannot mint
|
|
25
|
+
* one — an unsigned transform request is a resize amplifier and is refused. That is
|
|
26
|
+
* why the URLs are served rather than constructed in the SDK.
|
|
27
|
+
*/
|
|
28
|
+
variants: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PublicUser {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
image: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PublicContent {
|
|
38
|
+
id: string;
|
|
39
|
+
type: string;
|
|
40
|
+
title: string;
|
|
41
|
+
slug: string;
|
|
42
|
+
permalink: string | null;
|
|
43
|
+
locale: string;
|
|
44
|
+
parentId: string | null;
|
|
45
|
+
publishedAt: string | null;
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
/**
|
|
48
|
+
* A plain map keyed by field name — `{ title, summary, components: [...] }`.
|
|
49
|
+
*
|
|
50
|
+
* GraphQL's per-type object is richer; REST's job is to be pleasant to consume with
|
|
51
|
+
* no schema knowledge, which a keyed map is and a tagged union is not.
|
|
52
|
+
*/
|
|
53
|
+
fields: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PublicBlock {
|
|
57
|
+
blockId: string;
|
|
58
|
+
type: string;
|
|
59
|
+
fields: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Which relation fields to inline rather than leave as ids. */
|
|
63
|
+
export type ExpandSet = ReadonlySet<string>;
|
|
64
|
+
|
|
65
|
+
export function parseExpand(raw: string | undefined): ExpandSet {
|
|
66
|
+
if (!raw) return new Set();
|
|
67
|
+
return new Set(
|
|
68
|
+
raw
|
|
69
|
+
.split(',')
|
|
70
|
+
.map((name) => name.trim())
|
|
71
|
+
.filter(Boolean),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Collected {
|
|
76
|
+
content: Set<string>;
|
|
77
|
+
asset: Set<string>;
|
|
78
|
+
user: Set<string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Resolved {
|
|
82
|
+
content: Map<string, ContentRow>;
|
|
83
|
+
asset: Map<string, AssetRow>;
|
|
84
|
+
user: Map<string, UserRow>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Serialises rows for the REST surface.
|
|
89
|
+
*
|
|
90
|
+
* Three passes, not one: collect every id the expansion will need, load each target in
|
|
91
|
+
* a single batch, then build the output synchronously. Serialising and loading in one
|
|
92
|
+
* pass would still be correct — DataLoader batches within a tick — but it would depend
|
|
93
|
+
* on nothing in the walk ever awaiting, which is exactly the kind of invariant that
|
|
94
|
+
* quietly breaks and reintroduces the N+1 this whole layer exists to avoid.
|
|
95
|
+
*/
|
|
96
|
+
export async function serializeContents(
|
|
97
|
+
rows: ContentRow[],
|
|
98
|
+
ctx: PublicContext,
|
|
99
|
+
expand: ExpandSet,
|
|
100
|
+
): Promise<PublicContent[]> {
|
|
101
|
+
const collected: Collected = { content: new Set(), asset: new Set(), user: new Set() };
|
|
102
|
+
for (const row of rows) collectRow(row, ctx.manablox, expand, collected);
|
|
103
|
+
|
|
104
|
+
const resolved = await resolveAll(collected, ctx);
|
|
105
|
+
return rows.map((row) => buildContent(row, ctx, expand, resolved));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function serializeContent(
|
|
109
|
+
row: ContentRow,
|
|
110
|
+
ctx: PublicContext,
|
|
111
|
+
expand: ExpandSet,
|
|
112
|
+
): Promise<PublicContent> {
|
|
113
|
+
const [only] = await serializeContents([row], ctx, expand);
|
|
114
|
+
// `serializeContents` maps one-to-one, so this is total; the assertion documents it.
|
|
115
|
+
return only as PublicContent;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function serializeAsset(asset: AssetRow, ctx: PublicContext): PublicAsset {
|
|
119
|
+
// Absolute: a delivery consumer is on its own origin, and a root-relative `/media/…`
|
|
120
|
+
// would resolve against the *frontend* rather than the API.
|
|
121
|
+
const base = ctx.manablox.config.server.publicUrl;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: asset.id,
|
|
125
|
+
url: absoluteMediaUrl(ctx.media.urlFor(asset), base),
|
|
126
|
+
variants: buildVariants(asset, ctx),
|
|
127
|
+
filename: asset.filename,
|
|
128
|
+
mimeType: asset.mimeType,
|
|
129
|
+
size: asset.size,
|
|
130
|
+
width: asset.width,
|
|
131
|
+
height: asset.height,
|
|
132
|
+
alt: asset.alt,
|
|
133
|
+
title: asset.title,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Each configured preset, at the format the preset itself declares. */
|
|
138
|
+
function buildVariants(asset: AssetRow, ctx: PublicContext): Record<string, string> {
|
|
139
|
+
if (!asset.mimeType.startsWith('image/')) return {};
|
|
140
|
+
|
|
141
|
+
const presets = ctx.manablox.config.media.presets;
|
|
142
|
+
const base = ctx.manablox.config.server.publicUrl;
|
|
143
|
+
|
|
144
|
+
const out: Record<string, string> = {};
|
|
145
|
+
for (const [name, preset] of Object.entries(presets)) {
|
|
146
|
+
out[name] = absoluteMediaUrl(ctx.media.urlFor(asset, name, preset.format ?? 'webp'), base);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Pass 1 — collect
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function collectRow(row: ContentRow, manablox: Manablox, expand: ExpandSet, into: Collected): void {
|
|
156
|
+
const type = manablox.contentTypes.tryGet(row.typeId);
|
|
157
|
+
if (!type) return;
|
|
158
|
+
collectFields(type, row.fields, manablox, expand, into, 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function collectFields(
|
|
162
|
+
type: ContentTypeDefinition,
|
|
163
|
+
values: Record<string, unknown>,
|
|
164
|
+
manablox: Manablox,
|
|
165
|
+
expand: ExpandSet,
|
|
166
|
+
into: Collected,
|
|
167
|
+
depth: number,
|
|
168
|
+
): void {
|
|
169
|
+
if (depth > 16) return;
|
|
170
|
+
|
|
171
|
+
for (const field of type.fields) {
|
|
172
|
+
if (field.readRoles?.length) continue;
|
|
173
|
+
const fieldType = manablox.fieldTypes.tryGet(field.type);
|
|
174
|
+
if (!fieldType) continue;
|
|
175
|
+
|
|
176
|
+
const value = values[field.name];
|
|
177
|
+
const spec = resolveGraphQL(fieldType, field.settings);
|
|
178
|
+
|
|
179
|
+
if (spec.type.kind === 'ref' && expand.has(field.name)) {
|
|
180
|
+
for (const id of toIds(value)) into[spec.type.target].add(id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (fieldType.nested) {
|
|
184
|
+
for (const block of fieldBlocks(fieldType, value, field.settings)) {
|
|
185
|
+
const blockType = manablox.contentTypes.tryGet(block.type);
|
|
186
|
+
if (blockType) {
|
|
187
|
+
collectFields(blockType, block.fields, manablox, expand, into, depth + 1);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Pass 2 — load
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
async function resolveAll(collected: Collected, ctx: PublicContext): Promise<Resolved> {
|
|
199
|
+
const [contents, assets, users] = await Promise.all([
|
|
200
|
+
loadMany(ctx.loaders.publishedContent, collected.content),
|
|
201
|
+
loadMany(ctx.loaders.asset, collected.asset),
|
|
202
|
+
loadMany(ctx.loaders.user, collected.user),
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
return { content: contents, asset: assets, user: users };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function loadMany<T extends { id: string }>(
|
|
209
|
+
loader: { loadMany: (ids: string[]) => Promise<Array<T | null | Error>> },
|
|
210
|
+
ids: Set<string>,
|
|
211
|
+
): Promise<Map<string, T>> {
|
|
212
|
+
if (ids.size === 0) return new Map();
|
|
213
|
+
const rows = await loader.loadMany([...ids]);
|
|
214
|
+
const map = new Map<string, T>();
|
|
215
|
+
for (const row of rows) {
|
|
216
|
+
if (row && !(row instanceof Error)) map.set(row.id, row);
|
|
217
|
+
}
|
|
218
|
+
return map;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Pass 3 — build
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
function buildContent(
|
|
226
|
+
row: ContentRow,
|
|
227
|
+
ctx: PublicContext,
|
|
228
|
+
expand: ExpandSet,
|
|
229
|
+
resolved: Resolved,
|
|
230
|
+
): PublicContent {
|
|
231
|
+
const type = ctx.manablox.contentTypes.tryGet(row.typeId);
|
|
232
|
+
return {
|
|
233
|
+
id: row.id,
|
|
234
|
+
type: type?.name ?? 'unknown',
|
|
235
|
+
title: row.title,
|
|
236
|
+
slug: row.slug,
|
|
237
|
+
permalink: row.permalink,
|
|
238
|
+
locale: row.locale,
|
|
239
|
+
parentId: row.parentId,
|
|
240
|
+
publishedAt: row.publishedAt ? row.publishedAt.toISOString() : null,
|
|
241
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
242
|
+
fields: type ? buildFields(type, row.fields, ctx, expand, resolved, 0) : {},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function buildFields(
|
|
247
|
+
type: ContentTypeDefinition,
|
|
248
|
+
values: Record<string, unknown>,
|
|
249
|
+
ctx: PublicContext,
|
|
250
|
+
expand: ExpandSet,
|
|
251
|
+
resolved: Resolved,
|
|
252
|
+
depth: number,
|
|
253
|
+
): Record<string, unknown> {
|
|
254
|
+
const out: Record<string, unknown> = {};
|
|
255
|
+
if (depth > 16) return out;
|
|
256
|
+
|
|
257
|
+
for (const field of type.fields) {
|
|
258
|
+
// Role-gated fields are omitted entirely, exactly as in the GraphQL schema — a
|
|
259
|
+
// surface that returned them would make the two views disagree.
|
|
260
|
+
if (field.readRoles?.length) continue;
|
|
261
|
+
|
|
262
|
+
const fieldType = ctx.manablox.fieldTypes.tryGet(field.type);
|
|
263
|
+
if (!fieldType) continue;
|
|
264
|
+
|
|
265
|
+
const value = values[field.name];
|
|
266
|
+
const spec = resolveGraphQL(fieldType, field.settings);
|
|
267
|
+
|
|
268
|
+
if (fieldType.nested) {
|
|
269
|
+
const blocks = fieldBlocks(fieldType, value, field.settings).map((block) => {
|
|
270
|
+
const blockType = ctx.manablox.contentTypes.tryGet(block.type);
|
|
271
|
+
return {
|
|
272
|
+
blockId: block.blockId,
|
|
273
|
+
type: blockType?.name ?? 'unknown',
|
|
274
|
+
fields: blockType
|
|
275
|
+
? buildFields(blockType, block.fields, ctx, expand, resolved, depth + 1)
|
|
276
|
+
: {},
|
|
277
|
+
} satisfies PublicBlock;
|
|
278
|
+
});
|
|
279
|
+
out[field.name] = spec.list === false ? (blocks[0] ?? null) : blocks;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (spec.type.kind === 'ref' && expand.has(field.name)) {
|
|
284
|
+
const ids = toIds(value);
|
|
285
|
+
const inlined = ids
|
|
286
|
+
.map((id) =>
|
|
287
|
+
inline(spec.type.kind === 'ref' ? spec.type.target : 'content', id, ctx, resolved),
|
|
288
|
+
)
|
|
289
|
+
.filter((entry) => entry !== null);
|
|
290
|
+
out[field.name] = spec.list ? inlined : (inlined[0] ?? null);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
out[field.name] = value ?? null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function inline(
|
|
301
|
+
target: 'content' | 'asset' | 'user',
|
|
302
|
+
id: string,
|
|
303
|
+
ctx: PublicContext,
|
|
304
|
+
resolved: Resolved,
|
|
305
|
+
): unknown {
|
|
306
|
+
if (target === 'asset') {
|
|
307
|
+
const asset = resolved.asset.get(id);
|
|
308
|
+
return asset ? serializeAsset(asset, ctx) : null;
|
|
309
|
+
}
|
|
310
|
+
if (target === 'user') {
|
|
311
|
+
const user = resolved.user.get(id);
|
|
312
|
+
return user ? ({ id: user.id, name: user.name, image: user.image } satisfies PublicUser) : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const row = resolved.content.get(id);
|
|
316
|
+
if (!row) return null;
|
|
317
|
+
// A one-level summary, not a recursive expansion: `?expand=` inlining its own
|
|
318
|
+
// relations is how a cheap parameter becomes an unbounded traversal.
|
|
319
|
+
return {
|
|
320
|
+
id: row.id,
|
|
321
|
+
type: ctx.manablox.contentTypes.tryGet(row.typeId)?.name ?? 'unknown',
|
|
322
|
+
title: row.title,
|
|
323
|
+
permalink: row.permalink,
|
|
324
|
+
locale: row.locale,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function toIds(value: unknown): string[] {
|
|
329
|
+
if (typeof value === 'string') return [value];
|
|
330
|
+
if (Array.isArray(value))
|
|
331
|
+
return value.filter((entry): entry is string => typeof entry === 'string');
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Manablox } from '@manablox/core';
|
|
2
|
+
import { builtinFieldTypes } from '@manablox/fields';
|
|
3
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
4
|
+
import type { PublicContext } from '../src/context.js';
|
|
5
|
+
import { parseExpand, serializeContents } from '../src/serialize.js';
|
|
6
|
+
|
|
7
|
+
const SPACE = '11111111-1111-4111-8111-111111111111';
|
|
8
|
+
const ASSET = '22222222-2222-4222-8222-222222222222';
|
|
9
|
+
const RELATED = '33333333-3333-4333-8333-333333333333';
|
|
10
|
+
|
|
11
|
+
let manablox: Manablox;
|
|
12
|
+
let loads: string[];
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
manablox = new Manablox({
|
|
16
|
+
database: { url: 'postgres://unused' },
|
|
17
|
+
auth: { secret: 'test' },
|
|
18
|
+
fieldTypes: builtinFieldTypes,
|
|
19
|
+
logLevel: 'silent',
|
|
20
|
+
contentTypes: [
|
|
21
|
+
{
|
|
22
|
+
name: 'teaser',
|
|
23
|
+
kind: 'block',
|
|
24
|
+
fields: [
|
|
25
|
+
{ name: 'headline', type: 'string' },
|
|
26
|
+
{ name: 'image', type: 'asset' },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'page',
|
|
31
|
+
fields: [
|
|
32
|
+
{ name: 'summary', type: 'string' },
|
|
33
|
+
{ name: 'hero', type: 'asset' },
|
|
34
|
+
{ name: 'related', type: 'content', settings: { multiple: true, types: [] } },
|
|
35
|
+
{ name: 'secret', type: 'string', readRoles: ['admin'] },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
await manablox.init();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const asset = (id: string) => ({
|
|
44
|
+
id,
|
|
45
|
+
spaceId: SPACE,
|
|
46
|
+
filename: 'x.jpg',
|
|
47
|
+
name: 'x',
|
|
48
|
+
mimeType: 'image/jpeg',
|
|
49
|
+
size: 1,
|
|
50
|
+
width: 10,
|
|
51
|
+
height: 10,
|
|
52
|
+
alt: 'alt',
|
|
53
|
+
title: null,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function context(): PublicContext {
|
|
57
|
+
loads = [];
|
|
58
|
+
const loader = <T>(build: (id: string) => T | null, label: string) => ({
|
|
59
|
+
load: async (id: string) => {
|
|
60
|
+
loads.push(`${label}:1`);
|
|
61
|
+
return build(id);
|
|
62
|
+
},
|
|
63
|
+
loadMany: async (ids: string[]) => {
|
|
64
|
+
// One entry per *batch*, so the test can assert batching rather than call count.
|
|
65
|
+
loads.push(`${label}:${ids.length}`);
|
|
66
|
+
return ids.map(build);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
manablox,
|
|
72
|
+
repos: {} as never,
|
|
73
|
+
media: { urlFor: (row: { id: string }) => `https://cdn.test/${row.id}` } as never,
|
|
74
|
+
loaders: {
|
|
75
|
+
asset: loader((id) => asset(id), 'asset'),
|
|
76
|
+
user: loader(() => null, 'user'),
|
|
77
|
+
publishedContent: loader(
|
|
78
|
+
(id) => ({
|
|
79
|
+
id,
|
|
80
|
+
typeId: manablox.contentTypes.getByName('page').id,
|
|
81
|
+
title: 'Related',
|
|
82
|
+
permalink: 'related',
|
|
83
|
+
locale: 'en',
|
|
84
|
+
}),
|
|
85
|
+
'content',
|
|
86
|
+
),
|
|
87
|
+
content: loader(() => null, 'draft'),
|
|
88
|
+
children: loader(() => [], 'children'),
|
|
89
|
+
} as never,
|
|
90
|
+
spaceId: SPACE,
|
|
91
|
+
locale: 'en',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const row = (over: Record<string, unknown> = {}) =>
|
|
96
|
+
({
|
|
97
|
+
id: 'c1',
|
|
98
|
+
spaceId: SPACE,
|
|
99
|
+
typeId: manablox.contentTypes.getByName('page').id,
|
|
100
|
+
title: 'A page',
|
|
101
|
+
slug: 'a-page',
|
|
102
|
+
permalink: 'a-page',
|
|
103
|
+
locale: 'en',
|
|
104
|
+
parentId: null,
|
|
105
|
+
publishedAt: new Date('2026-01-01T00:00:00Z'),
|
|
106
|
+
updatedAt: new Date('2026-01-02T00:00:00Z'),
|
|
107
|
+
fields: { summary: 'hello', hero: ASSET, related: [RELATED], secret: 'nope' },
|
|
108
|
+
...over,
|
|
109
|
+
}) as never;
|
|
110
|
+
|
|
111
|
+
describe('serializer', () => {
|
|
112
|
+
it('returns fields as a plain map keyed by field name', async () => {
|
|
113
|
+
const [result] = await serializeContents([row()], context(), parseExpand(undefined));
|
|
114
|
+
expect(result?.fields).toEqual({ summary: 'hello', hero: ASSET, related: [RELATED] });
|
|
115
|
+
expect(result?.type).toBe('page');
|
|
116
|
+
expect(result?.publishedAt).toBe('2026-01-01T00:00:00.000Z');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('omits role-gated fields', async () => {
|
|
120
|
+
const [result] = await serializeContents([row()], context(), parseExpand(undefined));
|
|
121
|
+
expect(result?.fields).not.toHaveProperty('secret');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('inlines only the relations named in expand', async () => {
|
|
125
|
+
const [result] = await serializeContents([row()], context(), parseExpand('hero'));
|
|
126
|
+
expect(result?.fields.hero).toMatchObject({ id: ASSET, url: `https://cdn.test/${ASSET}` });
|
|
127
|
+
// `related` was not requested, so it stays a list of ids.
|
|
128
|
+
expect(result?.fields.related).toEqual([RELATED]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('loads each relation target in one batch however many rows there are', async () => {
|
|
132
|
+
const rows = Array.from({ length: 50 }, (_, i) => row({ id: `c${i}` }));
|
|
133
|
+
await serializeContents(rows, context(), parseExpand('hero,related'));
|
|
134
|
+
|
|
135
|
+
// One asset batch and one content batch — not fifty of each.
|
|
136
|
+
expect(loads.filter((entry) => entry.startsWith('asset:'))).toEqual(['asset:1']);
|
|
137
|
+
expect(loads.filter((entry) => entry.startsWith('content:'))).toEqual(['content:1']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('walks into blocks', async () => {
|
|
141
|
+
const teaser = manablox.contentTypes.getByName('teaser');
|
|
142
|
+
const withBlocks = row({
|
|
143
|
+
typeId: manablox.contentTypes.getByName('page').id,
|
|
144
|
+
fields: {
|
|
145
|
+
summary: 's',
|
|
146
|
+
hero: null,
|
|
147
|
+
related: [],
|
|
148
|
+
blocks: [{ blockId: 'b1', type: teaser.id, fields: { headline: 'Hi', image: ASSET } }],
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// `page` has no blocks field, so nothing should appear — the walker must not invent
|
|
153
|
+
// fields the content type does not declare.
|
|
154
|
+
const [result] = await serializeContents([withBlocks], context(), parseExpand(undefined));
|
|
155
|
+
expect(result?.fields).not.toHaveProperty('blocks');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('inlines a content relation as a summary, not a recursive expansion', async () => {
|
|
159
|
+
const [result] = await serializeContents([row()], context(), parseExpand('related'));
|
|
160
|
+
const [related] = result?.fields.related as Array<Record<string, unknown>>;
|
|
161
|
+
expect(related).toEqual({
|
|
162
|
+
id: RELATED,
|
|
163
|
+
type: 'page',
|
|
164
|
+
title: 'Related',
|
|
165
|
+
permalink: 'related',
|
|
166
|
+
locale: 'en',
|
|
167
|
+
});
|
|
168
|
+
expect(related).not.toHaveProperty('fields');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('parseExpand', () => {
|
|
173
|
+
it('accepts a comma list and ignores blanks', () => {
|
|
174
|
+
expect([...parseExpand('hero, author,,')]).toEqual(['hero', 'author']);
|
|
175
|
+
expect(parseExpand(undefined).size).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "@manablox/config-typescript/library.json", "include": ["src", "test"] }
|