@manablox/api-public 0.1.0 → 0.2.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/README.md +19 -0
- package/package.json +6 -6
- package/src/context.ts +2 -1
- package/src/router.ts +119 -52
- package/src/serialize.ts +14 -1
- package/test/serialize.test.ts +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# `@manablox/api-public`
|
|
2
|
+
|
|
3
|
+
The delivery REST API under `/v1`: an oRPC router with no principal in its context — the read-only guarantee is a property of the type — plus the serialiser that inlines relations on `expand` and the `/types` endpoint the SDK generates types from.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
- `publicRouter` / `PublicRouter`
|
|
8
|
+
- `PublicContext`
|
|
9
|
+
- `serializeContent`, `serializeContents`, `serializeAsset`, `parseExpand`
|
|
10
|
+
|
|
11
|
+
## Depends on
|
|
12
|
+
|
|
13
|
+
@manablox/services, @orpc/server, zod
|
|
14
|
+
|
|
15
|
+
## Test
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm --filter @manablox/api-public test
|
|
19
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manablox/api-public",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -11,16 +11,16 @@
|
|
|
11
11
|
"main": "./src/index.ts",
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@manablox/core": "0.
|
|
15
|
-
"@manablox/db": "0.
|
|
16
|
-
"@manablox/media": "0.
|
|
17
|
-
"@manablox/services": "0.
|
|
14
|
+
"@manablox/core": "0.2.0",
|
|
15
|
+
"@manablox/db": "0.2.0",
|
|
16
|
+
"@manablox/media": "0.2.0",
|
|
17
|
+
"@manablox/services": "0.2.0",
|
|
18
18
|
"@orpc/server": "^1.15.0",
|
|
19
19
|
"zod": "^4.5.4"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@manablox/config-typescript": "0.0.0",
|
|
23
|
-
"@manablox/fields": "0.
|
|
23
|
+
"@manablox/fields": "0.2.0",
|
|
24
24
|
"@types/node": "^26.4.1",
|
|
25
25
|
"typescript": "^7.0.2",
|
|
26
26
|
"vitest": "^5.0.0"
|
package/src/context.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Manablox } from '@manablox/core';
|
|
2
2
|
import type { Repositories } from '@manablox/db';
|
|
3
3
|
import type { MediaService } from '@manablox/media';
|
|
4
|
-
import type { Loaders } from '@manablox/services';
|
|
4
|
+
import type { Loaders, MenuService } from '@manablox/services';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The delivery context.
|
|
@@ -15,6 +15,7 @@ export interface PublicContext {
|
|
|
15
15
|
manablox: Manablox;
|
|
16
16
|
repos: Repositories;
|
|
17
17
|
media: MediaService;
|
|
18
|
+
menus: MenuService;
|
|
18
19
|
loaders: Loaders;
|
|
19
20
|
/** The one space this instance serves. Never taken from the request. */
|
|
20
21
|
spaceId: string;
|
package/src/router.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { ManabloxError, resolveGraphQL } from '@manablox/core';
|
|
1
|
+
import { ManabloxError, resolveGraphQL, TRANSPORT_CODE } from '@manablox/core';
|
|
2
|
+
import type { ContentRow } from '@manablox/db';
|
|
3
|
+
import { type PublicMenuItem, toPublicListQuery } from '@manablox/services';
|
|
2
4
|
import { ORPCError, os } from '@orpc/server';
|
|
3
5
|
import { z } from 'zod';
|
|
4
6
|
import type { PublicContext } from './context.js';
|
|
@@ -22,18 +24,10 @@ const base = os.$context<PublicContext>().use(async ({ next }) => {
|
|
|
22
24
|
return await next();
|
|
23
25
|
} catch (error) {
|
|
24
26
|
if (!ManabloxError.is(error)) throw error;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 } });
|
|
27
|
+
throw new ORPCError(TRANSPORT_CODE[error.kind], {
|
|
28
|
+
message: error.key,
|
|
29
|
+
data: { key: error.key },
|
|
30
|
+
});
|
|
37
31
|
}
|
|
38
32
|
});
|
|
39
33
|
|
|
@@ -67,9 +61,39 @@ const assetSchema: z.ZodType<PublicAsset> = z.object({
|
|
|
67
61
|
height: z.number().nullable(),
|
|
68
62
|
alt: z.string().nullable(),
|
|
69
63
|
title: z.string().nullable(),
|
|
64
|
+
focalPoint: z.object({ x: z.number(), y: z.number() }).nullable(),
|
|
65
|
+
crop: z
|
|
66
|
+
.object({ left: z.number(), top: z.number(), width: z.number(), height: z.number() })
|
|
67
|
+
.nullable(),
|
|
70
68
|
variants: z.record(z.string(), z.string()),
|
|
71
69
|
});
|
|
72
70
|
|
|
71
|
+
/** A menu entry for REST: the document inlined, sub-entries nested. */
|
|
72
|
+
interface PublicMenuEntry {
|
|
73
|
+
id: string;
|
|
74
|
+
label: string;
|
|
75
|
+
url: string | null;
|
|
76
|
+
content: PublicContent | null;
|
|
77
|
+
children: PublicMenuEntry[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const menuItemSchema: z.ZodType<PublicMenuEntry> = z.lazy(() =>
|
|
81
|
+
z.object({
|
|
82
|
+
id: z.string(),
|
|
83
|
+
label: z.string(),
|
|
84
|
+
url: z.string().nullable(),
|
|
85
|
+
content: contentSchema.nullable(),
|
|
86
|
+
children: z.array(menuItemSchema),
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const menuSchema = z.object({
|
|
91
|
+
id: z.string(),
|
|
92
|
+
name: z.string(),
|
|
93
|
+
machineName: z.string(),
|
|
94
|
+
items: z.array(menuItemSchema),
|
|
95
|
+
});
|
|
96
|
+
|
|
73
97
|
const listSchema = z.object({
|
|
74
98
|
items: z.array(contentSchema),
|
|
75
99
|
total: z.number(),
|
|
@@ -103,22 +127,15 @@ export const publicRouter = {
|
|
|
103
127
|
)
|
|
104
128
|
.output(listSchema)
|
|
105
129
|
.handler(async ({ input, context }) => {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
:
|
|
109
|
-
|
|
130
|
+
const query = toPublicListQuery(context.manablox.contentTypes, input, {
|
|
131
|
+
spaceId: context.spaceId,
|
|
132
|
+
locale: context.locale,
|
|
133
|
+
});
|
|
134
|
+
// The published projection, always. There is no other option on this router.
|
|
110
135
|
const page = await context.repos.content.list(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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.
|
|
136
|
+
query.filter,
|
|
137
|
+
query.pagination,
|
|
138
|
+
query.sorts,
|
|
122
139
|
true,
|
|
123
140
|
);
|
|
124
141
|
|
|
@@ -161,41 +178,66 @@ export const publicRouter = {
|
|
|
161
178
|
}),
|
|
162
179
|
)
|
|
163
180
|
.output(contentSchema)
|
|
164
|
-
.handler(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
181
|
+
.handler(({ input, context }) => resolvePermalink(input.path, input, context)),
|
|
182
|
+
|
|
183
|
+
// `{+path}` needs at least one character, so the empty path — the space's home page,
|
|
184
|
+
// which the repository resolves through `settings.homeContentId` — gets a route of
|
|
185
|
+
// its own. The SDK calls it for `byPermalink('/')`.
|
|
186
|
+
home: base
|
|
187
|
+
.route({
|
|
188
|
+
method: 'GET',
|
|
189
|
+
path: '/permalink',
|
|
190
|
+
summary: "Resolve the space's home page",
|
|
191
|
+
tags: ['content'],
|
|
192
|
+
})
|
|
193
|
+
.input(z.object({ locale: z.string().max(10).optional(), expand: expandArg }))
|
|
194
|
+
.output(contentSchema)
|
|
195
|
+
.handler(({ input, context }) => resolvePermalink('', input, context)),
|
|
175
196
|
|
|
176
197
|
menu: base
|
|
177
|
-
.route({
|
|
198
|
+
.route({
|
|
199
|
+
method: 'GET',
|
|
200
|
+
path: '/menus/{name}',
|
|
201
|
+
summary: 'A navigation menu by name',
|
|
202
|
+
tags: ['content'],
|
|
203
|
+
})
|
|
178
204
|
.input(
|
|
179
205
|
z.object({
|
|
180
|
-
|
|
206
|
+
name: z
|
|
207
|
+
.string()
|
|
208
|
+
.regex(/^[a-z][a-z0-9_-]*$/)
|
|
209
|
+
.max(64),
|
|
181
210
|
locale: z.string().max(10).optional(),
|
|
182
211
|
expand: expandArg,
|
|
183
212
|
}),
|
|
184
213
|
)
|
|
185
|
-
.output(
|
|
214
|
+
.output(menuSchema)
|
|
186
215
|
.handler(async ({ input, context }) => {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
visibleInMenu: true,
|
|
192
|
-
...(input.rootId ? { under: input.rootId } : {}),
|
|
193
|
-
},
|
|
194
|
-
{ limit: 500, offset: 0 },
|
|
195
|
-
[{ by: 'position', direction: 'asc' }],
|
|
216
|
+
const menu = await context.menus.resolve(
|
|
217
|
+
context.spaceId,
|
|
218
|
+
input.name,
|
|
219
|
+
input.locale ?? context.locale,
|
|
196
220
|
true,
|
|
197
221
|
);
|
|
198
|
-
|
|
222
|
+
if (!menu) throw ManabloxError.notFound('menu.notFound', { id: input.name });
|
|
223
|
+
|
|
224
|
+
// One batch for every document in the tree, then the tree is rebuilt around them.
|
|
225
|
+
const rows = collectMenuRows(menu.items);
|
|
226
|
+
const serialised = await serializeContents(rows, context, parseExpand(input.expand));
|
|
227
|
+
const byId = new Map(rows.map((row, index) => [row.id, serialised[index] as PublicContent]));
|
|
228
|
+
const toEntry = (item: PublicMenuItem): PublicMenuEntry => ({
|
|
229
|
+
id: item.id,
|
|
230
|
+
label: item.label,
|
|
231
|
+
url: item.url,
|
|
232
|
+
content: item.content ? (byId.get(item.content.id) ?? null) : null,
|
|
233
|
+
children: item.children.map(toEntry),
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
id: menu.id,
|
|
237
|
+
name: menu.name,
|
|
238
|
+
machineName: menu.machineName,
|
|
239
|
+
items: menu.items.map(toEntry),
|
|
240
|
+
};
|
|
199
241
|
}),
|
|
200
242
|
|
|
201
243
|
asset: base
|
|
@@ -296,3 +338,28 @@ function blockTypeNames(context: PublicContext, settings: unknown): string[] | u
|
|
|
296
338
|
.map((id) => context.manablox.contentTypes.tryGet(id)?.name)
|
|
297
339
|
.filter((name): name is string => Boolean(name));
|
|
298
340
|
}
|
|
341
|
+
|
|
342
|
+
function collectMenuRows(items: PublicMenuItem[]): ContentRow[] {
|
|
343
|
+
const out: ContentRow[] = [];
|
|
344
|
+
for (const item of items) {
|
|
345
|
+
if (item.content) out.push(item.content);
|
|
346
|
+
out.push(...collectMenuRows(item.children));
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function resolvePermalink(
|
|
352
|
+
path: string,
|
|
353
|
+
input: { locale?: string | undefined; expand?: string | undefined },
|
|
354
|
+
context: PublicContext,
|
|
355
|
+
): Promise<PublicContent> {
|
|
356
|
+
const permalink = path.replace(/^\/+|\/+$/g, '');
|
|
357
|
+
const row = await context.repos.content.findByPermalink(
|
|
358
|
+
context.spaceId,
|
|
359
|
+
input.locale ?? context.locale,
|
|
360
|
+
permalink,
|
|
361
|
+
true,
|
|
362
|
+
);
|
|
363
|
+
if (!row) throw ManabloxError.notFound('content.notFound', { permalink });
|
|
364
|
+
return serializeContent(row, context, parseExpand(input.expand));
|
|
365
|
+
}
|
package/src/serialize.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type AssetCrop,
|
|
2
3
|
type ContentTypeDefinition,
|
|
4
|
+
type FocalPoint,
|
|
3
5
|
fieldBlocks,
|
|
4
6
|
type Manablox,
|
|
5
7
|
resolveGraphQL,
|
|
6
8
|
} from '@manablox/core';
|
|
7
9
|
import type { AssetRow, ContentRow, UserRow } from '@manablox/db';
|
|
8
|
-
import { absoluteMediaUrl } from '@manablox/media';
|
|
10
|
+
import { absoluteMediaUrl, readImageEdits } from '@manablox/media';
|
|
9
11
|
import type { PublicContext } from './context.js';
|
|
10
12
|
|
|
11
13
|
export interface PublicAsset {
|
|
@@ -18,6 +20,14 @@ export interface PublicAsset {
|
|
|
18
20
|
height: number | null;
|
|
19
21
|
alt: string | null;
|
|
20
22
|
title: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Where the subject is, as fractions of the (cropped) image, or `null` for centred.
|
|
25
|
+
* Variants already honour it; it is here for a frontend that renders the original
|
|
26
|
+
* itself, e.g. as CSS `object-position`.
|
|
27
|
+
*/
|
|
28
|
+
focalPoint: FocalPoint | null;
|
|
29
|
+
/** The editor's crop in the original's pixels, or `null`. Variants are cut to it. */
|
|
30
|
+
crop: AssetCrop | null;
|
|
21
31
|
/**
|
|
22
32
|
* Signed transform URLs, keyed by preset name.
|
|
23
33
|
*
|
|
@@ -119,6 +129,7 @@ export function serializeAsset(asset: AssetRow, ctx: PublicContext): PublicAsset
|
|
|
119
129
|
// Absolute: a delivery consumer is on its own origin, and a root-relative `/media/…`
|
|
120
130
|
// would resolve against the *frontend* rather than the API.
|
|
121
131
|
const base = ctx.manablox.config.server.publicUrl;
|
|
132
|
+
const edits = readImageEdits(asset.meta);
|
|
122
133
|
|
|
123
134
|
return {
|
|
124
135
|
id: asset.id,
|
|
@@ -131,6 +142,8 @@ export function serializeAsset(asset: AssetRow, ctx: PublicContext): PublicAsset
|
|
|
131
142
|
height: asset.height,
|
|
132
143
|
alt: asset.alt,
|
|
133
144
|
title: asset.title,
|
|
145
|
+
focalPoint: edits.focalPoint ?? null,
|
|
146
|
+
crop: edits.crop ?? null,
|
|
134
147
|
};
|
|
135
148
|
}
|
|
136
149
|
|
package/test/serialize.test.ts
CHANGED