@unifiedcommerce/core 0.4.0 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unifiedcommerce/core",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -47,6 +47,8 @@ export interface EntityConfig {
47
47
  variants: EntityVariantConfig;
48
48
  fulfillment: string;
49
49
  hooks?: EntityHooks;
50
+ /** Optional URL alias. Generates ergonomic CRUD routes at `/api/{alias}` that delegate to the catalog service with `type` pre-injected. E.g., `alias: "products"` creates `GET /api/products`, `POST /api/products`, etc. */
51
+ alias?: string;
50
52
  }
51
53
 
52
54
  /**
@@ -17,6 +17,7 @@ import { searchRoutes } from "./routes/search.js";
17
17
  import { auditRoutes } from "./routes/audit.js";
18
18
  import { adminJobRoutes } from "./routes/admin-jobs.js";
19
19
  import { customerRoutes } from "./routes/customers.js";
20
+ import { entityAliasRoutes } from "./routes/entity-aliases.js";
20
21
 
21
22
  export function createRestRoutes(kernel: Kernel) {
22
23
  const router = new OpenAPIHono<AppEnv>({
@@ -66,6 +67,38 @@ export function createRestRoutes(kernel: Kernel) {
66
67
  router.route("/audit", auditRoutes(kernel));
67
68
  router.route("/admin", adminJobRoutes(kernel));
68
69
 
70
+ // ─── Entity alias routes (e.g., /api/products → /api/catalog/entities?type=product) ──
71
+ for (const aliasRouter of entityAliasRoutes(kernel)) {
72
+ const aliasPath = (aliasRouter as unknown as Record<string, string>).__aliasPath;
73
+ if (aliasPath) {
74
+ router.route(`/${aliasPath}`, aliasRouter);
75
+ }
76
+ }
77
+
78
+ // ─── Static shortcut aliases ───────────────────────────────
79
+ // /api/categories → forwards to /api/catalog/categories
80
+ router.all("/categories/*", (c) => {
81
+ const url = new URL(c.req.url);
82
+ url.pathname = url.pathname.replace("/api/categories", "/api/catalog/categories");
83
+ return router.fetch(new Request(url.toString(), c.req.raw));
84
+ });
85
+ router.all("/categories", (c) => {
86
+ const url = new URL(c.req.url);
87
+ url.pathname = "/api/catalog/categories";
88
+ return router.fetch(new Request(url.toString(), c.req.raw));
89
+ });
90
+ // /api/brands → forwards to /api/catalog/brands
91
+ router.all("/brands/*", (c) => {
92
+ const url = new URL(c.req.url);
93
+ url.pathname = url.pathname.replace("/api/brands", "/api/catalog/brands");
94
+ return router.fetch(new Request(url.toString(), c.req.raw));
95
+ });
96
+ router.all("/brands", (c) => {
97
+ const url = new URL(c.req.url);
98
+ url.pathname = "/api/catalog/brands";
99
+ return router.fetch(new Request(url.toString(), c.req.raw));
100
+ });
101
+
69
102
  // API Reference (Scalar) — disabled in production unless config.exposeOpenApiSpec is true
70
103
  const exposeSpec = kernel.config.exposeOpenApiSpec ?? (process.env.NODE_ENV !== "production");
71
104
  if (exposeSpec) {
@@ -0,0 +1,346 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import { z, createRoute } from "@hono/zod-openapi";
3
+ import type { Kernel } from "../../../runtime/kernel.js";
4
+ import type { CreateEntityInput, UpdateEntityInput } from "../../../modules/catalog/schemas.js";
5
+ import {
6
+ type AppEnv,
7
+ mapErrorToResponse,
8
+ mapErrorToStatus,
9
+ parseInclude,
10
+ parsePagination,
11
+ parseSort,
12
+ } from "../utils.js";
13
+ import { errorResponses } from "../schemas/shared.js";
14
+
15
+ /**
16
+ * Generate ergonomic alias routes for an entity type.
17
+ *
18
+ * E.g., if config has `entities.product.alias = "products"`, this creates:
19
+ * GET /api/products
20
+ * GET /api/products/:idOrSlug
21
+ * POST /api/products
22
+ * PATCH /api/products/:id
23
+ * DELETE /api/products/:id
24
+ * POST /api/products/:id/publish
25
+ * POST /api/products/:id/archive
26
+ * GET /api/products/:id/variants
27
+ * POST /api/products/:id/variants
28
+ * GET /api/products/:id/options
29
+ * GET /api/products/:id/attributes/:locale
30
+ * PUT /api/products/:id/attributes/:locale
31
+ *
32
+ * All delegate to the same catalog service with `type` pre-injected.
33
+ */
34
+ export function entityAliasRoutes(kernel: Kernel): OpenAPIHono<AppEnv>[] {
35
+ const routers: OpenAPIHono<AppEnv>[] = [];
36
+ const entities = kernel.config.entities ?? {};
37
+
38
+ for (const [entityType, entityConfig] of Object.entries(entities)) {
39
+ if (!entityConfig.alias) continue;
40
+
41
+ const alias = entityConfig.alias;
42
+ const tag = alias.charAt(0).toUpperCase() + alias.slice(1);
43
+ const router = new OpenAPIHono<AppEnv>();
44
+
45
+ // ─── LIST ─────────────────────────────────────────────────
46
+ const listRoute = createRoute({
47
+ method: "get",
48
+ path: "/",
49
+ tags: [tag],
50
+ summary: `List ${alias}`,
51
+ description: `Alias for GET /api/catalog/entities?type=${entityType}`,
52
+ request: {
53
+ query: z.object({
54
+ status: z.string().optional(),
55
+ category: z.string().optional(),
56
+ brand: z.string().optional(),
57
+ include: z.string().optional(),
58
+ sort: z.string().optional(),
59
+ page: z.string().optional(),
60
+ limit: z.string().optional(),
61
+ }),
62
+ },
63
+ responses: {
64
+ 200: {
65
+ content: { "application/json": { schema: z.object({ data: z.array(z.record(z.string(), z.unknown())), meta: z.record(z.string(), z.unknown()) }) } },
66
+ description: `${tag} list`,
67
+ },
68
+ },
69
+ });
70
+
71
+ // @ts-expect-error -- openapi handler union return type
72
+ router.openapi(listRoute, async (c) => {
73
+ const include = parseInclude(c.req.query("include"));
74
+ const pagination = parsePagination(c.req.query());
75
+ const filter: Record<string, string> = { type: entityType };
76
+ const status = c.req.query("status");
77
+ const category = c.req.query("category");
78
+ const brand = c.req.query("brand");
79
+ if (status) filter.status = status;
80
+ if (category) filter.category = category;
81
+ if (brand) filter.brand = brand;
82
+
83
+ const payload: Record<string, unknown> = { filter, pagination };
84
+ const sort = parseSort(c.req.query("sort"));
85
+ if (sort) payload.sort = sort;
86
+
87
+ const result = await kernel.services.catalog.list(payload, c.get("actor"));
88
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
89
+
90
+ let items = result.value.items;
91
+ if (include.size > 0) {
92
+ const opts = {
93
+ includeAttributes: include.has("attributes"),
94
+ includeVariants: include.has("variants"),
95
+ includeOptionTypes: include.has("optionTypes"),
96
+ includeCategories: include.has("categories"),
97
+ includeBrands: include.has("brands"),
98
+ includeMedia: include.has("media"),
99
+ includeInventory: include.has("inventory"),
100
+ includePricing: include.has("pricing"),
101
+ };
102
+ items = await Promise.all(
103
+ items.map(async (item) => {
104
+ const full = await kernel.services.catalog.getById(item.id, opts);
105
+ return full.ok ? full.value : item;
106
+ }),
107
+ );
108
+ }
109
+
110
+ return c.json({ data: items, meta: { pagination: result.value.pagination } });
111
+ });
112
+
113
+ // ─── GET BY ID/SLUG ───────────────────────────────────────
114
+ const getRoute = createRoute({
115
+ method: "get",
116
+ path: "/{idOrSlug}",
117
+ tags: [tag],
118
+ summary: `Get ${alias.slice(0, -1)} by ID or slug`,
119
+ request: {
120
+ params: z.object({ idOrSlug: z.string() }),
121
+ query: z.object({ include: z.string().optional() }),
122
+ },
123
+ responses: {
124
+ 200: { content: { "application/json": { schema: z.object({ data: z.record(z.string(), z.unknown()) }) } }, description: `${tag} detail` },
125
+ ...errorResponses,
126
+ },
127
+ });
128
+
129
+ // @ts-expect-error -- openapi handler union return type
130
+ router.openapi(getRoute, async (c) => {
131
+ const idOrSlug = c.req.valid("param").idOrSlug;
132
+ const include = parseInclude(c.req.query("include"));
133
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(idOrSlug);
134
+ const opts = {
135
+ includeAttributes: include.has("attributes"),
136
+ includeVariants: include.has("variants"),
137
+ includeOptionTypes: include.has("optionTypes"),
138
+ includeCategories: include.has("categories"),
139
+ includeBrands: include.has("brands"),
140
+ includeMedia: include.has("media"),
141
+ includeInventory: include.has("inventory"),
142
+ includePricing: include.has("pricing"),
143
+ };
144
+
145
+ const result = isUUID
146
+ ? await kernel.services.catalog.getById(idOrSlug, opts)
147
+ : await kernel.services.catalog.getBySlug(idOrSlug, opts);
148
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
149
+ return c.json({ data: result.value });
150
+ });
151
+
152
+ // ─── CREATE ───────────────────────────────────────────────
153
+ const createEntityRoute = createRoute({
154
+ method: "post",
155
+ path: "/",
156
+ tags: [tag],
157
+ summary: `Create ${alias.slice(0, -1)}`,
158
+ request: {
159
+ body: { content: { "application/json": { schema: z.object({ slug: z.string().optional(), title: z.string().optional(), description: z.string().optional(), metadata: z.record(z.string(), z.unknown()).optional() }).openapi(`Create${tag}Request`) } } },
160
+ },
161
+ responses: {
162
+ 201: { content: { "application/json": { schema: z.object({ data: z.record(z.string(), z.unknown()) }) } }, description: `${tag} created` },
163
+ ...errorResponses,
164
+ },
165
+ });
166
+
167
+ // @ts-expect-error -- openapi handler union return type
168
+ router.openapi(createEntityRoute, async (c) => {
169
+ const body = c.req.valid("json");
170
+ const input: CreateEntityInput = { type: entityType, ...body };
171
+ const result = await kernel.services.catalog.create(input, c.get("actor"));
172
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
173
+ return c.json({ data: result.value }, 201);
174
+ });
175
+
176
+ // ─── UPDATE ───────────────────────────────────────────────
177
+ const updateRoute = createRoute({
178
+ method: "patch",
179
+ path: "/{id}",
180
+ tags: [tag],
181
+ summary: `Update ${alias.slice(0, -1)}`,
182
+ request: {
183
+ params: z.object({ id: z.string().uuid() }),
184
+ body: { content: { "application/json": { schema: z.object({ slug: z.string().optional(), title: z.string().optional(), description: z.string().optional(), metadata: z.record(z.string(), z.unknown()).optional() }).openapi(`Update${tag}Request`) } } },
185
+ },
186
+ responses: {
187
+ 200: { content: { "application/json": { schema: z.object({ data: z.record(z.string(), z.unknown()) }) } }, description: `${tag} updated` },
188
+ ...errorResponses,
189
+ },
190
+ });
191
+
192
+ // @ts-expect-error -- openapi handler union return type
193
+ router.openapi(updateRoute, async (c) => {
194
+ const { id } = c.req.valid("param");
195
+ const body = c.req.valid("json");
196
+ const input: UpdateEntityInput = { ...body };
197
+ const result = await kernel.services.catalog.update(id, input, c.get("actor"));
198
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
199
+ return c.json({ data: result.value });
200
+ });
201
+
202
+ // ─── DELETE ────────────────────────────────────────────────
203
+ const deleteRoute = createRoute({
204
+ method: "delete",
205
+ path: "/{id}",
206
+ tags: [tag],
207
+ summary: `Delete ${alias.slice(0, -1)}`,
208
+ request: { params: z.object({ id: z.string().uuid() }) },
209
+ responses: {
210
+ 200: { content: { "application/json": { schema: z.object({ data: z.object({ deleted: z.literal(true) }) }) } }, description: "Deleted" },
211
+ ...errorResponses,
212
+ },
213
+ });
214
+
215
+ // @ts-expect-error -- openapi handler union return type
216
+ router.openapi(deleteRoute, async (c) => {
217
+ const { id } = c.req.valid("param");
218
+ const result = await kernel.services.catalog.delete(id, c.get("actor"));
219
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
220
+ return c.json({ data: { deleted: true as const } });
221
+ });
222
+
223
+ // ─── PUBLISH / ARCHIVE ────────────────────────────────────
224
+ for (const action of ["publish", "archive"] as const) {
225
+ const actionRoute = createRoute({
226
+ method: "post",
227
+ path: `/{id}/${action}`,
228
+ tags: [tag],
229
+ summary: `${action.charAt(0).toUpperCase() + action.slice(1)} ${alias.slice(0, -1)}`,
230
+ request: { params: z.object({ id: z.string().uuid() }) },
231
+ responses: {
232
+ 200: { content: { "application/json": { schema: z.object({ data: z.record(z.string(), z.unknown()) }) } }, description: `${tag} ${action}ed` },
233
+ ...errorResponses,
234
+ },
235
+ });
236
+
237
+ // @ts-expect-error -- openapi handler union return type
238
+ router.openapi(actionRoute, async (c) => {
239
+ const { id } = c.req.valid("param");
240
+ const result = await kernel.services.catalog[action](id, c.get("actor"));
241
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
242
+ return c.json({ data: result.value });
243
+ });
244
+ }
245
+
246
+ // ─── ATTRIBUTES ───────────────────────────────────────────
247
+ const getAttrsRoute = createRoute({
248
+ method: "get",
249
+ path: "/{id}/attributes/{locale}",
250
+ tags: [tag],
251
+ summary: `Get ${alias.slice(0, -1)} attributes`,
252
+ request: { params: z.object({ id: z.string().uuid(), locale: z.string() }) },
253
+ responses: {
254
+ 200: { content: { "application/json": { schema: z.object({ data: z.record(z.string(), z.unknown()) }) } }, description: "Attributes" },
255
+ ...errorResponses,
256
+ },
257
+ });
258
+
259
+ // @ts-expect-error -- openapi handler union return type
260
+ router.openapi(getAttrsRoute, async (c) => {
261
+ const { id, locale } = c.req.valid("param");
262
+ const result = await kernel.services.catalog.getAttributes(id, locale);
263
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
264
+ return c.json({ data: result.value });
265
+ });
266
+
267
+ const setAttrsRoute = createRoute({
268
+ method: "put",
269
+ path: "/{id}/attributes/{locale}",
270
+ tags: [tag],
271
+ summary: `Set ${alias.slice(0, -1)} attributes`,
272
+ request: {
273
+ params: z.object({ id: z.string().uuid(), locale: z.string() }),
274
+ body: { content: { "application/json": { schema: z.record(z.string(), z.unknown()).openapi(`Set${tag}AttributesRequest`) } } },
275
+ },
276
+ responses: {
277
+ 200: { content: { "application/json": { schema: z.object({ data: z.object({ updated: z.literal(true) }) }) } }, description: "Attributes set" },
278
+ ...errorResponses,
279
+ },
280
+ });
281
+
282
+ // @ts-expect-error -- openapi handler union return type
283
+ router.openapi(setAttrsRoute, async (c) => {
284
+ const { id, locale } = c.req.valid("param");
285
+ const body = c.req.valid("json");
286
+ const result = await kernel.services.catalog.setAttributes(id, locale, body);
287
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
288
+ return c.json({ data: { updated: true as const } });
289
+ });
290
+
291
+ // ─── VARIANTS ─────────────────────────────────────────────
292
+ if (entityConfig.variants.enabled) {
293
+ const createVariantRoute = createRoute({
294
+ method: "post",
295
+ path: "/{id}/variants",
296
+ tags: [tag],
297
+ summary: `Create variant for ${alias.slice(0, -1)}`,
298
+ request: {
299
+ params: z.object({ id: z.string().uuid() }),
300
+ body: { content: { "application/json": { schema: z.object({ sku: z.string().optional(), options: z.record(z.string(), z.string()).optional(), metadata: z.record(z.string(), z.unknown()).optional() }).openapi(`Create${tag}VariantRequest`) } } },
301
+ },
302
+ responses: {
303
+ 201: { content: { "application/json": { schema: z.object({ data: z.record(z.string(), z.unknown()) }) } }, description: "Variant created" },
304
+ ...errorResponses,
305
+ },
306
+ });
307
+
308
+ // @ts-expect-error -- openapi handler union return type
309
+ router.openapi(createVariantRoute, async (c) => {
310
+ const { id } = c.req.valid("param");
311
+ const body = c.req.valid("json");
312
+ const result = await kernel.services.catalog.createVariant({ entityId: id, ...body }, c.get("actor"));
313
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
314
+ return c.json({ data: result.value }, 201);
315
+ });
316
+
317
+ const listOptionsRoute = createRoute({
318
+ method: "get",
319
+ path: "/{id}/options",
320
+ tags: [tag],
321
+ summary: `List option types for ${alias.slice(0, -1)}`,
322
+ request: { params: z.object({ id: z.string().uuid() }) },
323
+ responses: {
324
+ 200: { content: { "application/json": { schema: z.object({ data: z.array(z.record(z.string(), z.unknown())) }) } }, description: "Option types" },
325
+ ...errorResponses,
326
+ },
327
+ });
328
+
329
+ // @ts-expect-error -- openapi handler union return type
330
+ router.openapi(listOptionsRoute, async (c) => {
331
+ const { id } = c.req.valid("param");
332
+ const result = await kernel.services.catalog.getById(id, { includeOptionTypes: true });
333
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
334
+ return c.json({ data: result.value.optionTypes ?? [] });
335
+ });
336
+ }
337
+
338
+ // Store alias → entityType for the tag group
339
+ (router as unknown as Record<string, string>).__aliasTag = tag;
340
+ (router as unknown as Record<string, string>).__aliasPath = alias;
341
+
342
+ routers.push(router);
343
+ }
344
+
345
+ return routers;
346
+ }
@@ -268,8 +268,15 @@ export async function createServer(config: CommerceConfig) {
268
268
  description: "Headless commerce engine REST API. Includes core and plugin endpoints.",
269
269
  },
270
270
  tags: [
271
+ // ── Entity aliases (generated from config.entities[type].alias) ──
272
+ ...Object.entries(config.entities ?? {})
273
+ .filter(([, cfg]) => cfg.alias)
274
+ .map(([type, cfg]) => {
275
+ const name = cfg.alias!.charAt(0).toUpperCase() + cfg.alias!.slice(1);
276
+ return { name, description: `Ergonomic alias for ${type} entities — CRUD, variants, attributes` };
277
+ }),
271
278
  // ── Storefront ──
272
- { name: "Catalog", description: "Products, categories, brands, variants, and option types" },
279
+ { name: "Catalog", description: "Products, categories, brands, variants, and option types (unified entity API)" },
273
280
  { name: "Search", description: "Full-text search and typeahead suggestions" },
274
281
  { name: "Pricing", description: "Base prices, price modifiers, and customer group pricing" },
275
282
  { name: "Carts", description: "Shopping cart lifecycle — create, add items, update quantities" },
@@ -289,7 +296,11 @@ export async function createServer(config: CommerceConfig) {
289
296
  });
290
297
 
291
298
  // Serve enriched spec with x-tagGroups vendor extension (Scalar/Redocly sidebar grouping)
299
+ const aliasTags = Object.values(config.entities ?? {})
300
+ .filter((cfg) => cfg.alias)
301
+ .map((cfg) => cfg.alias!.charAt(0).toUpperCase() + cfg.alias!.slice(1));
292
302
  const tagGroups = [
303
+ ...(aliasTags.length > 0 ? [{ name: "Quick Access", tags: aliasTags }] : []),
293
304
  { name: "Storefront", tags: ["Catalog", "Search", "Pricing", "Carts", "Checkout", "Promotions"] },
294
305
  { name: "Admin", tags: ["Orders", "Customers", "Inventory", "Media", "Payments"] },
295
306
  { name: "Operations", tags: ["Webhooks", "Audit", "Admin Jobs"] },