diff --git a/packages/plugins/plugin-hono-server/README.md b/packages/plugins/plugin-hono-server/README.md index e5d60f56b..afabf5c54 100644 --- a/packages/plugins/plugin-hono-server/README.md +++ b/packages/plugins/plugin-hono-server/README.md @@ -77,9 +77,130 @@ interface HonoPluginOptions { * Path to static files directory (optional) */ staticRoot?: string; + + /** + * REST server configuration + * Controls automatic endpoint generation and API behavior + */ + restConfig?: RestServerConfig; + + /** + * Whether to register standard ObjectStack CRUD endpoints + * @default true + */ + registerStandardEndpoints?: boolean; + + /** + * Whether to load endpoints from API Registry + * When enabled, routes are loaded dynamically from the API Registry + * When disabled, uses legacy static route registration + * @default true + */ + useApiRegistry?: boolean; } ``` +### Using API Registry (New in v0.9.0) + +The plugin now integrates with the ObjectStack API Registry for centralized endpoint management: + +```typescript +import { createApiRegistryPlugin } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; + +const kernel = new ObjectKernel(); + +// 1. Register API Registry Plugin first +kernel.use(createApiRegistryPlugin({ + conflictResolution: 'priority' // Handle route conflicts by priority +})); + +// 2. Register Hono Server Plugin +kernel.use(new HonoServerPlugin({ + port: 3000, + useApiRegistry: true, + registerStandardEndpoints: true, + restConfig: { + api: { + version: 'v1', + basePath: '/api', + enableCrud: true, + enableMetadata: true, + enableBatch: true + } + } +})); + +await kernel.bootstrap(); +``` + +**Benefits of API Registry Integration:** +- 📋 Centralized endpoint registration and discovery +- 🔀 Priority-based route conflict resolution +- 🧩 Support for plugin-registered custom endpoints +- ⚙️ Configurable endpoint generation via `RestServerConfig` +- 🔍 API introspection and documentation generation + +### Configuring REST Server Behavior + +Use `restConfig` to control which endpoints are automatically generated: + +```typescript +new HonoServerPlugin({ + restConfig: { + api: { + version: 'v2', + basePath: '/api', + enableCrud: true, + enableMetadata: true, + enableBatch: true, + enableDiscovery: true + }, + crud: { + dataPrefix: '/data', + operations: { + create: true, + read: true, + update: true, + delete: true, + list: true + } + }, + metadata: { + prefix: '/meta', + enableCache: true, + cacheTtl: 3600 + }, + batch: { + maxBatchSize: 200, + operations: { + createMany: true, + updateMany: true, + deleteMany: true, + upsertMany: true + } + } + } +}) +``` + +### Legacy Mode (Without API Registry) + +If the API Registry plugin is not registered, the server automatically falls back to legacy mode: + +```typescript +// No API Registry needed for simple setups +const kernel = new ObjectKernel(); + +kernel.use(new HonoServerPlugin({ + port: 3000, + useApiRegistry: false // Explicitly disable API Registry +})); + +await kernel.bootstrap(); +// All standard routes registered statically +``` + ## API Endpoints The plugin automatically exposes the following ObjectStack REST API endpoints: @@ -164,6 +285,60 @@ export class MyPlugin implements Plugin { } ``` +### Registering Custom Endpoints via API Registry + +Plugins can register their own endpoints through the API Registry: + +```typescript +export class MyApiPlugin implements Plugin { + name = 'my-api-plugin'; + version = '1.0.0'; + + async init(ctx: PluginContext) { + const apiRegistry = ctx.getService('api-registry'); + + apiRegistry.registerApi({ + id: 'my_custom_api', + name: 'My Custom API', + type: 'rest', + version: 'v1', + basePath: '/api/v1/custom', + endpoints: [ + { + id: 'get_custom_data', + method: 'GET', + path: '/api/v1/custom/data', + summary: 'Get custom data', + priority: 500, // Lower than core endpoints (950) + responses: [{ + statusCode: 200, + description: 'Custom data retrieved' + }] + } + ], + metadata: { + pluginSource: 'my-api-plugin', + status: 'active', + tags: ['custom'] + } + }); + + ctx.logger.info('Custom API endpoints registered'); + } + + async start(ctx: PluginContext) { + // Bind the actual handler implementation + const httpServer = ctx.getService('http-server'); + + httpServer.get('/api/v1/custom/data', async (req, res) => { + res.json({ data: 'my custom data' }); + }); + } +} +``` + +**Note:** The Hono Server Plugin loads routes from the API Registry sorted by priority (highest first), ensuring core endpoints take precedence over plugin endpoints. + ### Extending with Middleware The plugin provides extension points for adding custom middleware: diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index d64c22de4..ccbc847c8 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HonoServerPlugin } from './hono-plugin'; -import { PluginContext } from '@objectstack/core'; +import { PluginContext, ApiRegistry } from '@objectstack/core'; describe('HonoServerPlugin', () => { let context: any; let logger: any; let protocol: any; + let apiRegistry: any; beforeEach(() => { logger = { @@ -16,14 +17,38 @@ describe('HonoServerPlugin', () => { }; protocol = { - findData: vi.fn(), - createData: vi.fn() + getDiscovery: vi.fn().mockResolvedValue({ version: 'v1', apiName: 'ObjectStack' }), + getMetaTypes: vi.fn().mockResolvedValue({ types: ['object', 'plugin'] }), + getMetaItems: vi.fn().mockResolvedValue({ type: 'object', items: [] }), + findData: vi.fn().mockResolvedValue({ object: 'test', records: [] }), + getData: vi.fn().mockResolvedValue({ object: 'test', id: '1', record: {} }), + createData: vi.fn().mockResolvedValue({ object: 'test', id: '1', record: {} }), + updateData: vi.fn().mockResolvedValue({ object: 'test', id: '1', record: {} }), + deleteData: vi.fn().mockResolvedValue({ object: 'test', id: '1', success: true }), + batchData: vi.fn().mockResolvedValue({ total: 0, succeeded: 0, failed: 0 }), + createManyData: vi.fn().mockResolvedValue({ object: 'test', records: [], count: 0 }), + updateManyData: vi.fn().mockResolvedValue({ total: 0, succeeded: 0, failed: 0 }), + deleteManyData: vi.fn().mockResolvedValue({ total: 0, succeeded: 0, failed: 0 }), + getMetaItemCached: vi.fn().mockResolvedValue({ data: {}, notModified: false }), + getUiView: vi.fn().mockResolvedValue({ object: 'test', type: 'list' }) + }; + + apiRegistry = { + registerApi: vi.fn(), + getRegistry: vi.fn().mockReturnValue({ + version: '1.0.0', + conflictResolution: 'error', + apis: [], + totalApis: 0, + totalEndpoints: 0 + }) }; context = { logger, getService: vi.fn((service) => { if (service === 'protocol') return protocol; + if (service === 'api-registry') throw new Error('Not found'); return null; }), registerService: vi.fn(), @@ -47,11 +72,119 @@ describe('HonoServerPlugin', () => { expect(context.hook).toHaveBeenCalledWith('kernel:ready', expect.any(Function)); }); - it('should register CRUD routes', async () => { + it('should register CRUD routes in legacy mode when API Registry not available', async () => { const plugin = new HonoServerPlugin(); await plugin.init(context as PluginContext); await plugin.start(context as PluginContext); expect(context.getService).toHaveBeenCalledWith('protocol'); + expect(context.getService).toHaveBeenCalledWith('api-registry'); + expect(logger.debug).toHaveBeenCalledWith('API Registry not found, using legacy route registration'); + }); + + it('should use API Registry when available', async () => { + context.getService = vi.fn((service) => { + if (service === 'protocol') return protocol; + if (service === 'api-registry') return apiRegistry; + return null; + }); + + const plugin = new HonoServerPlugin(); + await plugin.init(context as PluginContext); + await plugin.start(context as PluginContext); + + expect(context.getService).toHaveBeenCalledWith('api-registry'); + expect(apiRegistry.registerApi).toHaveBeenCalled(); + }); + + it('should register standard endpoints to API Registry', async () => { + context.getService = vi.fn((service) => { + if (service === 'protocol') return protocol; + if (service === 'api-registry') return apiRegistry; + return null; + }); + + const plugin = new HonoServerPlugin(); + await plugin.init(context as PluginContext); + await plugin.start(context as PluginContext); + + expect(apiRegistry.registerApi).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'objectstack_core_api', + name: 'ObjectStack Core API', + type: 'rest', + version: 'v1' + }) + ); + }); + + it('should skip standard endpoint registration when disabled', async () => { + context.getService = vi.fn((service) => { + if (service === 'protocol') return protocol; + if (service === 'api-registry') return apiRegistry; + return null; + }); + + const plugin = new HonoServerPlugin({ registerStandardEndpoints: false }); + await plugin.init(context as PluginContext); + await plugin.start(context as PluginContext); + + expect(apiRegistry.registerApi).not.toHaveBeenCalled(); + }); + + it('should use legacy routes when useApiRegistry is disabled', async () => { + context.getService = vi.fn((service) => { + if (service === 'protocol') return protocol; + if (service === 'api-registry') return apiRegistry; + return null; + }); + + const plugin = new HonoServerPlugin({ useApiRegistry: false }); + await plugin.init(context as PluginContext); + await plugin.start(context as PluginContext); + + expect(apiRegistry.getRegistry).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith('Using legacy route registration'); + }); + + it('should respect REST server configuration', async () => { + context.getService = vi.fn((service) => { + if (service === 'protocol') return protocol; + if (service === 'api-registry') return apiRegistry; + return null; + }); + + const plugin = new HonoServerPlugin({ + restConfig: { + api: { + version: 'v2', + basePath: '/custom', + enableCrud: true, + enableMetadata: true, + enableBatch: true + } + } + }); + + await plugin.init(context as PluginContext); + await plugin.start(context as PluginContext); + + expect(apiRegistry.registerApi).toHaveBeenCalledWith( + expect.objectContaining({ + version: 'v2' + }) + ); + }); + + it('should handle protocol service not found gracefully', async () => { + context.getService = vi.fn(() => { + throw new Error('Service not found'); + }); + + const plugin = new HonoServerPlugin(); + await plugin.init(context as PluginContext); + await plugin.start(context as PluginContext); + + expect(logger.warn).toHaveBeenCalledWith('Protocol service not found, skipping protocol routes'); }); }); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index edfce9a98..57290e2a8 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -1,10 +1,30 @@ -import { Plugin, PluginContext, IHttpServer } from '@objectstack/core'; +import { Plugin, PluginContext, IHttpServer, ApiRegistry } from '@objectstack/core'; import { ObjectStackProtocol } from '@objectstack/spec/api'; +import { + ApiRegistryEntryInput, + ApiEndpointRegistrationInput, + RestServerConfig, +} from '@objectstack/spec/api'; import { HonoHttpServer } from './adapter'; export interface HonoPluginOptions { port?: number; staticRoot?: string; + /** + * REST server configuration + * Controls automatic endpoint generation and API behavior + */ + restConfig?: RestServerConfig; + /** + * Whether to register standard ObjectStack CRUD endpoints + * @default true + */ + registerStandardEndpoints?: boolean; + /** + * Whether to load endpoints from API Registry + * @default true + */ + useApiRegistry?: boolean; } /** @@ -17,12 +37,19 @@ export class HonoServerPlugin implements Plugin { name = 'com.objectstack.server.hono'; version = '0.9.0'; + // Constants + private static readonly DEFAULT_ENDPOINT_PRIORITY = 100; + private static readonly CORE_ENDPOINT_PRIORITY = 950; + private static readonly DISCOVERY_ENDPOINT_PRIORITY = 900; + private options: HonoPluginOptions; private server: HonoHttpServer; constructor(options: HonoPluginOptions = {}) { this.options = { port: 3000, + registerStandardEndpoints: true, + useApiRegistry: true, ...options }; this.server = new HonoHttpServer(this.options.port, this.options.staticRoot); @@ -42,6 +69,16 @@ export class HonoServerPlugin implements Plugin { ctx.logger.info('HTTP server service registered', { serviceName: 'http-server' }); } + /** + * Helper to create cache request object from HTTP headers + */ + private createCacheRequest(headers: any) { + return { + ifNoneMatch: headers['if-none-match'] as string, + ifModifiedSince: headers['if-modified-since'] as string, + }; + } + /** * Start phase - Bind routes and start listening */ @@ -53,122 +90,643 @@ export class HonoServerPlugin implements Plugin { try { protocol = ctx.getService('protocol'); - ctx.logger.debug('Protocol service found, registering protocol routes'); + ctx.logger.debug('Protocol service found'); } catch (e) { ctx.logger.warn('Protocol service not found, skipping protocol routes'); } - // Register protocol routes if available + // Try to get API Registry + let apiRegistry: ApiRegistry | null = null; + try { + apiRegistry = ctx.getService('api-registry'); + ctx.logger.debug('API Registry found, will use for endpoint registration'); + } catch (e) { + ctx.logger.debug('API Registry not found, using legacy route registration'); + } + + // Register standard ObjectStack endpoints if (protocol) { - const p = protocol!; + if (apiRegistry && this.options.registerStandardEndpoints) { + this.registerStandardEndpointsToRegistry(apiRegistry, ctx); + } + + // Bind routes from registry or fallback to legacy + if (apiRegistry && this.options.useApiRegistry) { + this.bindRoutesFromRegistry(apiRegistry, protocol, ctx); + } else { + this.bindLegacyRoutes(protocol, ctx); + } + } + + // Start server on kernel:ready hook + ctx.hook('kernel:ready', async () => { + const port = this.options.port || 3000; + ctx.logger.info('Starting HTTP server', { port }); - ctx.logger.debug('Registering API routes'); + await this.server.listen(port); + ctx.logger.info('HTTP server started successfully', { + port, + url: `http://localhost:${port}` + }); + }); + } + + /** + * Register standard ObjectStack API endpoints to the API Registry + */ + private registerStandardEndpointsToRegistry(registry: ApiRegistry, ctx: PluginContext) { + const config = this.options.restConfig || {}; + const apiVersion = config.api?.version || 'v1'; + const basePath = config.api?.basePath || '/api'; + const apiPath = config.api?.apiPath || `${basePath}/${apiVersion}`; + + const endpoints: ApiEndpointRegistrationInput[] = []; + + // Discovery endpoint + if (config.api?.enableDiscovery !== false) { + endpoints.push({ + id: 'get_discovery', + method: 'GET', + path: apiPath, + summary: 'API Discovery', + description: 'Get API version and capabilities', + responses: [{ + statusCode: 200, + description: 'API discovery information' + }], + priority: HonoServerPlugin.DISCOVERY_ENDPOINT_PRIORITY + }); + } + + // Metadata endpoints + if (config.api?.enableMetadata !== false) { + const metaPrefix = config.metadata?.prefix || '/meta'; - this.server.get('/api/v1', async (req, res) => { - ctx.logger.debug('API discovery request'); - res.json(await p.getDiscovery({})); + endpoints.push( + { + id: 'get_meta_types', + method: 'GET', + path: `${apiPath}${metaPrefix}`, + summary: 'Get Metadata Types', + description: 'List all available metadata types', + responses: [{ + statusCode: 200, + description: 'List of metadata types' + }], + priority: HonoServerPlugin.DISCOVERY_ENDPOINT_PRIORITY + }, + { + id: 'get_meta_items', + method: 'GET', + path: `${apiPath}${metaPrefix}/:type`, + summary: 'Get Metadata Items', + description: 'Get all items of a metadata type', + parameters: [{ + name: 'type', + in: 'path', + required: true, + schema: { type: 'string' } + }], + responses: [{ + statusCode: 200, + description: 'List of metadata items' + }], + priority: HonoServerPlugin.DISCOVERY_ENDPOINT_PRIORITY + }, + { + id: 'get_meta_item_cached', + method: 'GET', + path: `${apiPath}${metaPrefix}/:type/:name`, + summary: 'Get Metadata Item with Cache', + description: 'Get a specific metadata item with ETag support', + parameters: [ + { + name: 'type', + in: 'path', + required: true, + schema: { type: 'string' } + }, + { + name: 'name', + in: 'path', + required: true, + schema: { type: 'string' } + } + ], + responses: [ + { + statusCode: 200, + description: 'Metadata item', + headers: { + 'ETag': { description: 'Entity tag for caching', schema: { type: 'string' } }, + 'Last-Modified': { description: 'Last modification time', schema: { type: 'string' } }, + 'Cache-Control': { description: 'Cache directives', schema: { type: 'string' } } + } + }, + { + statusCode: 304, + description: 'Not Modified' + } + ], + priority: HonoServerPlugin.DISCOVERY_ENDPOINT_PRIORITY + } + ); + } + + // CRUD endpoints + if (config.api?.enableCrud !== false) { + const dataPrefix = config.crud?.dataPrefix || '/data'; + + endpoints.push( + // List/Query + { + id: 'find_data', + method: 'GET', + path: `${apiPath}${dataPrefix}/:object`, + summary: 'Find Records', + description: 'Query records from an object', + parameters: [{ + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }], + responses: [{ + statusCode: 200, + description: 'List of records' + }], + priority: HonoServerPlugin.CORE_ENDPOINT_PRIORITY + }, + // Get by ID + { + id: 'get_data', + method: 'GET', + path: `${apiPath}${dataPrefix}/:object/:id`, + summary: 'Get Record by ID', + description: 'Retrieve a single record by its ID', + parameters: [ + { + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }, + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' } + } + ], + responses: [ + { + statusCode: 200, + description: 'Record found' + }, + { + statusCode: 404, + description: 'Record not found' + } + ], + priority: HonoServerPlugin.CORE_ENDPOINT_PRIORITY + }, + // Create + { + id: 'create_data', + method: 'POST', + path: `${apiPath}${dataPrefix}/:object`, + summary: 'Create Record', + description: 'Create a new record', + parameters: [{ + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }], + requestBody: { + required: true, + description: 'Record data' + }, + responses: [{ + statusCode: 201, + description: 'Record created' + }], + priority: HonoServerPlugin.CORE_ENDPOINT_PRIORITY + }, + // Update + { + id: 'update_data', + method: 'PATCH', + path: `${apiPath}${dataPrefix}/:object/:id`, + summary: 'Update Record', + description: 'Update an existing record', + parameters: [ + { + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }, + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' } + } + ], + requestBody: { + required: true, + description: 'Fields to update' + }, + responses: [{ + statusCode: 200, + description: 'Record updated' + }], + priority: HonoServerPlugin.CORE_ENDPOINT_PRIORITY + }, + // Delete + { + id: 'delete_data', + method: 'DELETE', + path: `${apiPath}${dataPrefix}/:object/:id`, + summary: 'Delete Record', + description: 'Delete a record by ID', + parameters: [ + { + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }, + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string' } + } + ], + responses: [{ + statusCode: 200, + description: 'Record deleted' + }], + priority: HonoServerPlugin.CORE_ENDPOINT_PRIORITY + } + ); + } + + // Batch endpoints + if (config.api?.enableBatch !== false) { + const dataPrefix = config.crud?.dataPrefix || '/data'; + + endpoints.push( + { + id: 'batch_data', + method: 'POST', + path: `${apiPath}${dataPrefix}/:object/batch`, + summary: 'Batch Operations', + description: 'Perform batch create/update/delete operations', + parameters: [{ + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }], + requestBody: { + required: true, + description: 'Batch operation request' + }, + responses: [{ + statusCode: 200, + description: 'Batch operation completed' + }], + priority: HonoServerPlugin.DISCOVERY_ENDPOINT_PRIORITY + }, + { + id: 'create_many_data', + method: 'POST', + path: `${apiPath}${dataPrefix}/:object/createMany`, + summary: 'Create Multiple Records', + description: 'Create multiple records in one request', + parameters: [{ + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }], + requestBody: { + required: true, + description: 'Array of records to create' + }, + responses: [{ + statusCode: 201, + description: 'Records created' + }], + priority: HonoServerPlugin.DISCOVERY_ENDPOINT_PRIORITY + }, + { + id: 'update_many_data', + method: 'POST', + path: `${apiPath}${dataPrefix}/:object/updateMany`, + summary: 'Update Multiple Records', + description: 'Update multiple records in one request', + parameters: [{ + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }], + requestBody: { + required: true, + description: 'Array of records to update' + }, + responses: [{ + statusCode: 200, + description: 'Records updated' + }], + priority: HonoServerPlugin.DISCOVERY_ENDPOINT_PRIORITY + }, + { + id: 'delete_many_data', + method: 'POST', + path: `${apiPath}${dataPrefix}/:object/deleteMany`, + summary: 'Delete Multiple Records', + description: 'Delete multiple records in one request', + parameters: [{ + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }], + requestBody: { + required: true, + description: 'Array of record IDs to delete' + }, + responses: [{ + statusCode: 200, + description: 'Records deleted' + }], + priority: HonoServerPlugin.DISCOVERY_ENDPOINT_PRIORITY + } + ); + } + + // UI endpoints + endpoints.push({ + id: 'get_ui_view', + method: 'GET', + path: `${apiPath}/ui/view/:object`, + summary: 'Get UI View', + description: 'Get UI view definition for an object', + parameters: [ + { + name: 'object', + in: 'path', + required: true, + schema: { type: 'string' } + }, + { + name: 'type', + in: 'query', + schema: { + type: 'string', + enum: ['list', 'form'], + default: 'list' + } + } + ], + responses: [ + { + statusCode: 200, + description: 'UI view definition' + }, + { + statusCode: 404, + description: 'View not found' + } + ], + priority: HonoServerPlugin.DISCOVERY_ENDPOINT_PRIORITY + }); + + // Register the API in the registry + const apiEntry: ApiRegistryEntryInput = { + id: 'objectstack_core_api', + name: 'ObjectStack Core API', + type: 'rest', + version: apiVersion, + basePath: apiPath, + description: 'Standard ObjectStack CRUD and metadata API', + endpoints, + metadata: { + owner: 'objectstack', + status: 'active', + tags: ['core', 'crud', 'metadata'] + } + }; + + try { + registry.registerApi(apiEntry); + ctx.logger.info('Standard ObjectStack endpoints registered to API Registry', { + endpointCount: endpoints.length }); + } catch (error: any) { + ctx.logger.error('Failed to register standard endpoints', error); + } + } + + /** + * Bind HTTP routes from API Registry + */ + private bindRoutesFromRegistry(registry: ApiRegistry, protocol: ObjectStackProtocol, ctx: PluginContext) { + const apiRegistry = registry.getRegistry(); + + ctx.logger.debug('Binding routes from API Registry', { + totalApis: apiRegistry.totalApis, + totalEndpoints: apiRegistry.totalEndpoints + }); + + // Get all endpoints sorted by priority (highest first) + const allEndpoints: Array<{ + api: string; + endpoint: any; + }> = []; + + for (const api of apiRegistry.apis) { + for (const endpoint of api.endpoints) { + allEndpoints.push({ api: api.id, endpoint }); + } + } + + // Sort by priority (highest first) + allEndpoints.sort((a, b) => + (b.endpoint.priority || HonoServerPlugin.DEFAULT_ENDPOINT_PRIORITY) - + (a.endpoint.priority || HonoServerPlugin.DEFAULT_ENDPOINT_PRIORITY) + ); + + // Bind routes + for (const { endpoint } of allEndpoints) { + this.bindEndpoint(endpoint, protocol, ctx); + } + + ctx.logger.info('Routes bound from API Registry', { + totalRoutes: allEndpoints.length + }); + } - // Meta Protocol - this.server.get('/api/v1/meta', async (req, res) => { + /** + * Bind a single endpoint to the HTTP server + */ + private bindEndpoint(endpoint: any, protocol: ObjectStackProtocol, ctx: PluginContext) { + const method = endpoint.method || 'GET'; + const path = endpoint.path; + const id = endpoint.id; + + // Map endpoint ID to protocol method + const handler = this.createHandlerForEndpoint(id, protocol, ctx); + + if (!handler) { + ctx.logger.warn('No handler found for endpoint', { id, method, path }); + return; + } + + // Register route based on method + switch (method.toUpperCase()) { + case 'GET': + this.server.get(path, handler); + break; + case 'POST': + this.server.post(path, handler); + break; + case 'PATCH': + this.server.patch(path, handler); + break; + case 'PUT': + this.server.put(path, handler); + break; + case 'DELETE': + this.server.delete(path, handler); + break; + default: + ctx.logger.warn('Unsupported HTTP method', { method, path }); + } + + ctx.logger.debug('Route bound', { method, path, endpoint: id }); + } + + /** + * Create a route handler for an endpoint + */ + private createHandlerForEndpoint(endpointId: string, protocol: ObjectStackProtocol, ctx: PluginContext) { + const p = protocol; + + // Map endpoint IDs to protocol methods + const handlerMap: Record = { + 'get_discovery': async (req: any, res: any) => { + ctx.logger.debug('API discovery request'); + res.json(await p.getDiscovery({})); + }, + 'get_meta_types': async (req: any, res: any) => { ctx.logger.debug('Meta types request'); res.json(await p.getMetaTypes({})); - }); - this.server.get('/api/v1/meta/:type', async (req, res) => { + }, + 'get_meta_items': async (req: any, res: any) => { ctx.logger.debug('Meta items request', { type: req.params.type }); res.json(await p.getMetaItems({ type: req.params.type })); - }); - - // Data Protocol - this.server.get('/api/v1/data/:object', async (req, res) => { - ctx.logger.debug('Data find request', { object: req.params.object, query: req.query }); - try { + }, + 'get_meta_item_cached': async (req: any, res: any) => { + ctx.logger.debug('Meta item cached request', { + type: req.params.type, + name: req.params.name + }); + try { + const result = await p.getMetaItemCached({ + type: req.params.type, + name: req.params.name, + cacheRequest: this.createCacheRequest(req.headers) + }); + + if (result.notModified) { + res.status(304).send(''); + } else { + // Set cache headers + if (result.etag) { + const etagValue = result.etag.weak ? `W/"${result.etag.value}"` : `"${result.etag.value}"`; + res.header('ETag', etagValue); + } + if (result.lastModified) { + res.header('Last-Modified', new Date(result.lastModified).toUTCString()); + } + if (result.cacheControl) { + const directives = result.cacheControl.directives.join(', '); + const maxAge = result.cacheControl.maxAge ? `, max-age=${result.cacheControl.maxAge}` : ''; + res.header('Cache-Control', directives + maxAge); + } + res.json(result.data); + } + } catch (e: any) { + ctx.logger.warn('Meta item not found', { type: req.params.type, name: req.params.name }); + res.status(404).json({ error: e.message }); + } + }, + 'find_data': async (req: any, res: any) => { + ctx.logger.debug('Data find request', { object: req.params.object }); + try { const result = await p.findData({ object: req.params.object, query: req.query as any }); ctx.logger.debug('Data find completed', { object: req.params.object, count: result?.records?.length ?? 0 }); res.json(result); - } - catch(e:any) { + } catch (e: any) { ctx.logger.error('Data find failed', e, { object: req.params.object }); - res.status(400).json({error:e.message}); + res.status(400).json({ error: e.message }); } - }); - this.server.get('/api/v1/data/:object/:id', async (req, res) => { + }, + 'get_data': async (req: any, res: any) => { ctx.logger.debug('Data get request', { object: req.params.object, id: req.params.id }); - try { + try { const result = await p.getData({ object: req.params.object, id: req.params.id }); - ctx.logger.debug('Data get completed', { object: req.params.object, id: req.params.id }); res.json(result); + } catch (e: any) { + ctx.logger.warn('Data get failed', { object: req.params.object, id: req.params.id }); + res.status(404).json({ error: e.message }); } - catch(e:any) { - ctx.logger.warn('Data get failed - not found', { object: req.params.object, id: req.params.id }); - res.status(404).json({error:e.message}); - } - }); - this.server.post('/api/v1/data/:object', async (req, res) => { + }, + 'create_data': async (req: any, res: any) => { ctx.logger.debug('Data create request', { object: req.params.object }); - try { + try { const result = await p.createData({ object: req.params.object, data: req.body }); ctx.logger.info('Data created', { object: req.params.object, id: result?.id }); res.status(201).json(result); - } - catch(e:any) { + } catch (e: any) { ctx.logger.error('Data create failed', e, { object: req.params.object }); - res.status(400).json({error:e.message}); + res.status(400).json({ error: e.message }); } - }); - this.server.patch('/api/v1/data/:object/:id', async (req, res) => { + }, + 'update_data': async (req: any, res: any) => { ctx.logger.debug('Data update request', { object: req.params.object, id: req.params.id }); - try { + try { const result = await p.updateData({ object: req.params.object, id: req.params.id, data: req.body }); ctx.logger.info('Data updated', { object: req.params.object, id: req.params.id }); res.json(result); - } - catch(e:any) { + } catch (e: any) { ctx.logger.error('Data update failed', e, { object: req.params.object, id: req.params.id }); - res.status(400).json({error:e.message}); + res.status(400).json({ error: e.message }); } - }); - this.server.delete('/api/v1/data/:object/:id', async (req, res) => { + }, + 'delete_data': async (req: any, res: any) => { ctx.logger.debug('Data delete request', { object: req.params.object, id: req.params.id }); - try { + try { const result = await p.deleteData({ object: req.params.object, id: req.params.id }); - ctx.logger.info('Data deleted', { object: req.params.object, id: req.params.id, success: result?.success }); + ctx.logger.info('Data deleted', { object: req.params.object, id: req.params.id }); res.json(result); - } - catch(e:any) { + } catch (e: any) { ctx.logger.error('Data delete failed', e, { object: req.params.object, id: req.params.id }); - res.status(400).json({error:e.message}); - } - }); - - // UI Protocol - this.server.get('/api/v1/ui/view/:object', async (req, res) => { - const viewType = (req.query.type) || 'list'; - const qt = Array.isArray(viewType) ? viewType[0] : viewType; - ctx.logger.debug('UI view request', { object: req.params.object, viewType: qt }); - try { - res.json(await p.getUiView({ object: req.params.object, type: qt as any })); - } - catch(e:any) { - ctx.logger.warn('UI view not found', { object: req.params.object, viewType: qt }); - res.status(404).json({error:e.message}); + res.status(400).json({ error: e.message }); } - }); - - // Batch Operations - this.server.post('/api/v1/data/:object/batch', async (req, res) => { - ctx.logger.info('Batch operation request', { - object: req.params.object, - operation: req.body?.operation, - hasBody: !!req.body, - bodyType: typeof req.body, - bodyKeys: req.body ? Object.keys(req.body) : [] - }); + }, + 'batch_data': async (req: any, res: any) => { + ctx.logger.info('Batch operation request', { object: req.params.object }); try { const result = await p.batchData({ object: req.params.object, request: req.body }); ctx.logger.info('Batch operation completed', { - object: req.params.object, - operation: req.body?.operation, + object: req.params.object, total: result.total, succeeded: result.succeeded, failed: result.failed @@ -178,10 +736,9 @@ export class HonoServerPlugin implements Plugin { ctx.logger.error('Batch operation failed', e, { object: req.params.object }); res.status(400).json({ error: e.message }); } - }); - - this.server.post('/api/v1/data/:object/createMany', async (req, res) => { - ctx.logger.debug('Create many request', { object: req.params.object, count: req.body?.length }); + }, + 'create_many_data': async (req: any, res: any) => { + ctx.logger.debug('Create many request', { object: req.params.object }); try { const result = await p.createManyData({ object: req.params.object, records: req.body || [] }); ctx.logger.info('Create many completed', { object: req.params.object, count: result.records?.length ?? 0 }); @@ -190,12 +747,15 @@ export class HonoServerPlugin implements Plugin { ctx.logger.error('Create many failed', e, { object: req.params.object }); res.status(400).json({ error: e.message }); } - }); - - this.server.post('/api/v1/data/:object/updateMany', async (req, res) => { - ctx.logger.debug('Update many request', { object: req.params.object, count: req.body?.records?.length }); + }, + 'update_many_data': async (req: any, res: any) => { + ctx.logger.debug('Update many request', { object: req.params.object }); try { - const result = await p.updateManyData({ object: req.params.object, records: req.body?.records, options: req.body?.options }); + const result = await p.updateManyData({ + object: req.params.object, + records: req.body?.records, + options: req.body?.options + }); ctx.logger.info('Update many completed', { object: req.params.object, total: result.total, @@ -207,12 +767,15 @@ export class HonoServerPlugin implements Plugin { ctx.logger.error('Update many failed', e, { object: req.params.object }); res.status(400).json({ error: e.message }); } - }); - - this.server.post('/api/v1/data/:object/deleteMany', async (req, res) => { - ctx.logger.debug('Delete many request', { object: req.params.object, count: req.body?.ids?.length }); + }, + 'delete_many_data': async (req: any, res: any) => { + ctx.logger.debug('Delete many request', { object: req.params.object }); try { - const result = await p.deleteManyData({ object: req.params.object, ids: req.body?.ids, options: req.body?.options }); + const result = await p.deleteManyData({ + object: req.params.object, + ids: req.body?.ids, + options: req.body?.options + }); ctx.logger.info('Delete many completed', { object: req.params.object, total: result.total, @@ -224,87 +787,243 @@ export class HonoServerPlugin implements Plugin { ctx.logger.error('Delete many failed', e, { object: req.params.object }); res.status(400).json({ error: e.message }); } - }); - - // Enhanced Metadata Route with ETag Support - this.server.get('/api/v1/meta/:type/:name', async (req, res) => { - ctx.logger.debug('Meta item request with cache support', { - type: req.params.type, - name: req.params.name, - ifNoneMatch: req.headers['if-none-match'] - }); - try { - const cacheRequest = { - ifNoneMatch: req.headers['if-none-match'] as string, - ifModifiedSince: req.headers['if-modified-since'] as string, - }; - - const result = await p.getMetaItemCached({ - type: req.params.type, - name: req.params.name, - cacheRequest - }); - - if (result.notModified) { - ctx.logger.debug('Meta item not modified (304)', { type: req.params.type, name: req.params.name }); - res.status(304).json({}); - } else { - // Set cache headers - if (result.etag) { - const etagValue = result.etag.weak ? `W/"${result.etag.value}"` : `"${result.etag.value}"`; - res.header('ETag', etagValue); - } - if (result.lastModified) { - res.header('Last-Modified', new Date(result.lastModified).toUTCString()); - } - if (result.cacheControl) { - const directives = result.cacheControl.directives.join(', '); - const maxAge = result.cacheControl.maxAge ? `, max-age=${result.cacheControl.maxAge}` : ''; - res.header('Cache-Control', directives + maxAge); - } - - ctx.logger.debug('Meta item returned with cache headers', { - type: req.params.type, - name: req.params.name, - etag: result.etag?.value - }); - res.json(result.data); - } - } catch (e: any) { - ctx.logger.warn('Meta item not found', { type: req.params.type, name: req.params.name }); - res.status(404).json({ error: e.message }); - } - }); - - // UI Protocol endpoint - this.server.get('/api/v1/ui/view/:object', async (req, res) => { - ctx.logger.debug('Get UI view request', { object: req.params.object, type: req.query.type }); + }, + 'get_ui_view': async (req: any, res: any) => { + const viewType = (req.query.type as 'list' | 'form') || 'list'; + ctx.logger.debug('UI view request', { object: req.params.object, viewType }); try { - const viewType = (req.query.type as 'list' | 'form') || 'list'; const view = await p.getUiView({ object: req.params.object, type: viewType }); res.json(view); } catch (e: any) { - ctx.logger.warn('UI view not found', { object: req.params.object, error: e.message }); + ctx.logger.warn('UI view not found', { object: req.params.object }); res.status(404).json({ error: e.message }); } - }); + } + }; + return handlerMap[endpointId]; + } + /** + * Legacy route registration (fallback when API Registry is not available) + */ + private bindLegacyRoutes(protocol: ObjectStackProtocol, ctx: PluginContext) { + const p = protocol; + + ctx.logger.debug('Using legacy route registration'); - ctx.logger.info('All API routes registered'); - } - - // Start server on kernel:ready hook - ctx.hook('kernel:ready', async () => { - const port = this.options.port || 3000; - ctx.logger.info('Starting HTTP server', { port }); + ctx.logger.debug('Registering API routes'); - await this.server.listen(port); - ctx.logger.info('HTTP server started successfully', { - port, - url: `http://localhost:${port}` + this.server.get('/api/v1', async (req, res) => { + ctx.logger.debug('API discovery request'); + res.json(await p.getDiscovery({})); + }); + + // Meta Protocol + this.server.get('/api/v1/meta', async (req, res) => { + ctx.logger.debug('Meta types request'); + res.json(await p.getMetaTypes({})); + }); + this.server.get('/api/v1/meta/:type', async (req, res) => { + ctx.logger.debug('Meta items request', { type: req.params.type }); + res.json(await p.getMetaItems({ type: req.params.type })); + }); + + // Data Protocol + this.server.get('/api/v1/data/:object', async (req, res) => { + ctx.logger.debug('Data find request', { object: req.params.object, query: req.query }); + try { + const result = await p.findData({ object: req.params.object, query: req.query as any }); + ctx.logger.debug('Data find completed', { object: req.params.object, count: result?.records?.length ?? 0 }); + res.json(result); + } + catch(e:any) { + ctx.logger.error('Data find failed', e, { object: req.params.object }); + res.status(400).json({error:e.message}); + } + }); + this.server.get('/api/v1/data/:object/:id', async (req, res) => { + ctx.logger.debug('Data get request', { object: req.params.object, id: req.params.id }); + try { + const result = await p.getData({ object: req.params.object, id: req.params.id }); + ctx.logger.debug('Data get completed', { object: req.params.object, id: req.params.id }); + res.json(result); + } + catch(e:any) { + ctx.logger.warn('Data get failed - not found', { object: req.params.object, id: req.params.id }); + res.status(404).json({error:e.message}); + } + }); + this.server.post('/api/v1/data/:object', async (req, res) => { + ctx.logger.debug('Data create request', { object: req.params.object }); + try { + const result = await p.createData({ object: req.params.object, data: req.body }); + ctx.logger.info('Data created', { object: req.params.object, id: result?.id }); + res.status(201).json(result); + } + catch(e:any) { + ctx.logger.error('Data create failed', e, { object: req.params.object }); + res.status(400).json({error:e.message}); + } + }); + this.server.patch('/api/v1/data/:object/:id', async (req, res) => { + ctx.logger.debug('Data update request', { object: req.params.object, id: req.params.id }); + try { + const result = await p.updateData({ object: req.params.object, id: req.params.id, data: req.body }); + ctx.logger.info('Data updated', { object: req.params.object, id: req.params.id }); + res.json(result); + } + catch(e:any) { + ctx.logger.error('Data update failed', e, { object: req.params.object, id: req.params.id }); + res.status(400).json({error:e.message}); + } + }); + this.server.delete('/api/v1/data/:object/:id', async (req, res) => { + ctx.logger.debug('Data delete request', { object: req.params.object, id: req.params.id }); + try { + const result = await p.deleteData({ object: req.params.object, id: req.params.id }); + ctx.logger.info('Data deleted', { object: req.params.object, id: req.params.id, success: result?.success }); + res.json(result); + } + catch(e:any) { + ctx.logger.error('Data delete failed', e, { object: req.params.object, id: req.params.id }); + res.status(400).json({error:e.message}); + } + }); + + // UI Protocol + this.server.get('/api/v1/ui/view/:object', async (req, res) => { + const viewType = (req.query.type) || 'list'; + const qt = Array.isArray(viewType) ? viewType[0] : viewType; + ctx.logger.debug('UI view request', { object: req.params.object, viewType: qt }); + try { + res.json(await p.getUiView({ object: req.params.object, type: qt as any })); + } + catch(e:any) { + ctx.logger.warn('UI view not found', { object: req.params.object, viewType: qt }); + res.status(404).json({error:e.message}); + } + }); + + // Batch Operations + this.server.post('/api/v1/data/:object/batch', async (req, res) => { + ctx.logger.info('Batch operation request', { + object: req.params.object, + operation: req.body?.operation, + hasBody: !!req.body, + bodyType: typeof req.body, + bodyKeys: req.body ? Object.keys(req.body) : [] + }); + try { + const result = await p.batchData({ object: req.params.object, request: req.body }); + ctx.logger.info('Batch operation completed', { + object: req.params.object, + operation: req.body?.operation, + total: result.total, + succeeded: result.succeeded, + failed: result.failed + }); + res.json(result); + } catch (e: any) { + ctx.logger.error('Batch operation failed', e, { object: req.params.object }); + res.status(400).json({ error: e.message }); + } + }); + + this.server.post('/api/v1/data/:object/createMany', async (req, res) => { + ctx.logger.debug('Create many request', { object: req.params.object, count: req.body?.length }); + try { + const result = await p.createManyData({ object: req.params.object, records: req.body || [] }); + ctx.logger.info('Create many completed', { object: req.params.object, count: result.records?.length ?? 0 }); + res.status(201).json(result); + } catch (e: any) { + ctx.logger.error('Create many failed', e, { object: req.params.object }); + res.status(400).json({ error: e.message }); + } + }); + + this.server.post('/api/v1/data/:object/updateMany', async (req, res) => { + ctx.logger.debug('Update many request', { object: req.params.object, count: req.body?.records?.length }); + try { + const result = await p.updateManyData({ object: req.params.object, records: req.body?.records, options: req.body?.options }); + ctx.logger.info('Update many completed', { + object: req.params.object, + total: result.total, + succeeded: result.succeeded, + failed: result.failed + }); + res.json(result); + } catch (e: any) { + ctx.logger.error('Update many failed', e, { object: req.params.object }); + res.status(400).json({ error: e.message }); + } + }); + + this.server.post('/api/v1/data/:object/deleteMany', async (req, res) => { + ctx.logger.debug('Delete many request', { object: req.params.object, count: req.body?.ids?.length }); + try { + const result = await p.deleteManyData({ object: req.params.object, ids: req.body?.ids, options: req.body?.options }); + ctx.logger.info('Delete many completed', { + object: req.params.object, + total: result.total, + succeeded: result.succeeded, + failed: result.failed + }); + res.json(result); + } catch (e: any) { + ctx.logger.error('Delete many failed', e, { object: req.params.object }); + res.status(400).json({ error: e.message }); + } + }); + + // Enhanced Metadata Route with ETag Support + this.server.get('/api/v1/meta/:type/:name', async (req, res) => { + ctx.logger.debug('Meta item request with cache support', { + type: req.params.type, + name: req.params.name, + ifNoneMatch: req.headers['if-none-match'] }); + try { + const cacheRequest = this.createCacheRequest(req.headers); + + const result = await p.getMetaItemCached({ + type: req.params.type, + name: req.params.name, + cacheRequest + }); + + if (result.notModified) { + ctx.logger.debug('Meta item not modified (304)', { type: req.params.type, name: req.params.name }); + res.status(304).send(''); + } else { + // Set cache headers + if (result.etag) { + const etagValue = result.etag.weak ? `W/"${result.etag.value}"` : `"${result.etag.value}"`; + res.header('ETag', etagValue); + } + if (result.lastModified) { + res.header('Last-Modified', new Date(result.lastModified).toUTCString()); + } + if (result.cacheControl) { + const directives = result.cacheControl.directives.join(', '); + const maxAge = result.cacheControl.maxAge ? `, max-age=${result.cacheControl.maxAge}` : ''; + res.header('Cache-Control', directives + maxAge); + } + + ctx.logger.debug('Meta item returned with cache headers', { + type: req.params.type, + name: req.params.name, + etag: result.etag?.value + }); + res.json(result.data); + } + } catch (e: any) { + ctx.logger.warn('Meta item not found', { type: req.params.type, name: req.params.name }); + res.status(404).json({ error: e.message }); + } }); + + ctx.logger.info('All legacy API routes registered'); } /**