@objectstack/runtime 1.0.11 → 1.0.12
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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +10 -0
- package/dist/index.cjs +314 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -1
- package/dist/index.d.ts +49 -1
- package/dist/index.js +314 -39
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/app-plugin.ts +29 -6
- package/src/http-dispatcher.root.test.ts +58 -0
- package/src/http-dispatcher.ts +320 -42
- package/src/rest-server.ts +45 -1
package/package.json
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/runtime",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "ObjectStack Core Runtime & Query Engine",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
8
|
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
9
16
|
"dependencies": {
|
|
10
17
|
"zod": "^3.24.1",
|
|
11
|
-
"@objectstack/core": "1.0.
|
|
12
|
-
"@objectstack/spec": "1.0.
|
|
13
|
-
"@objectstack/types": "1.0.
|
|
18
|
+
"@objectstack/core": "1.0.12",
|
|
19
|
+
"@objectstack/spec": "1.0.12",
|
|
20
|
+
"@objectstack/types": "1.0.12"
|
|
14
21
|
},
|
|
15
22
|
"devDependencies": {
|
|
16
23
|
"typescript": "^5.0.0",
|
package/src/app-plugin.ts
CHANGED
|
@@ -99,22 +99,45 @@ export class AppPlugin implements Plugin {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
// Data Seeding
|
|
102
|
-
//
|
|
102
|
+
// Collect seed data from multiple locations (top-level `data` preferred, `manifest.data` for backward compat)
|
|
103
|
+
const seedDatasets: any[] = [];
|
|
104
|
+
|
|
105
|
+
// 1. Top-level `data` field (new standard location on ObjectStackDefinition)
|
|
106
|
+
if (Array.isArray(this.bundle.data)) {
|
|
107
|
+
seedDatasets.push(...this.bundle.data);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2. Legacy: `manifest.data` (backward compatibility)
|
|
103
111
|
const manifest = this.bundle.manifest || this.bundle;
|
|
104
112
|
if (manifest && Array.isArray(manifest.data)) {
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
seedDatasets.push(...manifest.data);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Resolve short object names to FQN using the package's namespace.
|
|
117
|
+
// e.g., seed `object: 'task'` in namespace 'todo' → 'todo__task'
|
|
118
|
+
// Reserved namespaces ('base', 'system') are not prefixed.
|
|
119
|
+
const namespace = (this.bundle.manifest || this.bundle)?.namespace as string | undefined;
|
|
120
|
+
const RESERVED_NS = new Set(['base', 'system']);
|
|
121
|
+
const toFQN = (name: string) => {
|
|
122
|
+
if (name.includes('__') || !namespace || RESERVED_NS.has(namespace)) return name;
|
|
123
|
+
return `${namespace}__${name}`;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (seedDatasets.length > 0) {
|
|
127
|
+
ctx.logger.info(`[AppPlugin] Found ${seedDatasets.length} seed datasets for ${appId}`);
|
|
128
|
+
for (const dataset of seedDatasets) {
|
|
107
129
|
if (dataset.object && Array.isArray(dataset.records)) {
|
|
108
|
-
|
|
130
|
+
const objectFQN = toFQN(dataset.object);
|
|
131
|
+
ctx.logger.info(`[Seeder] Seeding ${dataset.records.length} records for ${objectFQN}`);
|
|
109
132
|
for (const record of dataset.records) {
|
|
110
133
|
try {
|
|
111
134
|
// Use ObjectQL engine to insert data
|
|
112
135
|
// This ensures driver resolution and hook execution
|
|
113
136
|
// Use 'insert' which corresponds to 'create' in driver
|
|
114
|
-
await ql.insert(
|
|
137
|
+
await ql.insert(objectFQN, record);
|
|
115
138
|
} catch (err: any) {
|
|
116
139
|
// Ignore duplicate errors if needed, or log/warn
|
|
117
|
-
ctx.logger.warn(`[Seeder] Failed to insert ${
|
|
140
|
+
ctx.logger.warn(`[Seeder] Failed to insert ${objectFQN} record:`, { error: err.message });
|
|
118
141
|
}
|
|
119
142
|
}
|
|
120
143
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { HttpDispatcher } from './http-dispatcher';
|
|
4
|
+
import { ObjectKernel } from '@objectstack/core';
|
|
5
|
+
|
|
6
|
+
describe('HttpDispatcher Root Handling', () => {
|
|
7
|
+
let kernel: ObjectKernel;
|
|
8
|
+
let dispatcher: HttpDispatcher;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Mock minimal Kernel structure
|
|
12
|
+
kernel = {
|
|
13
|
+
services: {},
|
|
14
|
+
broker: {
|
|
15
|
+
call: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
context: {
|
|
18
|
+
getService: vi.fn(),
|
|
19
|
+
}
|
|
20
|
+
} as any;
|
|
21
|
+
|
|
22
|
+
dispatcher = new HttpDispatcher(kernel);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should handled GET request to root path ("") correctly', async () => {
|
|
26
|
+
const context = { request: {} };
|
|
27
|
+
const method = 'GET';
|
|
28
|
+
// MSW passes empty string when stripping base URL
|
|
29
|
+
const path = '';
|
|
30
|
+
const body = undefined;
|
|
31
|
+
const query = {};
|
|
32
|
+
|
|
33
|
+
const result = await dispatcher.dispatch(method, path, body, query, context);
|
|
34
|
+
|
|
35
|
+
expect(result.handled).toBe(true);
|
|
36
|
+
expect(result.response).toBeDefined();
|
|
37
|
+
expect(result.response?.status).toBe(200);
|
|
38
|
+
|
|
39
|
+
const data = result.response?.body?.data;
|
|
40
|
+
expect(data).toBeDefined();
|
|
41
|
+
// getDiscoveryInfo returns 'name' not 'apiName'
|
|
42
|
+
expect(data.name).toBe('ObjectOS');
|
|
43
|
+
expect(data.version).toBe('1.0.0');
|
|
44
|
+
expect(data.routes).toBeDefined();
|
|
45
|
+
// Since we passed empty prefix in dispatch code (hardcoded), routes should be relative
|
|
46
|
+
expect(data.routes.metadata).toBe('/meta');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should NOT handle POST request to root path ("")', async () => {
|
|
50
|
+
const context = { request: {} };
|
|
51
|
+
const method = 'POST';
|
|
52
|
+
const path = '';
|
|
53
|
+
|
|
54
|
+
const result = await dispatcher.dispatch(method, path, {}, {}, context);
|
|
55
|
+
|
|
56
|
+
expect(result.handled).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/http-dispatcher.ts
CHANGED
|
@@ -57,19 +57,25 @@ export class HttpDispatcher {
|
|
|
57
57
|
const hasAnalytics = !!services[CoreServiceName.enum.analytics];
|
|
58
58
|
const hasHub = !!services[CoreServiceName.enum.hub];
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
name: 'ObjectOS',
|
|
62
|
-
version: '1.0.0',
|
|
63
|
-
environment: getEnv('NODE_ENV', 'development'),
|
|
64
|
-
routes: {
|
|
60
|
+
const routes = {
|
|
65
61
|
data: `${prefix}/data`,
|
|
66
62
|
metadata: `${prefix}/meta`,
|
|
63
|
+
packages: `${prefix}/packages`,
|
|
67
64
|
auth: `${prefix}/auth`,
|
|
65
|
+
ui: `${prefix}/ui`,
|
|
68
66
|
graphql: hasGraphQL ? `${prefix}/graphql` : undefined,
|
|
69
67
|
storage: hasFiles ? `${prefix}/storage` : undefined,
|
|
70
68
|
analytics: hasAnalytics ? `${prefix}/analytics` : undefined,
|
|
71
69
|
hub: hasHub ? `${prefix}/hub` : undefined,
|
|
72
|
-
|
|
70
|
+
automation: `${prefix}/automation`,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
name: 'ObjectOS',
|
|
75
|
+
version: '1.0.0',
|
|
76
|
+
environment: getEnv('NODE_ENV', 'development'),
|
|
77
|
+
routes,
|
|
78
|
+
endpoints: routes, // Alias for backward compatibility with some clients
|
|
73
79
|
features: {
|
|
74
80
|
graphql: hasGraphQL,
|
|
75
81
|
search: hasSearch,
|
|
@@ -131,15 +137,26 @@ export class HttpDispatcher {
|
|
|
131
137
|
* Standard: /metadata/:type/:name
|
|
132
138
|
* Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
|
|
133
139
|
*/
|
|
134
|
-
async handleMetadata(path: string, context: HttpProtocolContext, method?: string, body?: any): Promise<HttpDispatcherResult> {
|
|
140
|
+
async handleMetadata(path: string, context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise<HttpDispatcherResult> {
|
|
135
141
|
const broker = this.ensureBroker();
|
|
136
142
|
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
137
143
|
|
|
138
144
|
// GET /metadata/types
|
|
139
145
|
if (parts[0] === 'types') {
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
// Try protocol service for dynamic types
|
|
147
|
+
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
148
|
+
if (protocol && typeof protocol.getMetaTypes === 'function') {
|
|
149
|
+
const result = await protocol.getMetaTypes({});
|
|
150
|
+
return { handled: true, response: this.success(result) };
|
|
151
|
+
}
|
|
152
|
+
// Fallback: ask broker for registered types
|
|
153
|
+
try {
|
|
154
|
+
const data = await broker.call('metadata.types', {}, { request: context.request });
|
|
155
|
+
return { handled: true, response: this.success(data) };
|
|
156
|
+
} catch {
|
|
157
|
+
// Last resort: hardcoded defaults
|
|
158
|
+
return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
|
|
159
|
+
}
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
// /metadata/:type/:name
|
|
@@ -172,12 +189,30 @@ export class HttpDispatcher {
|
|
|
172
189
|
|
|
173
190
|
try {
|
|
174
191
|
// Try specific calls based on type
|
|
175
|
-
if (type === 'objects') {
|
|
192
|
+
if (type === 'objects' || type === 'object') {
|
|
176
193
|
const data = await broker.call('metadata.getObject', { objectName: name }, { request: context.request });
|
|
177
194
|
return { handled: true, response: this.success(data) };
|
|
178
195
|
}
|
|
179
|
-
|
|
180
|
-
|
|
196
|
+
|
|
197
|
+
// If type is singular (e.g. 'app'), use it directly
|
|
198
|
+
// If plural (e.g. 'apps'), slice it
|
|
199
|
+
const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
|
|
200
|
+
|
|
201
|
+
// Try Protocol Service First (Preferred)
|
|
202
|
+
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
203
|
+
if (protocol && typeof protocol.getMetaItem === 'function') {
|
|
204
|
+
try {
|
|
205
|
+
const data = await protocol.getMetaItem({ type: singularType, name });
|
|
206
|
+
return { handled: true, response: this.success(data) };
|
|
207
|
+
} catch (e: any) {
|
|
208
|
+
// Protocol might throw if not found or not supported
|
|
209
|
+
// Fallback to broker?
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Generic call for other types if supported via Broker (Legacy)
|
|
214
|
+
const method = `metadata.get${this.capitalize(singularType)}`;
|
|
215
|
+
const data = await broker.call(method, { name }, { request: context.request });
|
|
181
216
|
return { handled: true, response: this.success(data) };
|
|
182
217
|
} catch (e: any) {
|
|
183
218
|
// Fallback: treat first part as object name if only 1 part (handled below)
|
|
@@ -189,27 +224,61 @@ export class HttpDispatcher {
|
|
|
189
224
|
// GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy)
|
|
190
225
|
if (parts.length === 1) {
|
|
191
226
|
const typeOrName = parts[0];
|
|
227
|
+
// Extract optional package filter from query string
|
|
228
|
+
const packageId = query?.package || undefined;
|
|
192
229
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
230
|
+
// Try protocol service first for any type
|
|
231
|
+
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
232
|
+
if (protocol && typeof protocol.getMetaItems === 'function') {
|
|
233
|
+
try {
|
|
234
|
+
const data = await protocol.getMetaItems({ type: typeOrName, packageId });
|
|
235
|
+
// Return any valid response from protocol (including empty items arrays)
|
|
236
|
+
if (data && (data.items !== undefined || Array.isArray(data))) {
|
|
237
|
+
return { handled: true, response: this.success(data) };
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Protocol doesn't know this type, fall through
|
|
241
|
+
}
|
|
202
242
|
}
|
|
203
243
|
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
244
|
+
// Try broker for the type
|
|
245
|
+
try {
|
|
246
|
+
if (typeOrName === 'objects') {
|
|
247
|
+
const data = await broker.call('metadata.objects', { packageId }, { request: context.request });
|
|
248
|
+
return { handled: true, response: this.success(data) };
|
|
249
|
+
}
|
|
250
|
+
const data = await broker.call(`metadata.${typeOrName}`, { packageId }, { request: context.request });
|
|
251
|
+
if (data !== null && data !== undefined) {
|
|
252
|
+
return { handled: true, response: this.success(data) };
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
// Broker doesn't support this action, fall through
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Legacy: /metadata/:objectName (treat as single object lookup)
|
|
259
|
+
try {
|
|
260
|
+
const data = await broker.call('metadata.getObject', { objectName: typeOrName }, { request: context.request });
|
|
261
|
+
return { handled: true, response: this.success(data) };
|
|
262
|
+
} catch (e: any) {
|
|
263
|
+
return { handled: true, response: this.error(e.message, 404) };
|
|
264
|
+
}
|
|
207
265
|
}
|
|
208
266
|
|
|
209
|
-
// GET /metadata
|
|
267
|
+
// GET /metadata — return available metadata types
|
|
210
268
|
if (parts.length === 0) {
|
|
211
|
-
|
|
212
|
-
|
|
269
|
+
// Try protocol service for dynamic types
|
|
270
|
+
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
271
|
+
if (protocol && typeof protocol.getMetaTypes === 'function') {
|
|
272
|
+
const result = await protocol.getMetaTypes({});
|
|
273
|
+
return { handled: true, response: this.success(result) };
|
|
274
|
+
}
|
|
275
|
+
// Fallback: ask broker for registered types
|
|
276
|
+
try {
|
|
277
|
+
const data = await broker.call('metadata.types', {}, { request: context.request });
|
|
278
|
+
return { handled: true, response: this.success(data) };
|
|
279
|
+
} catch {
|
|
280
|
+
return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
|
|
281
|
+
}
|
|
213
282
|
}
|
|
214
283
|
|
|
215
284
|
return { handled: false };
|
|
@@ -236,14 +305,13 @@ export class HttpDispatcher {
|
|
|
236
305
|
|
|
237
306
|
// POST /data/:object/query
|
|
238
307
|
if (action === 'query' && m === 'POST') {
|
|
308
|
+
// Spec: broker returns FindDataResponse = { object, records, total?, hasMore? }
|
|
239
309
|
const result = await broker.call('data.query', { object: objectName, ...body }, { request: context.request });
|
|
240
|
-
return { handled: true, response: this.success(result
|
|
310
|
+
return { handled: true, response: this.success(result) };
|
|
241
311
|
}
|
|
242
312
|
|
|
243
313
|
// POST /data/:object/batch
|
|
244
314
|
if (action === 'batch' && m === 'POST') {
|
|
245
|
-
// Spec complaint: forward the whole body { operation, records, options }
|
|
246
|
-
// Implementation in Kernel should handle the 'operation' field
|
|
247
315
|
const result = await broker.call('data.batch', { object: objectName, ...body }, { request: context.request });
|
|
248
316
|
return { handled: true, response: this.success(result) };
|
|
249
317
|
}
|
|
@@ -251,35 +319,39 @@ export class HttpDispatcher {
|
|
|
251
319
|
// GET /data/:object/:id
|
|
252
320
|
if (parts.length === 2 && m === 'GET') {
|
|
253
321
|
const id = parts[1];
|
|
254
|
-
|
|
255
|
-
|
|
322
|
+
// Spec: broker returns GetDataResponse = { object, id, record }
|
|
323
|
+
const result = await broker.call('data.get', { object: objectName, id, ...query }, { request: context.request });
|
|
324
|
+
return { handled: true, response: this.success(result) };
|
|
256
325
|
}
|
|
257
326
|
|
|
258
327
|
// PATCH /data/:object/:id
|
|
259
328
|
if (parts.length === 2 && m === 'PATCH') {
|
|
260
329
|
const id = parts[1];
|
|
261
|
-
|
|
262
|
-
|
|
330
|
+
// Spec: broker returns UpdateDataResponse = { object, id, record }
|
|
331
|
+
const result = await broker.call('data.update', { object: objectName, id, data: body }, { request: context.request });
|
|
332
|
+
return { handled: true, response: this.success(result) };
|
|
263
333
|
}
|
|
264
334
|
|
|
265
335
|
// DELETE /data/:object/:id
|
|
266
336
|
if (parts.length === 2 && m === 'DELETE') {
|
|
267
337
|
const id = parts[1];
|
|
268
|
-
|
|
269
|
-
|
|
338
|
+
// Spec: broker returns DeleteDataResponse = { object, id, deleted }
|
|
339
|
+
const result = await broker.call('data.delete', { object: objectName, id }, { request: context.request });
|
|
340
|
+
return { handled: true, response: this.success(result) };
|
|
270
341
|
}
|
|
271
342
|
} else {
|
|
272
343
|
// GET /data/:object (List)
|
|
273
344
|
if (m === 'GET') {
|
|
345
|
+
// Spec: broker returns FindDataResponse = { object, records, total?, hasMore? }
|
|
274
346
|
const result = await broker.call('data.query', { object: objectName, filters: query }, { request: context.request });
|
|
275
|
-
return { handled: true, response: this.success(result
|
|
347
|
+
return { handled: true, response: this.success(result) };
|
|
276
348
|
}
|
|
277
349
|
|
|
278
350
|
// POST /data/:object (Create)
|
|
279
351
|
if (m === 'POST') {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const res = this.success(
|
|
352
|
+
// Spec: broker returns CreateDataResponse = { object, id, record }
|
|
353
|
+
const result = await broker.call('data.create', { object: objectName, data: body }, { request: context.request });
|
|
354
|
+
const res = this.success(result);
|
|
283
355
|
res.status = 201;
|
|
284
356
|
return { handled: true, response: res };
|
|
285
357
|
}
|
|
@@ -321,6 +393,138 @@ export class HttpDispatcher {
|
|
|
321
393
|
return { handled: false };
|
|
322
394
|
}
|
|
323
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Handles Package Management requests
|
|
398
|
+
*
|
|
399
|
+
* REST Endpoints:
|
|
400
|
+
* - GET /packages → list all installed packages
|
|
401
|
+
* - GET /packages/:id → get a specific package
|
|
402
|
+
* - POST /packages → install a new package
|
|
403
|
+
* - DELETE /packages/:id → uninstall a package
|
|
404
|
+
* - PATCH /packages/:id/enable → enable a package
|
|
405
|
+
* - PATCH /packages/:id/disable → disable a package
|
|
406
|
+
*
|
|
407
|
+
* Uses ObjectQL SchemaRegistry directly (via the 'objectql' service)
|
|
408
|
+
* with broker fallback for backward compatibility.
|
|
409
|
+
*/
|
|
410
|
+
async handlePackages(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
411
|
+
const m = method.toUpperCase();
|
|
412
|
+
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
413
|
+
|
|
414
|
+
// Try to get SchemaRegistry from the ObjectQL service
|
|
415
|
+
const qlService = this.getObjectQLService();
|
|
416
|
+
const registry = qlService?.registry;
|
|
417
|
+
|
|
418
|
+
// If no registry available, try broker as fallback
|
|
419
|
+
if (!registry) {
|
|
420
|
+
if (this.kernel.broker) {
|
|
421
|
+
return this.handlePackagesViaBroker(parts, m, body, query, context);
|
|
422
|
+
}
|
|
423
|
+
return { handled: true, response: this.error('Package service not available', 503) };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
// GET /packages → list packages
|
|
428
|
+
if (parts.length === 0 && m === 'GET') {
|
|
429
|
+
let packages = registry.getAllPackages();
|
|
430
|
+
// Apply optional filters
|
|
431
|
+
if (query?.status) {
|
|
432
|
+
packages = packages.filter((p: any) => p.status === query.status);
|
|
433
|
+
}
|
|
434
|
+
if (query?.type) {
|
|
435
|
+
packages = packages.filter((p: any) => p.manifest?.type === query.type);
|
|
436
|
+
}
|
|
437
|
+
return { handled: true, response: this.success({ packages, total: packages.length }) };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// POST /packages → install package
|
|
441
|
+
if (parts.length === 0 && m === 'POST') {
|
|
442
|
+
const pkg = registry.installPackage(body.manifest || body, body.settings);
|
|
443
|
+
const res = this.success(pkg);
|
|
444
|
+
res.status = 201;
|
|
445
|
+
return { handled: true, response: res };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// PATCH /packages/:id/enable
|
|
449
|
+
if (parts.length === 2 && parts[1] === 'enable' && m === 'PATCH') {
|
|
450
|
+
const id = decodeURIComponent(parts[0]);
|
|
451
|
+
const pkg = registry.enablePackage(id);
|
|
452
|
+
if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
|
|
453
|
+
return { handled: true, response: this.success(pkg) };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// PATCH /packages/:id/disable
|
|
457
|
+
if (parts.length === 2 && parts[1] === 'disable' && m === 'PATCH') {
|
|
458
|
+
const id = decodeURIComponent(parts[0]);
|
|
459
|
+
const pkg = registry.disablePackage(id);
|
|
460
|
+
if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
|
|
461
|
+
return { handled: true, response: this.success(pkg) };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// GET /packages/:id → get package
|
|
465
|
+
if (parts.length === 1 && m === 'GET') {
|
|
466
|
+
const id = decodeURIComponent(parts[0]);
|
|
467
|
+
const pkg = registry.getPackage(id);
|
|
468
|
+
if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
|
|
469
|
+
return { handled: true, response: this.success(pkg) };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// DELETE /packages/:id → uninstall package
|
|
473
|
+
if (parts.length === 1 && m === 'DELETE') {
|
|
474
|
+
const id = decodeURIComponent(parts[0]);
|
|
475
|
+
const success = registry.uninstallPackage(id);
|
|
476
|
+
if (!success) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
|
|
477
|
+
return { handled: true, response: this.success({ success: true }) };
|
|
478
|
+
}
|
|
479
|
+
} catch (e: any) {
|
|
480
|
+
return { handled: true, response: this.error(e.message, e.statusCode || 500) };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { handled: false };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Fallback: handle packages via broker (for backward compatibility)
|
|
488
|
+
*/
|
|
489
|
+
private async handlePackagesViaBroker(parts: string[], m: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
490
|
+
const broker = this.kernel.broker;
|
|
491
|
+
try {
|
|
492
|
+
if (parts.length === 0 && m === 'GET') {
|
|
493
|
+
const result = await broker.call('package.list', query || {}, { request: context.request });
|
|
494
|
+
return { handled: true, response: this.success(result) };
|
|
495
|
+
}
|
|
496
|
+
if (parts.length === 0 && m === 'POST') {
|
|
497
|
+
const result = await broker.call('package.install', body, { request: context.request });
|
|
498
|
+
const res = this.success(result);
|
|
499
|
+
res.status = 201;
|
|
500
|
+
return { handled: true, response: res };
|
|
501
|
+
}
|
|
502
|
+
if (parts.length === 2 && parts[1] === 'enable' && m === 'PATCH') {
|
|
503
|
+
const id = decodeURIComponent(parts[0]);
|
|
504
|
+
const result = await broker.call('package.enable', { id }, { request: context.request });
|
|
505
|
+
return { handled: true, response: this.success(result) };
|
|
506
|
+
}
|
|
507
|
+
if (parts.length === 2 && parts[1] === 'disable' && m === 'PATCH') {
|
|
508
|
+
const id = decodeURIComponent(parts[0]);
|
|
509
|
+
const result = await broker.call('package.disable', { id }, { request: context.request });
|
|
510
|
+
return { handled: true, response: this.success(result) };
|
|
511
|
+
}
|
|
512
|
+
if (parts.length === 1 && m === 'GET') {
|
|
513
|
+
const id = decodeURIComponent(parts[0]);
|
|
514
|
+
const result = await broker.call('package.get', { id }, { request: context.request });
|
|
515
|
+
return { handled: true, response: this.success(result) };
|
|
516
|
+
}
|
|
517
|
+
if (parts.length === 1 && m === 'DELETE') {
|
|
518
|
+
const id = decodeURIComponent(parts[0]);
|
|
519
|
+
const result = await broker.call('package.uninstall', { id }, { request: context.request });
|
|
520
|
+
return { handled: true, response: this.success(result) };
|
|
521
|
+
}
|
|
522
|
+
} catch (e: any) {
|
|
523
|
+
return { handled: true, response: this.error(e.message, e.statusCode || 500) };
|
|
524
|
+
}
|
|
525
|
+
return { handled: false };
|
|
526
|
+
}
|
|
527
|
+
|
|
324
528
|
/**
|
|
325
529
|
* Handles Hub requests
|
|
326
530
|
* path: sub-path after /hub/
|
|
@@ -450,6 +654,36 @@ export class HttpDispatcher {
|
|
|
450
654
|
return { handled: false };
|
|
451
655
|
}
|
|
452
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Handles UI requests
|
|
659
|
+
* path: sub-path after /ui/
|
|
660
|
+
*/
|
|
661
|
+
async handleUi(path: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
662
|
+
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
663
|
+
|
|
664
|
+
// GET /ui/view/:object (with optional type param)
|
|
665
|
+
if (parts[0] === 'view' && parts[1]) {
|
|
666
|
+
const objectName = parts[1];
|
|
667
|
+
// Support both path param /view/obj/list AND query param /view/obj?type=list
|
|
668
|
+
const type = parts[2] || query?.type || 'list';
|
|
669
|
+
|
|
670
|
+
const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
|
|
671
|
+
|
|
672
|
+
if (protocol && typeof protocol.getUiView === 'function') {
|
|
673
|
+
try {
|
|
674
|
+
const result = await protocol.getUiView({ object: objectName, type });
|
|
675
|
+
return { handled: true, response: this.success(result) };
|
|
676
|
+
} catch (e: any) {
|
|
677
|
+
return { handled: true, response: this.error(e.message, 500) };
|
|
678
|
+
}
|
|
679
|
+
} else {
|
|
680
|
+
return { handled: true, response: this.error('Protocol service not available', 503) };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return { handled: false };
|
|
685
|
+
}
|
|
686
|
+
|
|
453
687
|
/**
|
|
454
688
|
* Handles Automation requests
|
|
455
689
|
* path: sub-path after /automation/
|
|
@@ -488,6 +722,31 @@ export class HttpDispatcher {
|
|
|
488
722
|
return services[name];
|
|
489
723
|
}
|
|
490
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Get the ObjectQL service which provides access to SchemaRegistry.
|
|
727
|
+
* Tries multiple access patterns since kernel structure varies.
|
|
728
|
+
*/
|
|
729
|
+
private getObjectQLService(): any {
|
|
730
|
+
// 1. Try via kernel.getService
|
|
731
|
+
if (typeof this.kernel.getService === 'function') {
|
|
732
|
+
try {
|
|
733
|
+
const svc = this.kernel.getService('objectql');
|
|
734
|
+
if (svc?.registry) return svc;
|
|
735
|
+
} catch { /* ignore */ }
|
|
736
|
+
}
|
|
737
|
+
// 2. Try via kernel context
|
|
738
|
+
if (this.kernel?.context?.getService) {
|
|
739
|
+
try {
|
|
740
|
+
const svc = this.kernel.context.getService('objectql');
|
|
741
|
+
if (svc?.registry) return svc;
|
|
742
|
+
} catch { /* ignore */ }
|
|
743
|
+
}
|
|
744
|
+
// 3. Try via services map
|
|
745
|
+
const services = this.getServicesMap();
|
|
746
|
+
if (services['objectql']?.registry) return services['objectql'];
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
491
750
|
private capitalize(s: string) {
|
|
492
751
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
493
752
|
}
|
|
@@ -499,13 +758,24 @@ export class HttpDispatcher {
|
|
|
499
758
|
async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
|
|
500
759
|
const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
|
|
501
760
|
|
|
761
|
+
// 0. Root Discovery Endpoint (GET /)
|
|
762
|
+
// Handles request to base URL (e.g. /api/v1) which MSW strips to empty string
|
|
763
|
+
if (cleanPath === '' && method === 'GET') {
|
|
764
|
+
// We use '' as prefix since we are internal dispatcher
|
|
765
|
+
const info = this.getDiscoveryInfo('');
|
|
766
|
+
return {
|
|
767
|
+
handled: true,
|
|
768
|
+
response: this.success(info)
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
502
772
|
// 1. System Protocols (Prefix-based)
|
|
503
773
|
if (cleanPath.startsWith('/auth')) {
|
|
504
774
|
return this.handleAuth(cleanPath.substring(5), method, body, context);
|
|
505
775
|
}
|
|
506
776
|
|
|
507
777
|
if (cleanPath.startsWith('/meta')) {
|
|
508
|
-
return this.handleMetadata(cleanPath.substring(5), context);
|
|
778
|
+
return this.handleMetadata(cleanPath.substring(5), context, method, body, query);
|
|
509
779
|
}
|
|
510
780
|
|
|
511
781
|
if (cleanPath.startsWith('/data')) {
|
|
@@ -521,6 +791,10 @@ export class HttpDispatcher {
|
|
|
521
791
|
return this.handleStorage(cleanPath.substring(8), method, body, context); // body here is file/stream for upload
|
|
522
792
|
}
|
|
523
793
|
|
|
794
|
+
if (cleanPath.startsWith('/ui')) {
|
|
795
|
+
return this.handleUi(cleanPath.substring(3), query, context);
|
|
796
|
+
}
|
|
797
|
+
|
|
524
798
|
if (cleanPath.startsWith('/automation')) {
|
|
525
799
|
return this.handleAutomation(cleanPath.substring(11), method, body, context);
|
|
526
800
|
}
|
|
@@ -533,6 +807,10 @@ export class HttpDispatcher {
|
|
|
533
807
|
return this.handleHub(cleanPath.substring(4), method, body, query, context);
|
|
534
808
|
}
|
|
535
809
|
|
|
810
|
+
if (cleanPath.startsWith('/packages')) {
|
|
811
|
+
return this.handlePackages(cleanPath.substring(9), method, body, query, context);
|
|
812
|
+
}
|
|
813
|
+
|
|
536
814
|
// OpenAPI Specification
|
|
537
815
|
if (cleanPath === '/openapi.json' && method === 'GET') {
|
|
538
816
|
const broker = this.ensureBroker();
|