Skip to content

Commit 8741749

Browse files
Copilothotlong
andcommitted
feat(runtime): add publish/revert/getPublished HTTP endpoints to HttpDispatcher and dispatcher-plugin
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 1af3247 commit 8741749

3 files changed

Lines changed: 190 additions & 0 deletions

File tree

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,24 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
199199
}
200200
});
201201

202+
server.post(`${prefix}/packages/:id/publish`, async (req: any, res: any) => {
203+
try {
204+
const result = await dispatcher.handlePackages(`/${req.params.id}/publish`, 'POST', req.body, {}, { request: req });
205+
sendResult(result, res);
206+
} catch (err: any) {
207+
errorResponse(err, res);
208+
}
209+
});
210+
211+
server.post(`${prefix}/packages/:id/revert`, async (req: any, res: any) => {
212+
try {
213+
const result = await dispatcher.handlePackages(`/${req.params.id}/revert`, 'POST', req.body, {}, { request: req });
214+
sendResult(result, res);
215+
} catch (err: any) {
216+
errorResponse(err, res);
217+
}
218+
});
219+
202220
// ── Storage ─────────────────────────────────────────────────
203221
server.post(`${prefix}/storage/upload`, async (req: any, res: any) => {
204222
try {

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,4 +469,124 @@ describe('HttpDispatcher', () => {
469469
).rejects.toThrow('Disk full');
470470
});
471471
});
472+
473+
// ═══════════════════════════════════════════════════════════════
474+
// Package Publish / Revert Endpoints
475+
// ═══════════════════════════════════════════════════════════════
476+
477+
describe('Package publish/revert endpoints', () => {
478+
it('should handle POST /packages/:id/publish via metadata service', async () => {
479+
const mockMetadata = {
480+
publishPackage: vi.fn().mockResolvedValue({
481+
success: true,
482+
packageId: 'com.acme.crm',
483+
version: 2,
484+
publishedAt: '2025-06-01T00:00:00Z',
485+
itemsPublished: 3,
486+
}),
487+
};
488+
const mockRegistry = {
489+
getAllPackages: vi.fn().mockReturnValue([]),
490+
enablePackage: vi.fn(),
491+
disablePackage: vi.fn(),
492+
};
493+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
494+
if (name === 'metadata') return Promise.resolve(mockMetadata);
495+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
496+
return null;
497+
});
498+
499+
const result = await dispatcher.handlePackages('/com.acme.crm/publish', 'POST', { publishedBy: 'admin' }, {}, { request: {} });
500+
expect(result.handled).toBe(true);
501+
expect(result.response?.status).toBe(200);
502+
expect(mockMetadata.publishPackage).toHaveBeenCalledWith('com.acme.crm', { publishedBy: 'admin' });
503+
});
504+
505+
it('should handle POST /packages/:id/revert via metadata service', async () => {
506+
const mockMetadata = {
507+
revertPackage: vi.fn().mockResolvedValue(undefined),
508+
};
509+
const mockRegistry = {
510+
getAllPackages: vi.fn().mockReturnValue([]),
511+
enablePackage: vi.fn(),
512+
disablePackage: vi.fn(),
513+
};
514+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
515+
if (name === 'metadata') return Promise.resolve(mockMetadata);
516+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
517+
return null;
518+
});
519+
520+
const result = await dispatcher.handlePackages('/com.acme.crm/revert', 'POST', {}, {}, { request: {} });
521+
expect(result.handled).toBe(true);
522+
expect(result.response?.status).toBe(200);
523+
expect(mockMetadata.revertPackage).toHaveBeenCalledWith('com.acme.crm');
524+
});
525+
526+
it('should fallback to broker for publish when metadata service unavailable', async () => {
527+
const mockRegistry = {
528+
getAllPackages: vi.fn().mockReturnValue([]),
529+
};
530+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
531+
if (name === 'metadata') return Promise.resolve(null);
532+
if (name === 'objectql') return Promise.resolve({ registry: mockRegistry });
533+
return null;
534+
});
535+
mockBroker.call.mockResolvedValue({ success: true, packageId: 'crm', version: 1, publishedAt: '2025-01-01T00:00:00Z', itemsPublished: 2 });
536+
537+
const result = await dispatcher.handlePackages('/crm/publish', 'POST', {}, {}, { request: {} });
538+
expect(result.handled).toBe(true);
539+
expect(mockBroker.call).toHaveBeenCalledWith('metadata.publishPackage', { packageId: 'crm' }, { request: {} });
540+
});
541+
});
542+
543+
// ═══════════════════════════════════════════════════════════════
544+
// Metadata getPublished Endpoint
545+
// ═══════════════════════════════════════════════════════════════
546+
547+
describe('Metadata getPublished endpoint', () => {
548+
it('should handle GET /metadata/:type/:name/published via metadata service', async () => {
549+
const mockMetadata = {
550+
getPublished: vi.fn().mockResolvedValue({ name: 'account', label: 'Account' }),
551+
};
552+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
553+
if (name === 'metadata') return Promise.resolve(mockMetadata);
554+
return null;
555+
});
556+
557+
const result = await dispatcher.handleMetadata('/object/account/published', { request: {} }, 'GET');
558+
expect(result.handled).toBe(true);
559+
expect(result.response?.status).toBe(200);
560+
expect(result.response?.body?.data).toEqual({ name: 'account', label: 'Account' });
561+
expect(mockMetadata.getPublished).toHaveBeenCalledWith('object', 'account');
562+
});
563+
564+
it('should return 404 when published item not found', async () => {
565+
const mockMetadata = {
566+
getPublished: vi.fn().mockResolvedValue(undefined),
567+
};
568+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
569+
if (name === 'metadata') return Promise.resolve(mockMetadata);
570+
return null;
571+
});
572+
573+
const result = await dispatcher.handleMetadata('/object/nonexistent/published', { request: {} }, 'GET');
574+
expect(result.handled).toBe(true);
575+
expect(result.response?.status).toBe(404);
576+
});
577+
578+
it('should fallback to broker for getPublished when metadata service unavailable', async () => {
579+
(kernel as any).getService = vi.fn().mockResolvedValue(null);
580+
mockBroker.call.mockResolvedValue({ name: 'account', fields: ['name'] });
581+
582+
const result = await dispatcher.handleMetadata('/object/account/published', { request: {} }, 'GET');
583+
expect(result.handled).toBe(true);
584+
expect(result.response?.status).toBe(200);
585+
expect(mockBroker.call).toHaveBeenCalledWith(
586+
'metadata.getPublished',
587+
{ type: 'object', name: 'account' },
588+
{ request: {} }
589+
);
590+
});
591+
});
472592
});

packages/runtime/src/http-dispatcher.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,24 @@ export class HttpDispatcher {
217217
}
218218
}
219219

220+
// GET /metadata/:type/:name/published → get published version
221+
if (parts.length === 3 && parts[2] === 'published' && (!method || method === 'GET')) {
222+
const [type, name] = parts;
223+
const metadataService = await this.getService(CoreServiceName.enum.metadata);
224+
if (metadataService && typeof (metadataService as any).getPublished === 'function') {
225+
const data = await (metadataService as any).getPublished(type, name);
226+
if (data === undefined) return { handled: true, response: this.error('Not found', 404) };
227+
return { handled: true, response: this.success(data) };
228+
}
229+
// Broker fallback
230+
try {
231+
const data = await broker.call('metadata.getPublished', { type, name }, { request: context.request });
232+
return { handled: true, response: this.success(data) };
233+
} catch (e: any) {
234+
return { handled: true, response: this.error(e.message, 404) };
235+
}
236+
}
237+
220238
// /metadata/:type/:name
221239
if (parts.length === 2) {
222240
const [type, name] = parts;
@@ -467,6 +485,8 @@ export class HttpDispatcher {
467485
* - DELETE /packages/:id → uninstall a package
468486
* - PATCH /packages/:id/enable → enable a package
469487
* - PATCH /packages/:id/disable → disable a package
488+
* - POST /packages/:id/publish → publish a package (metadata snapshot)
489+
* - POST /packages/:id/revert → revert a package to last published state
470490
*
471491
* Uses ObjectQL SchemaRegistry directly (via the 'objectql' service)
472492
* with broker fallback for backward compatibility.
@@ -525,6 +545,38 @@ export class HttpDispatcher {
525545
return { handled: true, response: this.success(pkg) };
526546
}
527547

548+
// POST /packages/:id/publish → publish package metadata
549+
if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') {
550+
const id = decodeURIComponent(parts[0]);
551+
const metadataService = await this.getService(CoreServiceName.enum.metadata);
552+
if (metadataService && typeof (metadataService as any).publishPackage === 'function') {
553+
const result = await (metadataService as any).publishPackage(id, body || {});
554+
return { handled: true, response: this.success(result) };
555+
}
556+
// Broker fallback
557+
if (this.kernel.broker) {
558+
const result = await this.kernel.broker.call('metadata.publishPackage', { packageId: id, ...body }, { request: context.request });
559+
return { handled: true, response: this.success(result) };
560+
}
561+
return { handled: true, response: this.error('Metadata service not available', 503) };
562+
}
563+
564+
// POST /packages/:id/revert → revert package to last published state
565+
if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') {
566+
const id = decodeURIComponent(parts[0]);
567+
const metadataService = await this.getService(CoreServiceName.enum.metadata);
568+
if (metadataService && typeof (metadataService as any).revertPackage === 'function') {
569+
await (metadataService as any).revertPackage(id);
570+
return { handled: true, response: this.success({ success: true }) };
571+
}
572+
// Broker fallback
573+
if (this.kernel.broker) {
574+
await this.kernel.broker.call('metadata.revertPackage', { packageId: id }, { request: context.request });
575+
return { handled: true, response: this.success({ success: true }) };
576+
}
577+
return { handled: true, response: this.error('Metadata service not available', 503) };
578+
}
579+
528580
// GET /packages/:id → get package
529581
if (parts.length === 1 && m === 'GET') {
530582
const id = decodeURIComponent(parts[0]);

0 commit comments

Comments
 (0)