From f1231cfb927efeb655fee71a9cca8ba6d11dfc65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 07:32:00 +0000 Subject: [PATCH 1/6] Initial plan From a3cbe9469438b0403f7547a7e0c31b6bada74e29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 07:43:30 +0000 Subject: [PATCH 2/6] Create @objectql/plugin-server package with Hono adapter Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugins/server/CHANGELOG.md | 27 + packages/plugins/server/README.md | 126 ++++ packages/plugins/server/jest.config.js | 7 + packages/plugins/server/package.json | 46 ++ .../plugins/server/src/adapters/graphql.ts | 559 ++++++++++++++++++ packages/plugins/server/src/adapters/hono.ts | 258 ++++++++ packages/plugins/server/src/adapters/node.ts | 293 +++++++++ packages/plugins/server/src/adapters/rest.ts | 338 +++++++++++ packages/plugins/server/src/file-handler.ts | 422 +++++++++++++ packages/plugins/server/src/index.ts | 25 + packages/plugins/server/src/metadata.ts | 244 ++++++++ packages/plugins/server/src/openapi.ts | 207 +++++++ packages/plugins/server/src/plugin.ts | 239 ++++++++ packages/plugins/server/src/server.ts | 274 +++++++++ packages/plugins/server/src/storage.ts | 179 ++++++ packages/plugins/server/src/templates.ts | 57 ++ packages/plugins/server/src/types.ts | 181 ++++++ packages/plugins/server/src/utils.ts | 29 + packages/plugins/server/tsconfig.json | 9 + packages/runtime/server/src/index.ts | 13 + pnpm-lock.yaml | 37 ++ pnpm-workspace.yaml | 1 + 22 files changed, 3571 insertions(+) create mode 100644 packages/plugins/server/CHANGELOG.md create mode 100644 packages/plugins/server/README.md create mode 100644 packages/plugins/server/jest.config.js create mode 100644 packages/plugins/server/package.json create mode 100644 packages/plugins/server/src/adapters/graphql.ts create mode 100644 packages/plugins/server/src/adapters/hono.ts create mode 100644 packages/plugins/server/src/adapters/node.ts create mode 100644 packages/plugins/server/src/adapters/rest.ts create mode 100644 packages/plugins/server/src/file-handler.ts create mode 100644 packages/plugins/server/src/index.ts create mode 100644 packages/plugins/server/src/metadata.ts create mode 100644 packages/plugins/server/src/openapi.ts create mode 100644 packages/plugins/server/src/plugin.ts create mode 100644 packages/plugins/server/src/server.ts create mode 100644 packages/plugins/server/src/storage.ts create mode 100644 packages/plugins/server/src/templates.ts create mode 100644 packages/plugins/server/src/types.ts create mode 100644 packages/plugins/server/src/utils.ts create mode 100644 packages/plugins/server/tsconfig.json diff --git a/packages/plugins/server/CHANGELOG.md b/packages/plugins/server/CHANGELOG.md new file mode 100644 index 00000000..dc137c54 --- /dev/null +++ b/packages/plugins/server/CHANGELOG.md @@ -0,0 +1,27 @@ +# @objectql/plugin-server + +## 3.0.1 + +### Added + +- Initial release as an ObjectQL plugin +- Support for JSON-RPC, REST, GraphQL, and Metadata APIs +- Hono framework adapter +- Express/Node.js adapter +- Plugin-based architecture +- Configurable routes and middleware +- File upload/download support +- Auto-start capability +- Backward compatibility with @objectql/server + +### Changed + +- Refactored server functionality into plugin architecture +- Improved modularity and extensibility +- Enhanced framework integration support + +### Documentation + +- Added comprehensive README with examples +- Documented all configuration options +- Included usage examples for Hono, Express, and standalone usage diff --git a/packages/plugins/server/README.md b/packages/plugins/server/README.md new file mode 100644 index 00000000..35487fc3 --- /dev/null +++ b/packages/plugins/server/README.md @@ -0,0 +1,126 @@ +# @objectql/plugin-server + +HTTP server plugin for ObjectQL. Provides Express, Hono, and custom HTTP server support with JSON-RPC, REST, GraphQL, and Metadata APIs. + +## Installation + +```bash +npm install @objectql/plugin-server +``` + +## Usage + +### As a Plugin + +```typescript +import { ObjectQL } from '@objectql/core'; +import { ServerPlugin } from '@objectql/plugin-server'; + +const app = new ObjectQL({ + datasources: { /* ... */ }, + plugins: [ + new ServerPlugin({ + port: 3000, + autoStart: true, + enableREST: true, + enableRPC: true, + enableMetadata: true + }) + ] +}); + +await app.init(); +``` + +### With Hono Framework + +```typescript +import { Hono } from 'hono'; +import { ObjectQL } from '@objectql/core'; +import { createHonoAdapter } from '@objectql/plugin-server/adapters/hono'; + +const app = new ObjectQL({ /* ... */ }); +await app.init(); + +const server = new Hono(); +const objectqlHandler = createHonoAdapter(app); + +server.all('/api/*', objectqlHandler); + +export default server; +``` + +### With Express + +```typescript +import express from 'express'; +import { ObjectQL } from '@objectql/core'; +import { createNodeHandler } from '@objectql/plugin-server'; + +const app = new ObjectQL({ /* ... */ }); +await app.init(); + +const server = express(); +const objectqlHandler = createNodeHandler(app); + +server.all('/api/*', objectqlHandler); + +server.listen(3000); +``` + +## Features + +### JSON-RPC API +- Protocol-first approach +- Supports all ObjectQL operations +- Type-safe requests and responses + +### REST API +- Standard HTTP methods (GET, POST, PUT, DELETE) +- RESTful resource endpoints +- Query parameter support + +### GraphQL API +- Auto-generated schema from ObjectQL metadata +- Support for queries and mutations +- Introspection support + +### Metadata API +- Explore object schemas +- Discover available operations +- Runtime schema inspection + +### File Upload/Download +- Single and batch file uploads +- Secure file storage +- File download support + +## Configuration Options + +```typescript +interface ServerPluginOptions { + port?: number; // Default: 3000 + host?: string; // Default: 'localhost' + routes?: ApiRouteConfig; // Custom route configuration + fileStorage?: IFileStorage; // Custom file storage + enableGraphQL?: boolean; // Default: false + enableREST?: boolean; // Default: true + enableMetadata?: boolean; // Default: true + enableRPC?: boolean; // Default: true + autoStart?: boolean; // Default: false + middleware?: Function[]; // Custom middleware +} +``` + +## API Routes + +Default routes (customizable): +- JSON-RPC: `/api/objectql` +- REST: `/api/data` +- GraphQL: `/api/graphql` +- Metadata: `/api/metadata` +- Files: `/api/files` + +## License + +MIT diff --git a/packages/plugins/server/jest.config.js b/packages/plugins/server/jest.config.js new file mode 100644 index 00000000..52184bc1 --- /dev/null +++ b/packages/plugins/server/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], +}; diff --git a/packages/plugins/server/package.json b/packages/plugins/server/package.json new file mode 100644 index 00000000..8aa20317 --- /dev/null +++ b/packages/plugins/server/package.json @@ -0,0 +1,46 @@ +{ + "name": "@objectql/plugin-server", + "version": "3.0.1", + "description": "HTTP server plugin for ObjectQL - Provides Express, Hono and REST API support", + "keywords": [ + "objectql", + "plugin", + "server", + "http", + "api", + "rest", + "graphql", + "express", + "hono", + "adapter", + "backend" + ], + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "jest" + }, + "dependencies": { + "@objectql/core": "workspace:*", + "@objectql/types": "workspace:*", + "graphql": "^16.8.1", + "@graphql-tools/schema": "^10.0.2", + "js-yaml": "^4.1.1" + }, + "peerDependencies": { + "hono": "^4.11.0" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.10.0", + "hono": "^4.11.0", + "typescript": "^5.3.0" + } +} diff --git a/packages/plugins/server/src/adapters/graphql.ts b/packages/plugins/server/src/adapters/graphql.ts new file mode 100644 index 00000000..160c2b41 --- /dev/null +++ b/packages/plugins/server/src/adapters/graphql.ts @@ -0,0 +1,559 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IObjectQL, ObjectConfig, FieldConfig } from '@objectql/types'; +import { ObjectQLServer } from '../server'; +import { ErrorCode } from '../types'; +import { IncomingMessage, ServerResponse } from 'http'; +import { graphql, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLList, GraphQLNonNull, GraphQLInputObjectType, GraphQLFieldConfigMap, GraphQLOutputType, GraphQLInputType } from 'graphql'; + +const APOLLO_SANDBOX_HTML = ` + + + +
+ + + +`; + +/** + * Normalize ObjectQL response to use 'id' instead of '_id' + */ +function normalizeId(data: unknown): unknown { + if (!data) return data; + + if (Array.isArray(data)) { + return data.map(item => normalizeId(item)); + } + + if (typeof data === 'object') { + const normalized = { ...data as Record }; + + // Map _id to id if present + if ('_id' in normalized) { + normalized.id = normalized._id; + delete normalized._id; + } + + // Remove '@type' field as it's not needed in GraphQL + delete normalized['@type']; + + return normalized; + } + + return data; +} + +/** + * Map ObjectQL field types to GraphQL types + */ +function mapFieldTypeToGraphQL(field: FieldConfig, isInput: boolean = false): GraphQLOutputType | GraphQLInputType { + const type = field.type; + + switch (type) { + case 'text': + case 'textarea': + case 'markdown': + case 'html': + case 'email': + case 'url': + case 'phone': + case 'password': + return GraphQLString; + case 'number': + case 'currency': + case 'percent': + return GraphQLFloat; + case 'autonumber': + return GraphQLInt; + case 'boolean': + return GraphQLBoolean; + case 'date': + case 'datetime': + case 'time': + return GraphQLString; // ISO 8601 string format + case 'select': + // For select fields, we could create an enum type, but for simplicity use String + return GraphQLString; + case 'lookup': + case 'master_detail': + // For relationships, return ID reference + return GraphQLString; + case 'file': + case 'image': + // File fields return metadata object (simplified as String for now) + return GraphQLString; + case 'object': + case 'formula': + case 'summary': + case 'location': + case 'vector': + case 'grid': + // Return as JSON string + return GraphQLString; + default: + return GraphQLString; + } +} + +/** + * Sanitize field/object names to be valid GraphQL identifiers + * GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ + */ +function sanitizeGraphQLName(name: string): string { + // Replace invalid characters with underscores + let sanitized = name.replace(/[^_a-zA-Z0-9]/g, '_'); + + // Ensure it starts with a letter or underscore + if (!/^[_a-zA-Z]/.test(sanitized)) { + sanitized = '_' + sanitized; + } + + return sanitized; +} + +/** + * Generate GraphQL schema from ObjectQL metadata + */ +export function generateGraphQLSchema(app: IObjectQL): GraphQLSchema { + const objects = app.metadata.list('object'); + + // Validate that there are objects to generate schema from + if (!objects || objects.length === 0) { + // Create a minimal schema with a dummy query to avoid GraphQL error + return new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + _schema: { + type: GraphQLString, + description: 'Schema introspection placeholder', + resolve: () => 'No objects registered in ObjectQL metadata' + } + } + }) + }); + } + + const typeMap: Record = {}; + const inputTypeMap: Record = {}; + const deleteResultTypeMap: Record = {}; + + // Create a shared ObjectQL server instance to reuse across resolvers + // This is safe because ObjectQLServer is stateless - it only holds a reference to the app + // and creates fresh contexts for each request via handle() + const server = new ObjectQLServer(app); + + // First pass: Create all object types + for (const config of objects) { + const objectName = config.name; + + // Skip if no name or fields defined + if (!objectName || !config.fields || Object.keys(config.fields).length === 0) { + continue; + } + + const sanitizedTypeName = sanitizeGraphQLName(objectName.charAt(0).toUpperCase() + objectName.slice(1)); + + // Create output type + const fields: GraphQLFieldConfigMap = { + id: { type: new GraphQLNonNull(GraphQLString) } + }; + + for (const [fieldName, fieldConfig] of Object.entries(config.fields)) { + const sanitizedFieldName = sanitizeGraphQLName(fieldName); + const gqlType = mapFieldTypeToGraphQL(fieldConfig, false) as GraphQLOutputType; + fields[sanitizedFieldName] = { + type: fieldConfig.required ? new GraphQLNonNull(gqlType) : gqlType, + description: fieldConfig.label || fieldName + }; + } + + typeMap[objectName] = new GraphQLObjectType({ + name: sanitizedTypeName, + description: config.label || objectName, + fields + }); + + // Create input type for mutations + const inputFields: Record = {}; + + for (const [fieldName, fieldConfig] of Object.entries(config.fields)) { + const sanitizedFieldName = sanitizeGraphQLName(fieldName); + const gqlType = mapFieldTypeToGraphQL(fieldConfig, true) as GraphQLInputType; + inputFields[sanitizedFieldName] = { + type: gqlType, + description: fieldConfig.label || fieldName + }; + } + + inputTypeMap[objectName] = new GraphQLInputObjectType({ + name: sanitizedTypeName + 'Input', + description: `Input type for ${config.label || objectName}`, + fields: inputFields + }); + + // Create delete result type (shared across all delete mutations for this object) + deleteResultTypeMap[objectName] = new GraphQLObjectType({ + name: 'Delete' + sanitizedTypeName + 'Result', + fields: { + id: { type: new GraphQLNonNull(GraphQLString) }, + deleted: { type: new GraphQLNonNull(GraphQLBoolean) } + } + }); + } + + // Build query root + const queryFields: GraphQLFieldConfigMap = {}; + + for (const config of objects) { + const objectName = config.name; + + if (!objectName || !typeMap[objectName]) continue; + + // Query single record by ID + queryFields[objectName] = { + type: typeMap[objectName], + args: { + id: { type: new GraphQLNonNull(GraphQLString) } + }, + resolve: async (_, args) => { + const result = await server.handle({ + op: 'findOne', + object: objectName, + args: args.id + }); + + if (result.error) { + throw new Error(result.error.message); + } + + return normalizeId(result); + } + }; + + // Query list of records + // Using 'List' suffix to avoid naming conflicts and handle irregular plurals + queryFields[objectName + 'List'] = { + type: new GraphQLList(typeMap[objectName]), + args: { + limit: { type: GraphQLInt }, + skip: { type: GraphQLInt }, + filters: { type: GraphQLString }, // JSON string + fields: { type: new GraphQLList(GraphQLString) }, + sort: { type: GraphQLString } // JSON string + }, + resolve: async (_, args) => { + const queryArgs: any = {}; + if (args.limit) queryArgs.limit = args.limit; + if (args.skip) queryArgs.skip = args.skip; + if (args.fields) queryArgs.fields = args.fields; + if (args.filters) { + try { + queryArgs.filters = JSON.parse(args.filters); + } catch (e) { + throw new Error('Invalid filters JSON'); + } + } + if (args.sort) { + try { + queryArgs.sort = JSON.parse(args.sort); + } catch (e) { + throw new Error('Invalid sort JSON'); + } + } + + const result = await server.handle({ + op: 'find', + object: objectName, + args: queryArgs + }); + + if (result.error) { + throw new Error(result.error.message); + } + + return normalizeId(result.items || []); + } + }; + } + + const queryType = new GraphQLObjectType({ + name: 'Query', + fields: queryFields + }); + + // Build mutation root + const mutationFields: GraphQLFieldConfigMap = {}; + + for (const config of objects) { + const objectName = config.name; + + if (!objectName || !typeMap[objectName] || !inputTypeMap[objectName]) continue; + + const capitalizedName = sanitizeGraphQLName(objectName.charAt(0).toUpperCase() + objectName.slice(1)); + + // Create mutation + mutationFields['create' + capitalizedName] = { + type: typeMap[objectName], + args: { + input: { type: new GraphQLNonNull(inputTypeMap[objectName]) } + }, + resolve: async (_, args) => { + const result = await server.handle({ + op: 'create', + object: objectName, + args: args.input + }); + + if (result.error) { + throw new Error(result.error.message); + } + + return normalizeId(result); + } + }; + + // Update mutation + mutationFields['update' + capitalizedName] = { + type: typeMap[objectName], + args: { + id: { type: new GraphQLNonNull(GraphQLString) }, + input: { type: new GraphQLNonNull(inputTypeMap[objectName]) } + }, + resolve: async (_, args) => { + const result = await server.handle({ + op: 'update', + object: objectName, + args: { + id: args.id, + data: args.input + } + }); + + if (result.error) { + throw new Error(result.error.message); + } + + return normalizeId(result); + } + }; + + // Delete mutation - use shared delete result type + mutationFields['delete' + capitalizedName] = { + type: deleteResultTypeMap[objectName], + args: { + id: { type: new GraphQLNonNull(GraphQLString) } + }, + resolve: async (_, args) => { + const result = await server.handle({ + op: 'delete', + object: objectName, + args: { id: args.id } + }); + + if (result.error) { + throw new Error(result.error.message); + } + + return result; + } + }; + } + + const mutationType = new GraphQLObjectType({ + name: 'Mutation', + fields: mutationFields + }); + + return new GraphQLSchema({ + query: queryType, + mutation: mutationType + }); +} + +/** + * Parse GraphQL request body + */ +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => body += chunk.toString()); + req.on('end', () => { + if (!body) return resolve({}); + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(new Error('Invalid JSON')); + } + }); + req.on('error', reject); + }); +} + +/** + * Send JSON response + */ +function sendJSON(res: ServerResponse, statusCode: number, data: any) { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = statusCode; + res.end(JSON.stringify(data)); +} + +/** + * Creates a GraphQL HTTP request handler for ObjectQL + * + * Endpoints: + * - POST /api/graphql - GraphQL queries and mutations + * - GET /api/graphql - GraphQL queries via URL parameters + */ +export function createGraphQLHandler(app: IObjectQL) { + // Generate schema once - Note: Schema is static after handler creation. + // If metadata changes at runtime, create a new handler or regenerate the schema. + const schema = generateGraphQLSchema(app); + + return async (req: IncomingMessage & { body?: any }, res: ServerResponse) => { + try { + // CORS headers + const requestOrigin = req.headers.origin; + const configuredOrigin = process.env.OBJECTQL_CORS_ORIGIN; + const isProduction = process.env.NODE_ENV === 'production'; + + if (!isProduction) { + res.setHeader('Access-Control-Allow-Origin', configuredOrigin || '*'); + } else if (configuredOrigin && (!requestOrigin || requestOrigin === configuredOrigin)) { + res.setHeader('Access-Control-Allow-Origin', configuredOrigin); + } + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { + res.statusCode = 200; + res.end(); + return; + } + + // HTML Playground Support (Apollo Sandbox) + // If it's a browser GET request without query params, show the playground + const acceptHeader = req.headers.accept || ''; + const urlObj = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`); + const hasQueryParams = urlObj.searchParams.has('query'); + + if (req.method === 'GET' && acceptHeader.includes('text/html') && !hasQueryParams) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(APOLLO_SANDBOX_HTML); + return; + } + + const url = req.url || ''; + const method = req.method || 'POST'; + + if (method !== 'GET' && method !== 'POST') { + sendJSON(res, 405, { + errors: [{ + message: 'Method not allowed. Use GET or POST.' + }] + }); + return; + } + + let query: string = ''; + let variables: any = null; + let operationName: string | null = null; + + if (method === 'GET') { + // Parse query string for GET requests + const urlObj = new URL(url, `http://${req.headers.host || 'localhost'}`); + query = urlObj.searchParams.get('query') || ''; + const varsParam = urlObj.searchParams.get('variables'); + if (varsParam) { + try { + variables = JSON.parse(varsParam); + } catch (e) { + sendJSON(res, 400, { + errors: [{ + message: 'Invalid variables JSON' + }] + }); + return; + } + } + operationName = urlObj.searchParams.get('operationName'); + } else { + // Parse body for POST requests + const body = req.body || await readBody(req); + query = body.query || ''; + variables = body.variables || null; + operationName = body.operationName || null; + } + + if (!query) { + sendJSON(res, 400, { + errors: [{ + message: 'Must provide query string' + }] + }); + return; + } + + // Execute GraphQL query + const result = await graphql({ + schema, + source: query, + variableValues: variables, + operationName, + contextValue: { app } + }); + + sendJSON(res, 200, result); + + } catch (e: any) { + console.error('[GraphQL Handler] Error:', e); + + const errorResponse: { + errors: Array<{ + message: string; + extensions: { + code: ErrorCode; + debug?: { + message?: string; + stack?: string; + }; + }; + }>; + } = { + errors: [{ + message: 'Internal server error', + extensions: { + code: ErrorCode.INTERNAL_ERROR + } + }] + }; + + // In non-production environments, include additional error details to aid debugging + if (typeof process !== 'undefined' && + process.env && + process.env.NODE_ENV !== 'production') { + const firstError = errorResponse.errors[0]; + firstError.extensions.debug = { + message: e && typeof e.message === 'string' ? e.message : undefined, + stack: e && typeof e.stack === 'string' ? e.stack : undefined + }; + } + + sendJSON(res, 500, errorResponse); + } + }; +} diff --git a/packages/plugins/server/src/adapters/hono.ts b/packages/plugins/server/src/adapters/hono.ts new file mode 100644 index 00000000..db6852c8 --- /dev/null +++ b/packages/plugins/server/src/adapters/hono.ts @@ -0,0 +1,258 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IObjectQL, ApiRouteConfig, resolveApiRoutes } from '@objectql/types'; +import { ObjectQLServer } from '../server'; +import { ObjectQLRequest, ErrorCode } from '../types'; + +/** + * Options for createHonoAdapter + */ +export interface HonoAdapterOptions { + /** Custom API route configuration */ + routes?: ApiRouteConfig; +} + +/** + * Creates a Hono-compatible middleware for ObjectQL + * + * This adapter integrates ObjectQL with the Hono web framework. + * + * @example + * ```typescript + * import { Hono } from 'hono'; + * import { ObjectQL } from '@objectql/core'; + * import { createHonoAdapter } from '@objectql/plugin-server/hono'; + * + * const app = new ObjectQL({ ... }); + * await app.init(); + * + * const server = new Hono(); + * const objectqlMiddleware = createHonoAdapter(app); + * + * server.all('/api/*', objectqlMiddleware); + * ``` + */ +export function createHonoAdapter(app: IObjectQL, options?: HonoAdapterOptions) { + const server = new ObjectQLServer(app); + const routes = resolveApiRoutes(options?.routes); + + // Return Hono-compatible handler + return async (c: any) => { + try { + const req = c.req; + const path = req.path; + const method = req.method; + + // Handle JSON-RPC endpoint + if (path === routes.rpc || path.startsWith(routes.rpc + '/')) { + if (method === 'POST') { + const body = await req.json(); + const qlReq: ObjectQLRequest = { + op: body.op, + object: body.object, + args: body.args, + user: body.user, + ai_context: body.ai_context + }; + + const result = await server.handle(qlReq); + + // Determine HTTP status code based on error + let statusCode = 200; + if (result.error) { + statusCode = getStatusCodeFromError(result.error.code as ErrorCode); + } + + return c.json(result, statusCode); + } + return c.json({ error: { code: ErrorCode.INVALID_REQUEST, message: 'Method not allowed' } }, 405); + } + + // Handle REST API endpoint + if (path.startsWith(routes.data + '/')) { + const pathParts = path.replace(routes.data + '/', '').split('/'); + const objectName = pathParts[0]; + const id = pathParts[1]; + + let qlReq: ObjectQLRequest; + + switch (method) { + case 'GET': + if (id) { + // GET /api/data/:object/:id - findOne + qlReq = { + op: 'findOne', + object: objectName, + args: id + }; + } else { + // GET /api/data/:object - find with query params + const query = req.query(); + const args: any = {}; + if (query.filter) args.filters = JSON.parse(query.filter); + if (query.fields) args.fields = query.fields.split(','); + if (query.limit || query.top) args.limit = parseInt(query.limit || query.top); + if (query.skip || query.offset) args.skip = parseInt(query.skip || query.offset); + + qlReq = { + op: 'find', + object: objectName, + args + }; + } + break; + + case 'POST': + const createBody = await req.json(); + if (Array.isArray(createBody)) { + // Bulk create + qlReq = { + op: 'createMany', + object: objectName, + args: createBody + }; + } else { + // Single create + qlReq = { + op: 'create', + object: objectName, + args: createBody + }; + } + break; + + case 'PUT': + case 'PATCH': + if (!id) { + return c.json({ + error: { + code: ErrorCode.INVALID_REQUEST, + message: 'ID is required for update' + } + }, 400); + } + const updateBody = await req.json(); + qlReq = { + op: 'update', + object: objectName, + args: { id, data: updateBody } + }; + break; + + case 'DELETE': + if (!id) { + return c.json({ + error: { + code: ErrorCode.INVALID_REQUEST, + message: 'ID is required for delete' + } + }, 400); + } + qlReq = { + op: 'delete', + object: objectName, + args: { id } + }; + break; + + default: + return c.json({ + error: { + code: ErrorCode.INVALID_REQUEST, + message: 'Method not allowed' + } + }, 405); + } + + const result = await server.handle(qlReq); + let statusCode = 200; + if (result.error) { + statusCode = getStatusCodeFromError(result.error.code as ErrorCode); + } else if (method === 'POST') { + statusCode = 201; + } + + return c.json(result, statusCode); + } + + // Handle Metadata endpoint + if (path.startsWith(routes.metadata + '/') || path === routes.metadata) { + const resource = path.replace(routes.metadata, '').replace(/^\//, ''); + + if (!resource || resource === 'object') { + // List all objects + const objects = app.metadata.list('object'); + return c.json({ objects }); + } + + if (resource.startsWith('object/')) { + // Get specific object + const objectName = resource.replace('object/', ''); + const obj = app.getObject(objectName); + if (!obj) { + return c.json({ + error: { + code: ErrorCode.NOT_FOUND, + message: `Object '${objectName}' not found` + } + }, 404); + } + return c.json({ object: obj }); + } + + return c.json({ + error: { + code: ErrorCode.NOT_FOUND, + message: 'Metadata resource not found' + } + }, 404); + } + + // Default 404 + return c.json({ + error: { + code: ErrorCode.NOT_FOUND, + message: 'Endpoint not found' + } + }, 404); + + } catch (e: any) { + console.error('[Hono Adapter] Error:', e); + return c.json({ + error: { + code: ErrorCode.INTERNAL_ERROR, + message: 'Internal server error' + } + }, 500); + } + }; +} + +/** + * Map ObjectQL error codes to HTTP status codes + */ +function getStatusCodeFromError(code: ErrorCode): number { + switch (code) { + case ErrorCode.INVALID_REQUEST: + case ErrorCode.VALIDATION_ERROR: + return 400; + case ErrorCode.UNAUTHORIZED: + return 401; + case ErrorCode.FORBIDDEN: + return 403; + case ErrorCode.NOT_FOUND: + return 404; + case ErrorCode.CONFLICT: + return 409; + case ErrorCode.RATE_LIMIT_EXCEEDED: + return 429; + default: + return 500; + } +} diff --git a/packages/plugins/server/src/adapters/node.ts b/packages/plugins/server/src/adapters/node.ts new file mode 100644 index 00000000..3542d580 --- /dev/null +++ b/packages/plugins/server/src/adapters/node.ts @@ -0,0 +1,293 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IObjectQL, ApiRouteConfig, resolveApiRoutes } from '@objectql/types'; +import { ObjectQLServer } from '../server'; +import { ObjectQLRequest, ErrorCode, IFileStorage } from '../types'; +import { IncomingMessage, ServerResponse } from 'http'; +import { generateOpenAPI } from '../openapi'; +import { createFileUploadHandler, createBatchFileUploadHandler, createFileDownloadHandler } from '../file-handler'; +import { LocalFileStorage } from '../storage'; +import { escapeRegexPath } from '../utils'; +import { getWelcomePageHtml } from '../templates'; + +/** + * Options for createNodeHandler + */ +export interface NodeHandlerOptions { + /** File storage provider (defaults to LocalFileStorage) */ + fileStorage?: IFileStorage; + /** Custom API route configuration */ + routes?: ApiRouteConfig; +} + +/** + * Creates a standard Node.js HTTP request handler. + */ +export function createNodeHandler(app: IObjectQL, options?: NodeHandlerOptions) { + const server = new ObjectQLServer(app); + const routes = resolveApiRoutes(options?.routes); + + // Initialize file storage + const defaultBaseUrl = process.env.OBJECTQL_BASE_URL || `http://localhost:3000${routes.files}`; + const fileStorage = options?.fileStorage || new LocalFileStorage({ + baseDir: process.env.OBJECTQL_UPLOAD_DIR || './uploads', + baseUrl: defaultBaseUrl + }); + + // Create file handlers + const uploadHandler = createFileUploadHandler(fileStorage, app); + const batchUploadHandler = createBatchFileUploadHandler(fileStorage, app); + const downloadHandler = createFileDownloadHandler(fileStorage); + + + return async (req: IncomingMessage & { body?: any }, res: ServerResponse) => { + // CORS Headers + const origin = req.headers.origin; + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } else { + res.setHeader('Access-Control-Allow-Origin', '*'); + } + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + // Handle OpenAPI spec request + if (req.method === 'GET' && req.url?.endsWith('/openapi.json')) { + const spec = generateOpenAPI(app); + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 200; + res.end(JSON.stringify(spec)); + return; + } + + const handleRequest = async (json: any) => { + try { + // Determine Operation based on JSON or previously derived info + const qlReq: ObjectQLRequest = { + op: json.op, + object: json.object, + args: json.args, + user: json.user, + ai_context: json.ai_context + }; + + const result = await server.handle(qlReq); + + // Determine HTTP status code based on error + let statusCode = 200; + if (result.error) { + switch (result.error.code) { + case ErrorCode.INVALID_REQUEST: + case ErrorCode.VALIDATION_ERROR: + statusCode = 400; + break; + case ErrorCode.UNAUTHORIZED: + statusCode = 401; + break; + case ErrorCode.FORBIDDEN: + statusCode = 403; + break; + case ErrorCode.NOT_FOUND: + statusCode = 404; + break; + case ErrorCode.CONFLICT: + statusCode = 409; + break; + case ErrorCode.RATE_LIMIT_EXCEEDED: + statusCode = 429; + break; + default: + statusCode = 500; + } + } + + res.setHeader('Content-Type', 'application/json'); + res.statusCode = statusCode; + res.end(JSON.stringify(result)); + } catch (e) { + console.error(e); + res.statusCode = 500; + res.end(JSON.stringify({ + error: { + code: ErrorCode.INTERNAL_ERROR, + message: 'Internal Server Error' + } + })); + } + }; + + // Parse URL + const urlObj = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + const pathName = urlObj.pathname; + const method = req.method; + + // 1. JSON-RPC: POST {rpcPath} + if (pathName === routes.rpc && method === 'POST') { + await processBody(req, async (json) => { + await handleRequest(json); + }, res); + return; + } + + // 2. REST API: {dataPath}/:object and {dataPath}/:object/:id + // Regex to match {dataPath}/objectName(/id)? + const escapedDataPath = escapeRegexPath(routes.data); + const restMatch = pathName.match(new RegExp(`^${escapedDataPath}/([^/]+)(?:/(.+))?$`)); + + if (restMatch) { + const objectName = restMatch[1]; + const id = restMatch[2]; + const query = Object.fromEntries(urlObj.searchParams.entries()); + + if (method === 'GET') { + // GET {dataPath}/:object/:id -> findOne + if (id) { + await handleRequest({ + op: 'findOne', + object: objectName, + args: id + }); + } + // GET {dataPath}/:object -> find (List) + else { + // Parse standard params + const args: any = {}; + if (query.fields) args.fields = (query.fields as string).split(','); + if (query.top) args.limit = parseInt(query.top as string); + if (query.skip) args.skip = parseInt(query.skip as string); + if (query.filter) { + try { + args.filters = JSON.parse(query.filter as string); + } catch (e) { + // ignore invalid filter json + } + } + await handleRequest({ op: 'find', object: objectName, args }); + } + return; + } + + if (method === 'POST' && !id) { + // POST {dataPath}/:object -> create + await processBody(req, async (body) => { + await handleRequest({ + op: 'create', + object: objectName, + args: body.data || body // Support enclosed in data or flat + }); + }, res); + return; + } + + if (method === 'PATCH' && id) { + // PATCH {dataPath}/:object/:id -> update + await processBody(req, async (body) => { + await handleRequest({ + op: 'update', + object: objectName, + args: { + id: id, + data: body.data || body + } + }); + }, res); + return; + } + + if (method === 'DELETE' && id) { + // DELETE {dataPath}/:object/:id -> delete + await handleRequest({ + op: 'delete', + object: objectName, + args: { id: id } + }); + return; + } + } + + // File Upload Endpoints + // POST {filesPath}/upload - Single file upload + if (pathName === `${routes.files}/upload` && method === 'POST') { + await uploadHandler(req, res); + return; + } + + // POST {filesPath}/upload/batch - Batch file upload + if (pathName === `${routes.files}/upload/batch` && method === 'POST') { + await batchUploadHandler(req, res); + return; + } + + // GET {filesPath}/:fileId - Download file + const escapedFilesPath = escapeRegexPath(routes.files); + const fileMatch = pathName.match(new RegExp(`^${escapedFilesPath}/([^/]+)$`)); + if (fileMatch && method === 'GET') { + const fileId = fileMatch[1]; + await downloadHandler(req, res, fileId); + return; + } + + // Fallback or 404 + if (req.method === 'POST') { + // Fallback for root POSTs if people forget {rpcPath} but send to /api something + await processBody(req, handleRequest, res); + return; + } + + // Special case for root: since we accept POST / (RPC), correct response for GET / is 405 + if (pathName === '/') { + if (method === 'GET') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.statusCode = 200; + res.end(getWelcomePageHtml(routes)); + return; + } + + res.setHeader('Allow', 'POST'); + res.statusCode = 405; + res.end(JSON.stringify({ error: { code: ErrorCode.INVALID_REQUEST, message: 'Method Not Allowed. Use POST for JSON-RPC.' } })); + return; + } + + res.statusCode = 404; + res.end(JSON.stringify({ error: { code: ErrorCode.NOT_FOUND, message: 'Not Found' } })); + }; +} + +// Helper to process body +async function processBody(req: IncomingMessage & { body?: any }, callback: (json: any) => Promise, res: ServerResponse) { + if (req.body && typeof req.body === 'object') { + return callback(req.body); + } + + let body = ''; + req.on('data', chunk => body += chunk); + req.on('end', async () => { + try { + const json = body ? JSON.parse(body) : {}; + await callback(json); + } catch (e) { + res.statusCode = 400; + res.end(JSON.stringify({ + error: { + code: 'INVALID_JSON', + message: 'Invalid JSON body' + } + })); + } + }); +} diff --git a/packages/plugins/server/src/adapters/rest.ts b/packages/plugins/server/src/adapters/rest.ts new file mode 100644 index 00000000..d0396320 --- /dev/null +++ b/packages/plugins/server/src/adapters/rest.ts @@ -0,0 +1,338 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IObjectQL, ApiRouteConfig, resolveApiRoutes } from '@objectql/types'; +import { ObjectQLServer } from '../server'; +import { ObjectQLRequest, ErrorCode } from '../types'; +import { IncomingMessage, ServerResponse } from 'http'; +import { escapeRegexPath } from '../utils'; + +/** + * Parse query string parameters + */ +function parseQueryParams(url: string): Record { + const params: Record = {}; + const queryIndex = url.indexOf('?'); + if (queryIndex === -1) return params; + + const queryString = url.substring(queryIndex + 1); + const pairs = queryString.split('&'); + + for (const pair of pairs) { + const [key, value] = pair.split('='); + if (!key) continue; + + const decodedKey = decodeURIComponent(key); + const decodedValue = decodeURIComponent(value || ''); + + // Try to parse JSON values + try { + params[decodedKey] = JSON.parse(decodedValue); + } catch { + params[decodedKey] = decodedValue; + } + } + + return params; +} + +/** + * Read request body as JSON + */ +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => body += chunk.toString()); + req.on('end', () => { + if (!body) return resolve({}); + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(new Error('Invalid JSON')); + } + }); + req.on('error', reject); + }); +} + +/** + * Send JSON response + */ +function sendJSON(res: ServerResponse, statusCode: number, data: any) { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = statusCode; + res.end(JSON.stringify(data)); +} + +/** + * Options for createRESTHandler + */ +export interface RESTHandlerOptions { + /** Custom API route configuration */ + routes?: ApiRouteConfig; +} + +/** + * Creates a REST-style HTTP request handler for ObjectQL + * + * Default Endpoints (configurable via routes option): + * - GET {dataPath}/:object - List records + * - GET {dataPath}/:object/:id - Get single record + * - POST {dataPath}/:object - Create record (or create many if array) + * - POST {dataPath}/:object/bulk-update - Update many records + * - POST {dataPath}/:object/bulk-delete - Delete many records + * - PUT {dataPath}/:object/:id - Update record + * - DELETE {dataPath}/:object/:id - Delete record + * + * @param app - ObjectQL application instance + * @param options - Optional configuration including custom routes + */ +export function createRESTHandler(app: IObjectQL, options?: RESTHandlerOptions) { + const server = new ObjectQLServer(app); + const routes = resolveApiRoutes(options?.routes); + const dataPath = routes.data; + + return async (req: IncomingMessage & { body?: any }, res: ServerResponse) => { + try { + // CORS headers + const requestOrigin = req.headers.origin; + const configuredOrigin = process.env.OBJECTQL_CORS_ORIGIN; + const isProduction = process.env.NODE_ENV === 'production'; + + // In development, allow all origins by default (or use configured override). + // In production, require an explicit OBJECTQL_CORS_ORIGIN to be set. + if (!isProduction) { + res.setHeader('Access-Control-Allow-Origin', configuredOrigin || '*'); + } else if (configuredOrigin && (!requestOrigin || requestOrigin === configuredOrigin)) { + res.setHeader('Access-Control-Allow-Origin', configuredOrigin); + } + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { + res.statusCode = 200; + res.end(); + return; + } + + const url = req.url || ''; + const method = req.method || 'GET'; + + // Parse URL: {dataPath}/:object or {dataPath}/:object/:id or {dataPath}/:object/bulk-* + const escapedPath = escapeRegexPath(dataPath); + const match = url.match(new RegExp(`^${escapedPath}/([^/\\?]+)(?:/([^/\\?]+))?(\\?.*)?$`)); + + if (!match) { + sendJSON(res, 404, { + error: { + code: ErrorCode.NOT_FOUND, + message: 'Invalid REST API endpoint' + } + }); + return; + } + + const [, objectName, id, queryString] = match; + const queryParams = queryString ? parseQueryParams(queryString) : {}; + + let qlRequest: ObjectQLRequest; + + switch (method) { + case 'GET': + if (id) { + // GET /api/data/:object/:id - Get single record + qlRequest = { + op: 'findOne', + object: objectName, + args: id + }; + } else { + // GET {dataPath}/:object - List records + const args: any = {}; + + // Parse query parameters + if (queryParams.filter) { + args.filters = queryParams.filter; + } + if (queryParams.fields) { + args.fields = queryParams.fields; + } + if (queryParams.sort) { + args.sort = Array.isArray(queryParams.sort) + ? queryParams.sort + : [[queryParams.sort, 'asc']]; + } + if (queryParams.top || queryParams.limit) { + args.limit = queryParams.top || queryParams.limit; + } + if (queryParams.skip || queryParams.offset) { + args.skip = queryParams.skip || queryParams.offset; + } + if (queryParams.expand) { + args.expand = queryParams.expand; + } + + qlRequest = { + op: 'find', + object: objectName, + args + }; + } + break; + + case 'POST': + const createBody = req.body || await readBody(req); + + // Check for bulk operations + if (id === 'bulk-update') { + // POST {dataPath}/:object/bulk-update - Update many records + qlRequest = { + op: 'updateMany', + object: objectName, + args: { + filters: createBody.filters, + data: createBody.data + } + }; + } else if (id === 'bulk-delete') { + // POST {dataPath}/:object/bulk-delete - Delete many records + qlRequest = { + op: 'deleteMany', + object: objectName, + args: { + filters: createBody.filters || {} + } + }; + } else if (Array.isArray(createBody)) { + // POST {dataPath}/:object with array - Create many records + qlRequest = { + op: 'createMany', + object: objectName, + args: createBody + }; + } else { + // POST {dataPath}/:object - Create single record + qlRequest = { + op: 'create', + object: objectName, + args: createBody + }; + } + break; + + case 'PUT': + case 'PATCH': + // PUT {dataPath}/:object/:id - Update record + if (!id) { + sendJSON(res, 400, { + error: { + code: ErrorCode.INVALID_REQUEST, + message: 'ID is required for update operation' + } + }); + return; + } + + const updateBody = req.body || await readBody(req); + qlRequest = { + op: 'update', + object: objectName, + args: { + id, + data: updateBody + } + }; + break; + + case 'DELETE': + // DELETE {dataPath}/:object/:id - Delete record + if (!id) { + sendJSON(res, 400, { + error: { + code: ErrorCode.INVALID_REQUEST, + message: 'ID is required for delete operation' + } + }); + return; + } + + qlRequest = { + op: 'delete', + object: objectName, + args: { id } + }; + break; + + default: + sendJSON(res, 405, { + error: { + code: ErrorCode.INVALID_REQUEST, + message: 'Method not allowed' + } + }); + return; + } + + // Execute the request + const result = await server.handle(qlRequest); + + if (!result) { + sendJSON(res, 404, { + error: { + code: ErrorCode.NOT_FOUND, + message: 'Resource not found' + } + }); + return; + } + + // Determine HTTP status code + let statusCode = 200; + if (result.error) { + switch (result.error.code) { + case ErrorCode.INVALID_REQUEST: + case ErrorCode.VALIDATION_ERROR: + statusCode = 400; + break; + case ErrorCode.UNAUTHORIZED: + statusCode = 401; + break; + case ErrorCode.FORBIDDEN: + statusCode = 403; + break; + case ErrorCode.NOT_FOUND: + statusCode = 404; + break; + case ErrorCode.CONFLICT: + statusCode = 409; + break; + case ErrorCode.RATE_LIMIT_EXCEEDED: + statusCode = 429; + break; + default: + statusCode = 500; + } + } else if (method === 'POST' && qlRequest.op === 'create') { + statusCode = 201; // Created - only for single create + } else if (method === 'POST' && qlRequest.op === 'createMany') { + statusCode = 201; // Created - for bulk create + } + + sendJSON(res, statusCode, result); + + } catch (e: any) { + console.error('[REST Handler] Error:', e); + sendJSON(res, 500, { + error: { + code: ErrorCode.INTERNAL_ERROR, + message: 'Internal server error' + } + }); + } + }; +} diff --git a/packages/plugins/server/src/file-handler.ts b/packages/plugins/server/src/file-handler.ts new file mode 100644 index 00000000..03846347 --- /dev/null +++ b/packages/plugins/server/src/file-handler.ts @@ -0,0 +1,422 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IncomingMessage, ServerResponse } from 'http'; +import { IFileStorage, AttachmentData, ErrorCode } from './types'; +import { IObjectQL, FieldConfig } from '@objectql/types'; + +/** + * Parse multipart/form-data request + */ +export function parseMultipart( + req: IncomingMessage, + boundary: string +): Promise<{ fields: Record; files: Array<{ fieldname: string; filename: string; mimeType: string; buffer: Buffer }> }> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + req.on('data', (chunk) => chunks.push(chunk)); + req.on('error', reject); + req.on('end', () => { + try { + const buffer = Buffer.concat(chunks); + const result = parseMultipartBuffer(buffer, boundary); + resolve(result); + } catch (error) { + reject(error); + } + }); + }); +} + +function parseMultipartBuffer( + buffer: Buffer, + boundary: string +): { fields: Record; files: Array<{ fieldname: string; filename: string; mimeType: string; buffer: Buffer }> } { + const fields: Record = {}; + const files: Array<{ fieldname: string; filename: string; mimeType: string; buffer: Buffer }> = []; + + const delimiter = Buffer.from(`--${boundary}`); + const parts = splitBuffer(buffer, delimiter); + + for (const part of parts) { + if (part.length === 0 || part.toString().trim() === '--') { + continue; + } + + // Find header/body separator (double CRLF) + const headerEnd = findSequence(part, Buffer.from('\r\n\r\n')); + if (headerEnd === -1) continue; + + const headerSection = part.slice(0, headerEnd).toString(); + const bodySection = part.slice(headerEnd + 4); + + // Parse Content-Disposition header + const dispositionMatch = headerSection.match(/Content-Disposition: form-data; name="([^"]+)"(?:; filename="([^"]+)")?/i); + if (!dispositionMatch) continue; + + const fieldname = dispositionMatch[1]; + const filename = dispositionMatch[2]; + + if (filename) { + // This is a file upload + const contentTypeMatch = headerSection.match(/Content-Type: (.+)/i); + const mimeType = contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream'; + + // Remove trailing CRLF from body + let fileBuffer = bodySection; + if (fileBuffer.length >= 2 && fileBuffer[fileBuffer.length - 2] === 0x0d && fileBuffer[fileBuffer.length - 1] === 0x0a) { + fileBuffer = fileBuffer.slice(0, -2); + } + + files.push({ fieldname, filename, mimeType, buffer: fileBuffer }); + } else { + // This is a regular form field + let value = bodySection.toString('utf-8'); + if (value.endsWith('\r\n')) { + value = value.slice(0, -2); + } + fields[fieldname] = value; + } + } + + return { fields, files }; +} + +function splitBuffer(buffer: Buffer, delimiter: Buffer): Buffer[] { + const parts: Buffer[] = []; + let start = 0; + let pos = 0; + + while (pos <= buffer.length - delimiter.length) { + let match = true; + for (let i = 0; i < delimiter.length; i++) { + if (buffer[pos + i] !== delimiter[i]) { + match = false; + break; + } + } + + if (match) { + if (pos > start) { + parts.push(buffer.slice(start, pos)); + } + pos += delimiter.length; + start = pos; + } else { + pos++; + } + } + + if (start < buffer.length) { + parts.push(buffer.slice(start)); + } + + return parts; +} + +function findSequence(buffer: Buffer, sequence: Buffer): number { + for (let i = 0; i <= buffer.length - sequence.length; i++) { + let match = true; + for (let j = 0; j < sequence.length; j++) { + if (buffer[i + j] !== sequence[j]) { + match = false; + break; + } + } + if (match) return i; + } + return -1; +} + +/** + * Validate uploaded file against field configuration + */ +export function validateFile( + file: { filename: string; mimeType: string; buffer: Buffer }, + fieldConfig?: FieldConfig, + objectName?: string, + fieldName?: string +): { valid: boolean; error?: { code: string; message: string; details?: any } } { + // If no field config provided, allow the upload + if (!fieldConfig) { + return { valid: true }; + } + + const fileSize = file.buffer.length; + const fileName = file.filename; + const mimeType = file.mimeType; + + // Validate file size + if (fieldConfig.max_size && fileSize > fieldConfig.max_size) { + return { + valid: false, + error: { + code: 'FILE_TOO_LARGE', + message: `File size (${fileSize} bytes) exceeds maximum allowed size (${fieldConfig.max_size} bytes)`, + details: { + file: fileName, + size: fileSize, + max_size: fieldConfig.max_size + } + } + }; + } + + if (fieldConfig.min_size && fileSize < fieldConfig.min_size) { + return { + valid: false, + error: { + code: 'FILE_TOO_SMALL', + message: `File size (${fileSize} bytes) is below minimum required size (${fieldConfig.min_size} bytes)`, + details: { + file: fileName, + size: fileSize, + min_size: fieldConfig.min_size + } + } + }; + } + + // Validate file type/extension + if (fieldConfig.accept && Array.isArray(fieldConfig.accept) && fieldConfig.accept.length > 0) { + const fileExt = fileName.substring(fileName.lastIndexOf('.')).toLowerCase(); + const acceptedExtensions = fieldConfig.accept.map(ext => ext.toLowerCase()); + + if (!acceptedExtensions.includes(fileExt)) { + return { + valid: false, + error: { + code: 'FILE_TYPE_NOT_ALLOWED', + message: `File type '${fileExt}' is not allowed. Allowed types: ${acceptedExtensions.join(', ')}`, + details: { + file: fileName, + extension: fileExt, + allowed: acceptedExtensions + } + } + }; + } + } + + return { valid: true }; +} + +/** + * Send error response + */ +export function sendError(res: ServerResponse, statusCode: number, code: string, message: string, details?: any) { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = statusCode; + res.end(JSON.stringify({ + error: { + code, + message, + details + } + })); +} + +/** + * Send success response + */ +export function sendSuccess(res: ServerResponse, data: any) { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 200; + res.end(JSON.stringify({ data })); +} + +/** + * Extract user ID from authorization header + * @internal This is a placeholder implementation. In production, integrate with actual auth middleware. + */ +function extractUserId(authHeader: string | undefined): string | undefined { + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return undefined; + } + + // TODO: In production, decode JWT or validate token properly + // This is a placeholder implementation + console.warn('[Security] File upload authentication is using placeholder implementation. Integrate with actual auth system.'); + return 'user_from_token'; +} + +/** + * Create file upload handler + */ +export function createFileUploadHandler(storage: IFileStorage, app: IObjectQL) { + return async (req: IncomingMessage, res: ServerResponse) => { + try { + // Check content type + const contentType = req.headers['content-type']; + if (!contentType || !contentType.startsWith('multipart/form-data')) { + sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Content-Type must be multipart/form-data'); + return; + } + + // Extract boundary + const boundaryMatch = contentType.match(/boundary=(.+)/); + if (!boundaryMatch) { + sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Missing boundary in Content-Type'); + return; + } + + const boundary = boundaryMatch[1]; + + // Parse multipart data + const { fields, files } = await parseMultipart(req, boundary); + + if (files.length === 0) { + sendError(res, 400, ErrorCode.INVALID_REQUEST, 'No file provided'); + return; + } + + // Get field configuration if object and field are specified + let fieldConfig: FieldConfig | undefined; + if (fields.object && fields.field) { + const objectConfig = (app as any).getObject(fields.object); + if (objectConfig && objectConfig.fields) { + fieldConfig = objectConfig.fields[fields.field]; + } + } + + // Single file upload + const file = files[0]; + + // Validate file + const validation = validateFile(file, fieldConfig, fields.object, fields.field); + if (!validation.valid) { + sendError(res, 400, validation.error!.code, validation.error!.message, validation.error!.details); + return; + } + + // Extract user ID from authorization header + const userId = extractUserId(req.headers.authorization); + + // Save file + const attachmentData = await storage.save( + file.buffer, + file.filename, + file.mimeType, + { + folder: fields.folder, + object: fields.object, + field: fields.field, + userId + } + ); + + sendSuccess(res, attachmentData); + } catch (error) { + console.error('File upload error:', error); + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'File upload failed'); + } + }; +} + +/** + * Create batch file upload handler + */ +export function createBatchFileUploadHandler(storage: IFileStorage, app: IObjectQL) { + return async (req: IncomingMessage, res: ServerResponse) => { + try { + // Check content type + const contentType = req.headers['content-type']; + if (!contentType || !contentType.startsWith('multipart/form-data')) { + sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Content-Type must be multipart/form-data'); + return; + } + + // Extract boundary + const boundaryMatch = contentType.match(/boundary=(.+)/); + if (!boundaryMatch) { + sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Missing boundary in Content-Type'); + return; + } + + const boundary = boundaryMatch[1]; + + // Parse multipart data + const { fields, files } = await parseMultipart(req, boundary); + + if (files.length === 0) { + sendError(res, 400, ErrorCode.INVALID_REQUEST, 'No files provided'); + return; + } + + // Get field configuration if object and field are specified + let fieldConfig: FieldConfig | undefined; + if (fields.object && fields.field) { + const objectConfig = (app as any).getObject(fields.object); + if (objectConfig && objectConfig.fields) { + fieldConfig = objectConfig.fields[fields.field]; + } + } + + // Extract user ID from authorization header + const userId = extractUserId(req.headers.authorization); + + // Upload all files + const uploadedFiles: AttachmentData[] = []; + + for (const file of files) { + // Validate each file + const validation = validateFile(file, fieldConfig, fields.object, fields.field); + if (!validation.valid) { + sendError(res, 400, validation.error!.code, validation.error!.message, validation.error!.details); + return; + } + + // Save file + const attachmentData = await storage.save( + file.buffer, + file.filename, + file.mimeType, + { + folder: fields.folder, + object: fields.object, + field: fields.field, + userId + } + ); + + uploadedFiles.push(attachmentData); + } + + sendSuccess(res, uploadedFiles); + } catch (error) { + console.error('Batch file upload error:', error); + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Batch file upload failed'); + } + }; +} + +/** + * Create file download handler + */ +export function createFileDownloadHandler(storage: IFileStorage) { + return async (req: IncomingMessage, res: ServerResponse, fileId: string) => { + try { + const file = await storage.get(fileId); + + if (!file) { + sendError(res, 404, ErrorCode.NOT_FOUND, 'File not found'); + return; + } + + // Set appropriate headers + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Length', file.length); + res.statusCode = 200; + res.end(file); + } catch (error) { + console.error('File download error:', error); + sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'File download failed'); + } + }; +} diff --git a/packages/plugins/server/src/index.ts b/packages/plugins/server/src/index.ts new file mode 100644 index 00000000..5649a368 --- /dev/null +++ b/packages/plugins/server/src/index.ts @@ -0,0 +1,25 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// Re-export plugin +export * from './plugin'; + +// Re-export all core server functionality +export * from './types'; +export * from './utils'; +export * from './openapi'; +export * from './server'; +export * from './metadata'; +export * from './storage'; +export * from './file-handler'; + +// Re-export adapters +export * from './adapters/node'; +export * from './adapters/rest'; +export * from './adapters/graphql'; +export * from './adapters/hono'; diff --git a/packages/plugins/server/src/metadata.ts b/packages/plugins/server/src/metadata.ts new file mode 100644 index 00000000..ae655f6e --- /dev/null +++ b/packages/plugins/server/src/metadata.ts @@ -0,0 +1,244 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IObjectQL, ApiRouteConfig, resolveApiRoutes } from '@objectql/types'; +import { IncomingMessage, ServerResponse } from 'http'; +import { ErrorCode } from './types'; +import { escapeRegexPath } from './utils'; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => body += chunk.toString()); + req.on('end', () => { + if (!body) return resolve({}); + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); +} + +/** + * Options for createMetadataHandler + */ +export interface MetadataHandlerOptions { + /** Custom API route configuration */ + routes?: ApiRouteConfig; +} + +/** + * Creates a handler for metadata endpoints. + * These endpoints expose information about registered objects and other metadata. + * + * @param app - ObjectQL application instance + * @param options - Optional configuration including custom routes + */ +export function createMetadataHandler(app: IObjectQL, options?: MetadataHandlerOptions) { + const routes = resolveApiRoutes(options?.routes); + const metadataPath = routes.metadata; + return async (req: IncomingMessage, res: ServerResponse) => { + // Parse the URL + const url = req.url || ''; + const method = req.method; + + // CORS headers for development + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.statusCode = 200; + res.end(); + return; + } + + try { + // Helper to send JSON + const sendJson = (data: any, status = 200) => { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = status; + res.end(JSON.stringify(data)); + }; + + const sendError = (code: ErrorCode, message: string, status = 400) => { + sendJson({ error: { code, message } }, status); + }; + + // --------------------------------------------------------- + // 1. List Entries (GET {metadataPath}/:type) + // --------------------------------------------------------- + + // Generic List: {metadataPath}/:type + // Also handles legacy {metadataPath} (defaults to objects) + const escapedPath = escapeRegexPath(metadataPath); + const listMatch = url.match(new RegExp(`^${escapedPath}/([^/]+)$`)); + const isRootMetadata = url === metadataPath; + + if (method === 'GET' && (listMatch || isRootMetadata)) { + let type = isRootMetadata ? 'object' : listMatch![1]; + if (type === 'objects') type = 'object'; // Alias behavior + + if (type === 'object') { + const configs = app.getConfigs(); + const objects = Object.values(configs).map(obj => ({ + name: obj.name, + label: obj.label || obj.name, + icon: obj.icon, + description: obj.description, + fields: obj.fields || {} + })); + // Return standardized format with items + return sendJson({ items: objects }); + } + + const entries = app.metadata.list(type); + // Return standardized list format + return sendJson({ + items: entries + }); + } + + // --------------------------------------------------------- + // 2. Get Single Entry (GET {metadataPath}/:type/:id) + // --------------------------------------------------------- + + const detailMatch = url.match(new RegExp(`^${escapedPath}/([^/]+)/([^/\\?]+)$`)); + + if (method === 'GET' && detailMatch) { + let [, type, id] = detailMatch; + if (type === 'objects') type = 'object'; + + // Handle Object Special Logic (Field Formatting) + if (type === 'object') { + const metadata = app.getObject(id); + if (!metadata) { + return sendError(ErrorCode.NOT_FOUND, `Object '${id}' not found`, 404); + } + + // Convert fields to map with name populated + const fields: Record = {}; + if (metadata.fields) { + Object.entries(metadata.fields).forEach(([key, field]) => { + fields[key] = { + ...field, + name: field.name || key + }; + }); + } + + return sendJson({ + ...metadata, + fields + }); + } else { + // Generic Metadata (View, Form, etc.) + const content = app.metadata.get(type, id); + if (!content) { + return sendError(ErrorCode.NOT_FOUND, `${type} '${id}' not found`, 404); + } + return sendJson(content); + } + } + + // --------------------------------------------------------- + // 3. Update Entry (POST/PUT {metadataPath}/:type/:id) + // --------------------------------------------------------- + if ((method === 'POST' || method === 'PUT') && detailMatch) { + let [, type, id] = detailMatch; + if (type === 'objects') type = 'object'; + + const body = await readBody(req); + try { + // await app.updateMetadata(type, id, body); + // return sendJson({ success: true }); + return sendError(ErrorCode.INTERNAL_ERROR, 'Metadata updates via API are temporarily disabled in this architectural version.', 501); + } catch (e: any) { + const isUserError = e.message.startsWith('Cannot update') || e.message.includes('not found'); + return sendError( + isUserError ? ErrorCode.INVALID_REQUEST : ErrorCode.INTERNAL_ERROR, + e.message, + isUserError ? 400 : 500 + ); + } + } + + // --------------------------------------------------------- + // 4. Object Sub-resources (Fields, Actions) + // --------------------------------------------------------- + + // GET {metadataPath}/object/:name/fields/:field + // Legacy path support. + const fieldMatch = url.match(new RegExp(`^${escapedPath}/(?:objects|object)/([^/]+)/fields/([^/\\?]+)$`)); + if (method === 'GET' && fieldMatch) { + const [, objectName, fieldName] = fieldMatch; + const metadata = app.getObject(objectName); + + if (!metadata) return sendError(ErrorCode.NOT_FOUND, `Object '${objectName}' not found`, 404); + + const field = metadata.fields?.[fieldName]; + if (!field) return sendError(ErrorCode.NOT_FOUND, `Field '${fieldName}' not found`, 404); + + return sendJson({ + name: field.name || fieldName, + type: field.type, + label: field.label, + required: field.required, + unique: field.unique, + defaultValue: field.defaultValue, + options: field.options, + min: field.min, + max: field.max, + minLength: field.minLength, + maxLength: field.maxLength, + validation: field.validation + }); + } + + // GET {metadataPath}/object/:name/actions + const actionsMatch = url.match(new RegExp(`^${escapedPath}/(?:objects|object)/([^/]+)/actions$`)); + if (method === 'GET' && actionsMatch) { + const [, objectName] = actionsMatch; + const metadata = app.getObject(objectName); + + if (!metadata) return sendError(ErrorCode.NOT_FOUND, `Object '${objectName}' not found`, 404); + + const actions = metadata.actions || {}; + const formattedActions = Object.entries(actions).map(([key, action]) => { + const actionConfig = action as any; + const hasFields = !!actionConfig.fields && Object.keys(actionConfig.fields).length > 0; + return { + name: key, + type: actionConfig.type || (hasFields ? 'record' : 'global'), + label: actionConfig.label || key, + params: actionConfig.params || {}, + description: actionConfig.description + }; + }); + + return sendJson({ items: formattedActions }); + } + + // Not found + sendError(ErrorCode.NOT_FOUND, 'Not Found', 404); + + } catch (e: any) { + console.error('[Metadata Handler] Error:', e); + res.statusCode = 500; + res.end(JSON.stringify({ + error: { + code: ErrorCode.INTERNAL_ERROR, + message: 'Internal Server Error' + } + })); + } + }; +} diff --git a/packages/plugins/server/src/openapi.ts b/packages/plugins/server/src/openapi.ts new file mode 100644 index 00000000..e122295d --- /dev/null +++ b/packages/plugins/server/src/openapi.ts @@ -0,0 +1,207 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IObjectQL, ObjectConfig, FieldConfig, ApiRouteConfig, resolveApiRoutes } from '@objectql/types'; + +interface OpenAPISchema { + openapi: string; + info: { + title: string; + version: string; + }; + paths: Record; + components: { + schemas: Record; + }; +} + +export function generateOpenAPI(app: IObjectQL, routeConfig?: ApiRouteConfig): OpenAPISchema { + const registry = (app as any).metadata; // Direct access or via interface + const objects = registry.list('object') as ObjectConfig[]; + const routes = resolveApiRoutes(routeConfig); + + const paths: Record = {}; + const schemas: Record = {}; + + + // 1. JSON-RPC Endpoint + paths[routes.rpc] = { + post: { + summary: 'JSON-RPC Entry Point', + description: 'Execute any ObjectQL operation via a JSON body.', + tags: ['System'], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + op: { type: 'string', enum: ['find', 'findOne', 'create', 'update', 'delete', 'count', 'action'] }, + object: { type: 'string' }, + args: { type: 'object' } + }, + required: ['op', 'object'] + } + } + } + }, + responses: { + 200: { + description: 'Operation Result', + content: { + 'application/json': { + schema: { type: 'object' } // Dynamic result + } + } + } + } + } + }; + + // 2. Generate Schemas + for (const obj of objects) { + const schemaName = obj.name; + const properties: Record = {}; + + for (const [fieldName, field] of Object.entries(obj.fields)) { + properties[fieldName] = mapFieldTypeToOpenAPI(field); + } + + schemas[schemaName] = { + type: 'object', + properties + }; + } + + // 3. REST API Paths + for (const obj of objects) { + const name = obj.name; + const basePath = `${routes.data}/${name}`; // Standard REST Path + + // GET {dataPath}/:name (List) + paths[basePath] = { + get: { + summary: `List ${name}`, + tags: [name], + parameters: [ + { name: 'filter', in: 'query', schema: { type: 'string' }, description: 'JSON filter args' }, + { name: 'fields', in: 'query', schema: { type: 'string' }, description: 'Comma-separated fields to return' }, + { name: 'top', in: 'query', schema: { type: 'integer' }, description: 'Limit' }, + { name: 'skip', in: 'query', schema: { type: 'integer' }, description: 'Offset' } + ], + responses: { + 200: { + description: `List of ${name}`, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { $ref: `#/components/schemas/${name}` } } + } + } + } + } + } + } + }, + post: { + summary: `Create ${name}`, + tags: [name], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { $ref: `#/components/schemas/${name}` } + } + } + } + } + }, + responses: { + 200: { description: 'Created' } + } + } + }; + + // /api/data/:name/:id + paths[`${basePath}/{id}`] = { + get: { + summary: `Get ${name}`, + tags: [name], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: { + description: 'Item', + content: { + 'application/json': { + schema: { $ref: `#/components/schemas/${name}` } + } + } + } + } + }, + patch: { + summary: `Update ${name}`, + tags: [name], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { type: 'object' } + } + } + } + } + }, + responses: { + 200: { description: 'Updated' } + } + }, + delete: { + summary: `Delete ${name}`, + tags: [name], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: { description: 'Deleted' } + } + } + }; + } + + return { + openapi: '3.0.0', + info: { + title: 'ObjectQL API', + version: '1.0.0' + }, + paths, + components: { + schemas + } + }; +} + +function mapFieldTypeToOpenAPI(field: FieldConfig | string): any { + const type = typeof field === 'string' ? field : field.type; + + switch (type) { + case 'string': return { type: 'string' }; + case 'integer': return { type: 'integer' }; + case 'float': return { type: 'number' }; + case 'boolean': return { type: 'boolean' }; + case 'date': return { type: 'string', format: 'date-time' }; + case 'json': return { type: 'object' }; + default: return { type: 'string' }; // Fallback or relationship ID + } +} diff --git a/packages/plugins/server/src/plugin.ts b/packages/plugins/server/src/plugin.ts new file mode 100644 index 00000000..a2550eb3 --- /dev/null +++ b/packages/plugins/server/src/plugin.ts @@ -0,0 +1,239 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IObjectQL, ObjectQLPlugin, ApiRouteConfig } from '@objectql/types'; +import { IncomingMessage, ServerResponse, createServer, Server } from 'http'; +import { createNodeHandler, NodeHandlerOptions } from './adapters/node'; +import { createRESTHandler, RESTHandlerOptions } from './adapters/rest'; +import { createGraphQLHandler } from './adapters/graphql'; +import { createMetadataHandler } from './metadata'; + +export interface ServerPluginOptions { + /** + * Port number to listen on + * @default 3000 + */ + port?: number; + + /** + * Host address to bind to + * @default 'localhost' + */ + host?: string; + + /** + * Custom API route configuration + */ + routes?: ApiRouteConfig; + + /** + * File storage configuration + */ + fileStorage?: NodeHandlerOptions['fileStorage']; + + /** + * Enable GraphQL endpoint + * @default false + */ + enableGraphQL?: boolean; + + /** + * Enable REST endpoint + * @default true + */ + enableREST?: boolean; + + /** + * Enable metadata endpoint + * @default true + */ + enableMetadata?: boolean; + + /** + * Enable JSON-RPC endpoint + * @default true + */ + enableRPC?: boolean; + + /** + * Automatically start server on setup + * @default false + */ + autoStart?: boolean; + + /** + * Custom request handler middleware + */ + middleware?: ((req: IncomingMessage, res: ServerResponse, next: () => void) => void)[]; +} + +/** + * Server Plugin for ObjectQL + * Provides HTTP server capabilities with support for JSON-RPC, REST, GraphQL and Metadata APIs + */ +export class ServerPlugin implements ObjectQLPlugin { + name = 'objectql-server'; + private server?: Server; + private options: ServerPluginOptions; + + constructor(options: ServerPluginOptions = {}) { + this.options = { + port: options.port || parseInt(process.env.PORT || '3000'), + host: options.host || process.env.HOST || 'localhost', + routes: options.routes || {}, + fileStorage: options.fileStorage, + enableGraphQL: options.enableGraphQL ?? false, + enableREST: options.enableREST ?? true, + enableMetadata: options.enableMetadata ?? true, + enableRPC: options.enableRPC ?? true, + autoStart: options.autoStart ?? false, + middleware: options.middleware || [] + }; + } + + async setup(app: IObjectQL): Promise { + console.log('[ServerPlugin] Setting up HTTP server...'); + + // Create handlers based on enabled features + const nodeHandler = this.options.enableRPC + ? createNodeHandler(app, { + routes: this.options.routes, + fileStorage: this.options.fileStorage + }) + : undefined; + + const restHandler = this.options.enableREST + ? createRESTHandler(app, { routes: this.options.routes }) + : undefined; + + const graphqlHandler = this.options.enableGraphQL + ? createGraphQLHandler(app) + : undefined; + + const metadataHandler = this.options.enableMetadata + ? createMetadataHandler(app) + : undefined; + + // Create HTTP server + this.server = createServer((req, res) => { + // Apply middleware + let middlewareIndex = 0; + const middleware = this.options.middleware || []; + const next = () => { + if (middlewareIndex < middleware.length) { + const fn = middleware[middlewareIndex++]; + fn(req, res, next); + } else { + // Route to appropriate handler + this.routeRequest(req, res, { + nodeHandler, + restHandler, + graphqlHandler, + metadataHandler + }); + } + }; + next(); + }); + + // Auto-start if configured + if (this.options.autoStart) { + await this.start(); + } + + console.log('[ServerPlugin] Server setup complete'); + } + + /** + * Route incoming requests to the appropriate handler + */ + private routeRequest( + req: IncomingMessage, + res: ServerResponse, + handlers: { + nodeHandler?: (req: IncomingMessage, res: ServerResponse) => Promise; + restHandler?: (req: IncomingMessage, res: ServerResponse) => Promise; + graphqlHandler?: (req: IncomingMessage, res: ServerResponse) => Promise; + metadataHandler?: (req: IncomingMessage, res: ServerResponse) => Promise; + } + ) { + const url = req.url || '/'; + const resolvedRoutes = this.options.routes || {}; + + // Determine which handler to use based on URL path + // Note: GraphQL not in default routes, would need custom configuration + if (handlers.restHandler && url.startsWith(resolvedRoutes.data || '/api/data')) { + handlers.restHandler(req, res); + } else if (handlers.metadataHandler && url.startsWith(resolvedRoutes.metadata || '/api/metadata')) { + handlers.metadataHandler(req, res); + } else if (handlers.nodeHandler) { + handlers.nodeHandler(req, res); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Endpoint not found' } })); + } + } + + /** + * Start the HTTP server + */ + async start(): Promise { + if (!this.server) { + throw new Error('Server not initialized. Call setup() first.'); + } + + return new Promise((resolve, reject) => { + this.server!.listen(this.options.port, this.options.host, () => { + const routes = this.options.routes || {}; + console.log(`\n🚀 ObjectQL Server running on http://${this.options.host}:${this.options.port}`); + console.log(`\n🔌 APIs:`); + if (this.options.enableRPC) { + console.log(` - JSON-RPC: http://${this.options.host}:${this.options.port}${routes.rpc || '/api/objectql'}`); + } + if (this.options.enableREST) { + console.log(` - REST: http://${this.options.host}:${this.options.port}${routes.data || '/api/data'}`); + } + if (this.options.enableGraphQL) { + console.log(` - GraphQL: http://${this.options.host}:${this.options.port}/api/graphql`); + } + if (this.options.enableMetadata) { + console.log(` - Metadata: http://${this.options.host}:${this.options.port}${routes.metadata || '/api/metadata'}`); + } + resolve(); + }); + + this.server!.on('error', reject); + }); + } + + /** + * Stop the HTTP server + */ + async stop(): Promise { + if (!this.server) { + return; + } + + return new Promise((resolve, reject) => { + this.server!.close((err) => { + if (err) reject(err); + else { + console.log('[ServerPlugin] Server stopped'); + resolve(); + } + }); + }); + } + + /** + * Get the underlying Node.js HTTP server instance + */ + getServer(): Server | undefined { + return this.server; + } +} diff --git a/packages/plugins/server/src/server.ts b/packages/plugins/server/src/server.ts new file mode 100644 index 00000000..732672df --- /dev/null +++ b/packages/plugins/server/src/server.ts @@ -0,0 +1,274 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IObjectQL, ObjectQLContext } from '@objectql/types'; +import { ObjectQLRequest, ObjectQLResponse, ErrorCode } from './types'; + +export class ObjectQLServer { + constructor(private app: IObjectQL) {} + + /** + * The core handler that processes a JSON request object and returns a result. + * This is framework-agnostic. + */ + async handle(req: ObjectQLRequest): Promise { + try { + // Log AI context if provided + if (req.ai_context) { + console.log('[ObjectQL AI Context]', { + object: req.object, + op: req.op, + intent: req.ai_context.intent, + natural_language: req.ai_context.natural_language, + use_case: req.ai_context.use_case + }); + } + + // 1. Build Context + // TODO: integrate with real session/auth + const contextOptions = { + userId: req.user?.id, + roles: req.user?.roles || [], + // TODO: spaceId + }; + + // Note: Currently IObjectQL.createContext implies we have access to it. + // But IObjectQL interface in @objectql/types usually doesn't expose createContext (it's on the class). + // We need to cast or fix the interface. Assuming 'app' behaves like ObjectQL class. + const app = this.app as any; + if (typeof app.createContext !== 'function') { + return this.errorResponse( + ErrorCode.INTERNAL_ERROR, + "The provided ObjectQL instance does not support createContext." + ); + } + + const ctx: ObjectQLContext = app.createContext(contextOptions); + + // Validate object exists + const objectConfig = app.getObject(req.object); + if (!objectConfig) { + return this.errorResponse( + ErrorCode.NOT_FOUND, + `Object '${req.object}' not found` + ); + } + + const repo = ctx.object(req.object); + + let result: any; + + switch (req.op) { + case 'find': + result = await repo.find(req.args); + // For find operations, return items array with pagination metadata + return this.buildListResponse(result, req.args, repo); + case 'findOne': + // Support both string ID and query object + result = await repo.findOne(req.args); + if (result) { + return { ...result, '@type': req.object }; + } + return result; + case 'create': + result = await repo.create(req.args); + if (result) { + return { ...result, '@type': req.object }; + } + return result; + case 'update': + result = await repo.update(req.args.id, req.args.data); + if (result) { + return { ...result, '@type': req.object }; + } + return result; + case 'delete': + result = await repo.delete(req.args.id); + if (!result) { + return this.errorResponse( + ErrorCode.NOT_FOUND, + `Record with id '${req.args.id}' not found for delete` + ); + } + // Return standardized delete response with object type + return { + id: req.args.id, + deleted: true, + '@type': req.object + }; + case 'count': + result = await repo.count(req.args); + return { count: result, '@type': req.object }; + case 'action': + // Map generic args to ActionContext + result = await app.executeAction(req.object, req.args.action, { + ...ctx, // Pass context (user, etc.) + id: req.args.id, + input: req.args.input || req.args.params // Support both for convenience + }); + if (result && typeof result === 'object') { + return { ...result, '@type': req.object }; + } + return result; + case 'createMany': + // Bulk create operation + if (!Array.isArray(req.args)) { + return this.errorResponse( + ErrorCode.INVALID_REQUEST, + 'createMany expects args to be an array of records' + ); + } + result = await repo.createMany(req.args); + return { + items: result, + count: Array.isArray(result) ? result.length : 0, + '@type': req.object + }; + case 'updateMany': + // Bulk update operation + // args should be { filters, data } + if (!req.args || typeof req.args !== 'object' || !req.args.data) { + return this.errorResponse( + ErrorCode.INVALID_REQUEST, + 'updateMany expects args to be an object with { filters, data }' + ); + } + result = await repo.updateMany(req.args.filters || {}, req.args.data); + return { + count: result, + '@type': req.object + }; + case 'deleteMany': + // Bulk delete operation + // args should be { filters } + if (!req.args || typeof req.args !== 'object') { + return this.errorResponse( + ErrorCode.INVALID_REQUEST, + 'deleteMany expects args to be an object with { filters }' + ); + } + result = await repo.deleteMany(req.args.filters || {}); + return { + count: result, + '@type': req.object + }; + default: + return this.errorResponse( + ErrorCode.INVALID_REQUEST, + `Unknown operation: ${req.op}` + ); + } + + } catch (e: any) { + return this.handleError(e); + } + } + + /** + * Build a standardized list response with pagination metadata + */ + private async buildListResponse(items: any[], args: any, repo: any): Promise { + const response: ObjectQLResponse = { + items + }; + + // Calculate pagination metadata if limit/skip are present + if (args && (args.limit || args.skip)) { + const skip = args.skip || 0; + const limit = args.limit || items.length; + + // Get total count - use the same arguments as the query to ensure consistency + const total = await repo.count(args || {}); + + const size = limit; + const page = limit > 0 ? Math.floor(skip / limit) + 1 : 1; + const pages = limit > 0 ? Math.ceil(total / limit) : 1; + const has_next = skip + items.length < total; + + response.meta = { + total, + page, + size, + pages, + has_next + }; + } + + return response; + } + + /** + * Handle errors and convert them to appropriate error responses + */ + private handleError(error: any): ObjectQLResponse { + console.error('[ObjectQL Server] Error:', error); + + // Handle validation errors + if (error.name === 'ValidationError' || error.code === 'VALIDATION_ERROR') { + return this.errorResponse( + ErrorCode.VALIDATION_ERROR, + 'Validation failed', + { fields: error.fields || error.details } + ); + } + + // Handle permission errors + if (error.name === 'PermissionError' || error.code === 'FORBIDDEN') { + return this.errorResponse( + ErrorCode.FORBIDDEN, + error.message || 'You do not have permission to access this resource', + error.details + ); + } + + // Handle not found errors + if (error.name === 'NotFoundError' || error.code === 'NOT_FOUND') { + return this.errorResponse( + ErrorCode.NOT_FOUND, + error.message || 'Resource not found' + ); + } + + // Handle conflict errors (e.g., unique constraint violations) + if (error.name === 'ConflictError' || error.code === 'CONFLICT') { + return this.errorResponse( + ErrorCode.CONFLICT, + error.message || 'Resource conflict', + error.details + ); + } + + // Handle database errors + if (error.name === 'DatabaseError' || error.code?.startsWith('DB_')) { + return this.errorResponse( + ErrorCode.DATABASE_ERROR, + 'Database operation failed', + { originalError: error.message } + ); + } + + // Default to internal error + return this.errorResponse( + ErrorCode.INTERNAL_ERROR, + error.message || 'An error occurred' + ); + } + + /** + * Create a standardized error response + */ + private errorResponse(code: ErrorCode, message: string, details?: any): ObjectQLResponse { + return { + error: { + code, + message, + details + } + }; + } +} diff --git a/packages/plugins/server/src/storage.ts b/packages/plugins/server/src/storage.ts new file mode 100644 index 00000000..5f5d2bef --- /dev/null +++ b/packages/plugins/server/src/storage.ts @@ -0,0 +1,179 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IFileStorage, AttachmentData, FileStorageOptions } from './types'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +/** + * Local filesystem storage implementation for file attachments + */ +export class LocalFileStorage implements IFileStorage { + private baseDir: string; + private baseUrl: string; + + constructor(options: { baseDir: string; baseUrl: string }) { + this.baseDir = options.baseDir; + this.baseUrl = options.baseUrl; + + // Ensure base directory exists + if (!fs.existsSync(this.baseDir)) { + fs.mkdirSync(this.baseDir, { recursive: true }); + } + } + + async save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise { + // Generate unique ID for the file + const id = crypto.randomBytes(16).toString('hex'); + const ext = path.extname(filename); + const basename = path.basename(filename, ext); + const storedFilename = `${id}${ext}`; + + // Determine storage path + let folder = options?.folder || 'uploads'; + if (options?.object) { + folder = path.join(folder, options.object); + } + + const folderPath = path.join(this.baseDir, folder); + if (!fs.existsSync(folderPath)) { + fs.mkdirSync(folderPath, { recursive: true }); + } + + const filePath = path.join(folderPath, storedFilename); + + // Write file to disk (async for better performance) + await fs.promises.writeFile(filePath, file); + + // Generate public URL + const url = this.getPublicUrl(path.join(folder, storedFilename)); + + const attachmentData: AttachmentData = { + id, + name: storedFilename, + url, + size: file.length, + type: mimeType, + original_name: filename, + uploaded_at: new Date().toISOString(), + uploaded_by: options?.userId + }; + + return attachmentData; + } + + async get(fileId: string): Promise { + try { + // Search for file in the upload directory + const found = this.findFile(this.baseDir, fileId); + if (!found) { + return null; + } + // Use async read for better performance + return await fs.promises.readFile(found); + } catch (error) { + console.error('Error reading file:', error); + return null; + } + } + + async delete(fileId: string): Promise { + try { + const found = this.findFile(this.baseDir, fileId); + if (!found) { + return false; + } + // Use async unlink for better performance + await fs.promises.unlink(found); + return true; + } catch (error) { + console.error('Error deleting file:', error); + return false; + } + } + + getPublicUrl(filePath: string): string { + // Normalize path separators for URLs + const normalizedPath = filePath.replace(/\\/g, '/'); + return `${this.baseUrl}/${normalizedPath}`; + } + + /** + * Recursively search for a file by ID + */ + private findFile(dir: string, fileId: string): string | null { + const files = fs.readdirSync(dir); + + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + const found = this.findFile(filePath, fileId); + if (found) { + return found; + } + } else if (file.startsWith(fileId)) { + return filePath; + } + } + + return null; + } +} + +/** + * Memory storage implementation for testing + */ +export class MemoryFileStorage implements IFileStorage { + private files = new Map(); + private baseUrl: string; + + constructor(options: { baseUrl: string }) { + this.baseUrl = options.baseUrl; + } + + async save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise { + const id = crypto.randomBytes(16).toString('hex'); + const ext = path.extname(filename); + const storedFilename = `${id}${ext}`; + + const attachmentData: AttachmentData = { + id, + name: storedFilename, + url: this.getPublicUrl(storedFilename), + size: file.length, + type: mimeType, + original_name: filename, + uploaded_at: new Date().toISOString(), + uploaded_by: options?.userId + }; + + this.files.set(id, { buffer: file, metadata: attachmentData }); + + return attachmentData; + } + + async get(fileId: string): Promise { + const entry = this.files.get(fileId); + return entry ? entry.buffer : null; + } + + async delete(fileId: string): Promise { + return this.files.delete(fileId); + } + + getPublicUrl(filePath: string): string { + return `${this.baseUrl}/${filePath}`; + } + + clear(): void { + this.files.clear(); + } +} diff --git a/packages/plugins/server/src/templates.ts b/packages/plugins/server/src/templates.ts new file mode 100644 index 00000000..647d8b99 --- /dev/null +++ b/packages/plugins/server/src/templates.ts @@ -0,0 +1,57 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export function getWelcomePageHtml(routes: { rpc: string; data: string; }) { + return ` + + + ObjectQL Server + + + +
+ Running +
+

ObjectQL Server

+

The server is operational and ready to accept requests.

+ +
+

API Endpoints

+
    +
  • JSON-RPC: POST ${routes.rpc}
  • +
  • REST API: GET ${routes.data}/:object
  • +
  • OpenAPI Spec: /openapi.json
  • +
+
+ +
+

Useful Links

+ +
+ +

+ Powered by ObjectQL +

+ +`; +} diff --git a/packages/plugins/server/src/types.ts b/packages/plugins/server/src/types.ts new file mode 100644 index 00000000..49be18b3 --- /dev/null +++ b/packages/plugins/server/src/types.ts @@ -0,0 +1,181 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// src/types.ts + +/** + * Standardized error codes for ObjectQL API + */ +export enum ErrorCode { + INVALID_REQUEST = 'INVALID_REQUEST', + VALIDATION_ERROR = 'VALIDATION_ERROR', + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + NOT_FOUND = 'NOT_FOUND', + CONFLICT = 'CONFLICT', + INTERNAL_ERROR = 'INTERNAL_ERROR', + DATABASE_ERROR = 'DATABASE_ERROR', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED' +} + +/** + * AI context for better logging, debugging, and AI processing + */ +export interface AIContext { + intent?: string; + natural_language?: string; + use_case?: string; + [key: string]: unknown; +} + +/** + * ObjectQL JSON-RPC style request + */ +export interface ObjectQLRequest { + // Identity provided by the framework adapter (e.g. from session) + user?: { + id: string; + roles: string[]; + [key: string]: any; + }; + + // The actual operation + op: 'find' | 'findOne' | 'create' | 'update' | 'delete' | 'count' | 'action' | 'createMany' | 'updateMany' | 'deleteMany'; + object: string; + + // Arguments + args: any; + + // Optional AI context for explainability + ai_context?: AIContext; +} + +/** + * Error details structure + */ +export interface ErrorDetails { + field?: string; + reason?: string; + fields?: Record; + required_permission?: string; + user_roles?: string[]; + retry_after?: number; + [key: string]: unknown; +} + +/** + * Pagination metadata + */ +export interface PaginationMeta { + total: number; // Total number of records + page?: number; // Current page number (1-indexed, e.g. page 1 corresponds to skip=0) + size?: number; // Number of items per page + pages?: number; // Total number of pages + has_next?: boolean; // Whether there is a next page +} + +/** + * ObjectQL API response + */ +export interface ObjectQLResponse { + // For list operations (find) + items?: any[]; + + // Pagination metadata (for list operations) + meta?: PaginationMeta; + + // Error information + error?: { + code: ErrorCode | string; + message: string; + details?: ErrorDetails | any; // Allow flexible details structure + }; + + // For single item operations, the response is the object itself with '@type' field + // This allows any additional fields from the actual data object + [key: string]: any; +} + +/** + * Attachment/File metadata structure + */ +export interface AttachmentData { + /** Unique identifier for this file */ + id: string; + /** File name (e.g., "invoice.pdf") */ + name: string; + /** Publicly accessible URL to the file */ + url: string; + /** File size in bytes */ + size: number; + /** MIME type (e.g., "application/pdf", "image/jpeg") */ + type: string; + /** Original filename as uploaded by user */ + original_name?: string; + /** Upload timestamp (ISO 8601) */ + uploaded_at?: string; + /** User ID who uploaded the file */ + uploaded_by?: string; +} + +/** + * Image-specific attachment data with metadata + */ +export interface ImageAttachmentData extends AttachmentData { + /** Image width in pixels */ + width?: number; + /** Image height in pixels */ + height?: number; + /** Thumbnail URL (if generated) */ + thumbnail_url?: string; + /** Alternative sizes/versions */ + variants?: { + small?: string; + medium?: string; + large?: string; + }; +} + +/** + * File storage provider interface + */ +export interface IFileStorage { + /** + * Save a file and return its metadata + */ + save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise; + + /** + * Retrieve a file by its ID or path + */ + get(fileId: string): Promise; + + /** + * Delete a file + */ + delete(fileId: string): Promise; + + /** + * Generate a public URL for a file + */ + getPublicUrl(fileId: string): string; +} + +/** + * Options for file storage operations + */ +export interface FileStorageOptions { + /** Logical folder/path for organization */ + folder?: string; + /** Object name (for context/validation) */ + object?: string; + /** Field name (for validation against field config) */ + field?: string; + /** User ID who uploaded the file */ + userId?: string; +} diff --git a/packages/plugins/server/src/utils.ts b/packages/plugins/server/src/utils.ts new file mode 100644 index 00000000..88163a35 --- /dev/null +++ b/packages/plugins/server/src/utils.ts @@ -0,0 +1,29 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Utility functions for server operations + */ + +/** + * Escapes special regex characters in a path string for use in RegExp + * @param path - The path string to escape + * @returns Escaped path string safe for use in RegExp + */ +export function escapeRegexPath(path: string): string { + return path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Normalizes a path to ensure it starts with a forward slash + * @param path - The path string to normalize + * @returns Normalized path string starting with '/' + */ +export function normalizePath(path: string): string { + return path.startsWith('/') ? path : `/${path}`; +} diff --git a/packages/plugins/server/tsconfig.json b/packages/plugins/server/tsconfig.json new file mode 100644 index 00000000..f6004589 --- /dev/null +++ b/packages/plugins/server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/runtime/server/src/index.ts b/packages/runtime/server/src/index.ts index b91676ba..c26c5b12 100644 --- a/packages/runtime/server/src/index.ts +++ b/packages/runtime/server/src/index.ts @@ -6,6 +6,19 @@ * LICENSE file in the root directory of this source tree. */ +/** + * @deprecated This package is deprecated. Use @objectql/plugin-server instead. + * + * @example + * ```typescript + * // Old way (still supported for backward compatibility) + * import { createNodeHandler } from '@objectql/server'; + * + * // New way (recommended) + * import { ServerPlugin } from '@objectql/plugin-server'; + * ``` + */ + export * from './types'; export * from './utils'; export * from './openapi'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fe393ec..870bb40b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,37 @@ importers: specifier: ^2.4.0 version: 2.4.0 + packages/plugins/server: + dependencies: + '@graphql-tools/schema': + specifier: ^10.0.2 + version: 10.0.31(graphql@16.12.0) + '@objectql/core': + specifier: workspace:* + version: link:../../foundation/core + '@objectql/types': + specifier: workspace:* + version: link:../../foundation/types + graphql: + specifier: ^16.8.1 + version: 16.12.0 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 + devDependencies: + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^20.10.0 + version: 20.19.29 + hono: + specifier: ^4.11.0 + version: 4.11.4 + typescript: + specifier: ^5.3.0 + version: 5.9.3 + packages/runtime/server: dependencies: '@graphql-tools/schema': @@ -4857,6 +4888,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hono@4.11.4: + resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -13241,6 +13276,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hono@4.11.4: {} + hookable@5.5.3: {} hosted-git-info@2.8.9: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1bc9005c..68a4b0f6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - packages/foundation/* - packages/drivers/* - packages/runtime/* + - packages/plugins/* - packages/tools/* - examples/quickstart/* - examples/integrations/* From eb2758f249c23fbc2078be3cbdddf02891d29a93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 07:46:32 +0000 Subject: [PATCH 3/6] Add Hono server example demonstrating plugin usage Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- examples/integrations/hono-server/README.md | 82 +++++++++ .../integrations/hono-server/package.json | 42 +++++ .../integrations/hono-server/src/index.ts | 174 ++++++++++++++++++ .../hono-server/src/task.object.yml | 24 +++ .../hono-server/src/user.object.yml | 17 ++ .../integrations/hono-server/tsconfig.json | 9 + pnpm-lock.yaml | 83 +++++++-- 7 files changed, 420 insertions(+), 11 deletions(-) create mode 100644 examples/integrations/hono-server/README.md create mode 100644 examples/integrations/hono-server/package.json create mode 100644 examples/integrations/hono-server/src/index.ts create mode 100644 examples/integrations/hono-server/src/task.object.yml create mode 100644 examples/integrations/hono-server/src/user.object.yml create mode 100644 examples/integrations/hono-server/tsconfig.json diff --git a/examples/integrations/hono-server/README.md b/examples/integrations/hono-server/README.md new file mode 100644 index 00000000..17e3ab9d --- /dev/null +++ b/examples/integrations/hono-server/README.md @@ -0,0 +1,82 @@ +# ObjectQL Hono Server Example + +This example demonstrates how to integrate ObjectQL with the [Hono](https://hono.dev/) web framework. + +## Features + +- ⚡ Fast and lightweight Hono framework +- 🔌 ObjectQL plugin-based architecture +- 📡 JSON-RPC, REST, and Metadata APIs +- 🌐 CORS support +- 💾 SQLite in-memory database + +## Quick Start + +```bash +# Install dependencies +pnpm install + +# Start the server +pnpm dev +``` + +The server will start on http://localhost:3005 + +## API Endpoints + +### JSON-RPC +```bash +curl -X POST http://localhost:3005/api/objectql \ + -H "Content-Type: application/json" \ + -d '{"op": "find", "object": "User", "args": {}}' +``` + +### REST API +```bash +# List all users +curl http://localhost:3005/api/data/User + +# Get a specific user +curl http://localhost:3005/api/data/User/1 + +# Create a user +curl -X POST http://localhost:3005/api/data/User \ + -H "Content-Type: application/json" \ + -d '{"name": "John", "email": "john@example.com", "age": 30, "status": "active"}' +``` + +### Metadata API +```bash +# List all objects +curl http://localhost:3005/api/metadata/object + +# Get User object schema +curl http://localhost:3005/api/metadata/object/User +``` + +## Why Hono? + +Hono is a modern, ultra-lightweight web framework that works on any JavaScript runtime (Node.js, Cloudflare Workers, Deno, Bun). It's perfect for: + +- Edge computing deployments +- Serverless functions +- High-performance APIs +- TypeScript-first development + +## Architecture + +This example uses the `@objectql/plugin-server` package which provides a clean adapter for Hono: + +```typescript +import { createHonoAdapter } from '@objectql/plugin-server'; + +const server = new Hono(); +const objectqlHandler = createHonoAdapter(app); +server.all('/api/*', objectqlHandler); +``` + +## Learn More + +- [Hono Documentation](https://hono.dev/) +- [ObjectQL Documentation](https://objectql.org) +- [@objectql/plugin-server](../../packages/plugins/server) diff --git a/examples/integrations/hono-server/package.json b/examples/integrations/hono-server/package.json new file mode 100644 index 00000000..fd6f79a4 --- /dev/null +++ b/examples/integrations/hono-server/package.json @@ -0,0 +1,42 @@ +{ + "name": "@objectql/example-hono-server", + "version": "3.0.1", + "description": "Hono Server Integration Example for ObjectQL", + "private": true, + "keywords": [ + "objectql", + "module", + "hono", + "api", + "rest", + "server", + "interface" + ], + "license": "MIT", + "author": "ObjectQL Contributors", + "repository": { + "type": "git", + "url": "https://github.com/objectql/objectql.git", + "directory": "examples/integrations/hono-server" + }, + "scripts": { + "build": "tsc && cp src/*.yml dist/ || true", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@objectql/core": "workspace:*", + "@objectql/plugin-server": "workspace:*", + "@objectql/types": "workspace:*", + "@objectql/driver-sql": "workspace:*", + "@objectql/platform-node": "workspace:*", + "hono": "^4.11.0", + "@hono/node-server": "^1.19.0", + "sqlite3": "^5.1.7" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "typescript": "^5.0.0", + "tsx": "^4.7.0" + } +} diff --git a/examples/integrations/hono-server/src/index.ts b/examples/integrations/hono-server/src/index.ts new file mode 100644 index 00000000..d8bc5436 --- /dev/null +++ b/examples/integrations/hono-server/src/index.ts @@ -0,0 +1,174 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { ObjectQL } from '@objectql/core'; +import { SqlDriver } from '@objectql/driver-sql'; +import { ObjectLoader } from '@objectql/platform-node'; +import { createHonoAdapter } from '@objectql/plugin-server'; +import * as path from 'path'; + +async function main() { + // 1. Init ObjectQL + const app = new ObjectQL({ + datasources: { + default: new SqlDriver({ + client: 'sqlite3', + connection: { + filename: ':memory:' + }, + useNullAsDefault: true + }) + } + }); + + // 2. Load Schema + const rootDir = path.resolve(__dirname, '..'); + const loader = new ObjectLoader(app.metadata); + loader.load(rootDir); + + // 3. Init + await app.init(); + + // 4. Create Hono server with ObjectQL adapter + const server = new Hono(); + const port = 3005; + + // Add CORS middleware + server.use('*', async (c, next) => { + c.header('Access-Control-Allow-Origin', '*'); + c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (c.req.method === 'OPTIONS') { + return c.text('', 200); + } + + await next(); + }); + + // Mount ObjectQL handler + const objectqlHandler = createHonoAdapter(app); + server.all('/api/*', objectqlHandler); + + // Welcome page + server.get('/', (c) => { + return c.html(` + + + + ObjectQL Hono Server + + + +

🚀 ObjectQL Hono Server

+

Welcome to the ObjectQL Hono integration example!

+ +

Available APIs

+
+ JSON-RPC: POST /api/objectql
+ Example: {"op": "find", "object": "User", "args": {}} +
+
+ REST: GET /api/data/:object
+ Example: GET /api/data/User +
+
+ Metadata: GET /api/metadata/object
+ Get schema information +
+ +

Test Commands

+
curl -X POST http://localhost:${port}/api/objectql \\
+  -H "Content-Type: application/json" \\
+  -d '{"op": "find", "object": "User", "args": {}}'
+
+curl http://localhost:${port}/api/data/User
+
+curl http://localhost:${port}/api/metadata/object
+ + + `); + }); + + // Create some sample data + const ctx = app.createContext({ isSystem: true }); + await ctx.object('User').create({ + name: 'Alice', + email: 'alice@example.com', + age: 28, + status: 'active' + }); + await ctx.object('User').create({ + name: 'Bob', + email: 'bob@example.com', + age: 35, + status: 'active' + }); + await ctx.object('User').create({ + name: 'Charlie', + email: 'charlie@example.com', + age: 42, + status: 'inactive' + }); + + await ctx.object('Task').create({ + title: 'Complete project', + description: 'Finish the ObjectQL console', + status: 'in-progress', + priority: 'high' + }); + await ctx.object('Task').create({ + title: 'Write documentation', + description: 'Document the new console feature', + status: 'pending', + priority: 'medium' + }); + await ctx.object('Task').create({ + title: 'Code review', + description: 'Review pull requests', + status: 'pending', + priority: 'low' + }); + + // Start Hono server + console.log(`\n🚀 ObjectQL Hono Server running on http://localhost:${port}`); + console.log(`\n🔌 APIs:`); + console.log(` - JSON-RPC: http://localhost:${port}/api/objectql`); + console.log(` - REST: http://localhost:${port}/api/data`); + console.log(` - Metadata: http://localhost:${port}/api/metadata`); + console.log(` - Web UI: http://localhost:${port}/`); + + serve({ + fetch: server.fetch, + port + }); +} + +main().catch(console.error); diff --git a/examples/integrations/hono-server/src/task.object.yml b/examples/integrations/hono-server/src/task.object.yml new file mode 100644 index 00000000..a4a8b9ee --- /dev/null +++ b/examples/integrations/hono-server/src/task.object.yml @@ -0,0 +1,24 @@ +label: Tasks +fields: + title: + type: string + label: Title + required: true + description: + type: text + label: Description + status: + type: string + label: Status + defaultValue: pending + priority: + type: string + label: Priority + defaultValue: medium + due_date: + type: date + label: Due Date + completed: + type: boolean + label: Completed + defaultValue: false diff --git a/examples/integrations/hono-server/src/user.object.yml b/examples/integrations/hono-server/src/user.object.yml new file mode 100644 index 00000000..77f7af15 --- /dev/null +++ b/examples/integrations/hono-server/src/user.object.yml @@ -0,0 +1,17 @@ +label: Users +fields: + name: + type: string + label: Full Name + required: true + email: + type: string + label: Email Address + required: true + status: + type: string + label: Status + defaultValue: active + age: + type: number + label: Age diff --git a/examples/integrations/hono-server/tsconfig.json b/examples/integrations/hono-server/tsconfig.json new file mode 100644 index 00000000..b54386af --- /dev/null +++ b/examples/integrations/hono-server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 870bb40b..7f4e565a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,7 @@ importers: version: 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@20.19.29)(jiti@1.21.7) + version: 7.3.1(@types/node@20.19.29)(jiti@1.21.7)(tsx@4.21.0) vitepress: specifier: ^1.6.4 version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@20.19.29)(@types/react@18.3.27)(postcss@8.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) @@ -80,7 +80,7 @@ importers: version: 10.0.0(fumadocs-core@13.4.10(@types/react@18.3.27)(next@14.2.35(@babel/core@7.28.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@14.2.35(@babel/core@7.28.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) fumadocs-ui: specifier: ^13.0.0 - version: 13.4.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(next@14.2.35(@babel/core@7.28.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.19) + version: 13.4.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(next@14.2.35(@babel/core@7.28.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.19(tsx@4.21.0)) katex: specifier: ^0.16.27 version: 0.16.27 @@ -135,7 +135,7 @@ importers: version: 8.5.6 tailwindcss: specifier: ^3.4.0 - version: 3.4.19 + version: 3.4.19(tsx@4.21.0) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -243,6 +243,43 @@ importers: specifier: ^5.0.0 version: 5.9.3 + examples/integrations/hono-server: + dependencies: + '@hono/node-server': + specifier: ^1.19.0 + version: 1.19.9(hono@4.11.4) + '@objectql/core': + specifier: workspace:* + version: link:../../../packages/foundation/core + '@objectql/driver-sql': + specifier: workspace:* + version: link:../../../packages/drivers/sql + '@objectql/platform-node': + specifier: workspace:* + version: link:../../../packages/foundation/platform-node + '@objectql/plugin-server': + specifier: workspace:* + version: link:../../../packages/plugins/server + '@objectql/types': + specifier: workspace:* + version: link:../../../packages/foundation/types + hono: + specifier: ^4.11.0 + version: 4.11.4 + sqlite3: + specifier: ^5.1.7 + version: 5.1.7 + devDependencies: + '@types/node': + specifier: ^20.10.0 + version: 20.19.29 + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + examples/quickstart/hello-world: dependencies: '@objectql/core': @@ -1616,6 +1653,12 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -7676,6 +7719,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -9140,6 +9188,10 @@ snapshots: dependencies: graphql: 16.12.0 + '@hono/node-server@1.19.9(hono@4.11.4)': + dependencies: + hono: 4.11.4 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -10333,10 +10385,10 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.8.1 - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(tsx@4.21.0) '@textlint/ast-node-types@15.5.0': {} @@ -12928,7 +12980,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@13.4.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(next@14.2.35(@babel/core@7.28.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.19): + fumadocs-ui@13.4.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(next@14.2.35(@babel/core@7.28.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.19(tsx@4.21.0)): dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -12937,7 +12989,7 @@ snapshots: '@radix-ui/react-popover': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tailwindcss/typography': 0.5.19(tailwindcss@3.4.19) + '@tailwindcss/typography': 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)) class-variance-authority: 0.7.1 cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fumadocs-core: 13.4.10(@types/react@18.3.27)(next@14.2.35(@babel/core@7.28.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -15775,12 +15827,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 + tsx: 4.21.0 postcss-nested@6.2.0(postcss@8.5.6): dependencies: @@ -16831,7 +16884,7 @@ snapshots: tailwind-merge@2.6.0: {} - tailwindcss@3.4.19: + tailwindcss@3.4.19(tsx@4.21.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -16850,7 +16903,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -17039,6 +17092,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -17325,7 +17385,7 @@ snapshots: '@types/node': 20.19.29 fsevents: 2.3.3 - vite@7.3.1(@types/node@20.19.29)(jiti@1.21.7): + vite@7.3.1(@types/node@20.19.29)(jiti@1.21.7)(tsx@4.21.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -17337,6 +17397,7 @@ snapshots: '@types/node': 20.19.29 fsevents: 2.3.3 jiti: 1.21.7 + tsx: 4.21.0 vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@20.19.29)(@types/react@18.3.27)(postcss@8.5.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3): dependencies: From 9cafa9f1e206ff065b73af847e385738adddc779 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 07:49:45 +0000 Subject: [PATCH 4/6] Fix object names and test Hono server functionality Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- examples/integrations/hono-server/README.md | 12 +++++------ .../integrations/hono-server/src/index.ts | 20 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/examples/integrations/hono-server/README.md b/examples/integrations/hono-server/README.md index 17e3ab9d..da6eba07 100644 --- a/examples/integrations/hono-server/README.md +++ b/examples/integrations/hono-server/README.md @@ -28,19 +28,19 @@ The server will start on http://localhost:3005 ```bash curl -X POST http://localhost:3005/api/objectql \ -H "Content-Type: application/json" \ - -d '{"op": "find", "object": "User", "args": {}}' + -d '{"op": "find", "object": "user", "args": {}}' ``` ### REST API ```bash # List all users -curl http://localhost:3005/api/data/User +curl http://localhost:3005/api/data/user # Get a specific user -curl http://localhost:3005/api/data/User/1 +curl http://localhost:3005/api/data/user/1 # Create a user -curl -X POST http://localhost:3005/api/data/User \ +curl -X POST http://localhost:3005/api/data/user \ -H "Content-Type: application/json" \ -d '{"name": "John", "email": "john@example.com", "age": 30, "status": "active"}' ``` @@ -50,8 +50,8 @@ curl -X POST http://localhost:3005/api/data/User \ # List all objects curl http://localhost:3005/api/metadata/object -# Get User object schema -curl http://localhost:3005/api/metadata/object/User +# Get user object schema +curl http://localhost:3005/api/metadata/object/user ``` ## Why Hono? diff --git a/examples/integrations/hono-server/src/index.ts b/examples/integrations/hono-server/src/index.ts index d8bc5436..71f7a528 100644 --- a/examples/integrations/hono-server/src/index.ts +++ b/examples/integrations/hono-server/src/index.ts @@ -93,11 +93,11 @@ async function main() {

Available APIs

JSON-RPC: POST /api/objectql
- Example: {"op": "find", "object": "User", "args": {}} + Example: {"op": "find", "object": "user", "args": {}}
REST: GET /api/data/:object
- Example: GET /api/data/User + Example: GET /api/data/user
Metadata: GET /api/metadata/object
@@ -107,9 +107,9 @@ async function main() {

Test Commands

curl -X POST http://localhost:${port}/api/objectql \\
   -H "Content-Type: application/json" \\
-  -d '{"op": "find", "object": "User", "args": {}}'
+  -d '{"op": "find", "object": "user", "args": {}}'
 
-curl http://localhost:${port}/api/data/User
+curl http://localhost:${port}/api/data/user
 
 curl http://localhost:${port}/api/metadata/object
@@ -119,38 +119,38 @@ curl http://localhost:${port}/api/metadata/object // Create some sample data const ctx = app.createContext({ isSystem: true }); - await ctx.object('User').create({ + await ctx.object('user').create({ name: 'Alice', email: 'alice@example.com', age: 28, status: 'active' }); - await ctx.object('User').create({ + await ctx.object('user').create({ name: 'Bob', email: 'bob@example.com', age: 35, status: 'active' }); - await ctx.object('User').create({ + await ctx.object('user').create({ name: 'Charlie', email: 'charlie@example.com', age: 42, status: 'inactive' }); - await ctx.object('Task').create({ + await ctx.object('task').create({ title: 'Complete project', description: 'Finish the ObjectQL console', status: 'in-progress', priority: 'high' }); - await ctx.object('Task').create({ + await ctx.object('task').create({ title: 'Write documentation', description: 'Document the new console feature', status: 'pending', priority: 'medium' }); - await ctx.object('Task').create({ + await ctx.object('task').create({ title: 'Code review', description: 'Review pull requests', status: 'pending', From dd37a060bca16cf636ee7d49618b33094a574068 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 07:50:59 +0000 Subject: [PATCH 5/6] Add comprehensive summary of plugin refactor implementation Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- PLUGIN_REFACTOR_SUMMARY.md | 208 +++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 PLUGIN_REFACTOR_SUMMARY.md diff --git a/PLUGIN_REFACTOR_SUMMARY.md b/PLUGIN_REFACTOR_SUMMARY.md new file mode 100644 index 00000000..f148b7a2 --- /dev/null +++ b/PLUGIN_REFACTOR_SUMMARY.md @@ -0,0 +1,208 @@ +# Server Plugin Refactor - Implementation Summary + +## Overview + +This document summarizes the implementation of the server-side refactor using a plugin-based architecture, as requested in the issue: "参考这个包以插件的方式重构服务端,@objectstack/plugin-hono-server" (Refactor the server side using a plugin approach, referencing @objectstack/plugin-hono-server). + +## What Was Implemented + +### 1. New Plugin Package: `@objectql/plugin-server` + +**Location**: `/packages/plugins/server/` + +A new plugin package that encapsulates all HTTP server functionality: + +- **ServerPlugin Class**: Implements the `ObjectQLPlugin` interface +- **Core Features**: + - JSON-RPC API support + - REST API support + - GraphQL API support + - Metadata API support + - File upload/download support + - Configurable routes + - Custom middleware support + - Auto-start capability + +**Key Files**: +- `src/plugin.ts` - Main ServerPlugin implementation +- `src/server.ts` - Core ObjectQLServer logic +- `src/adapters/node.ts` - Node.js HTTP adapter +- `src/adapters/rest.ts` - REST API adapter +- `src/adapters/graphql.ts` - GraphQL adapter +- `src/adapters/hono.ts` - **NEW** Hono framework adapter +- `src/metadata.ts` - Metadata API handler +- `src/file-handler.ts` - File upload/download handlers +- `src/storage.ts` - File storage abstraction +- `src/openapi.ts` - OpenAPI spec generation +- `src/types.ts` - Type definitions +- `src/utils.ts` - Utility functions + +### 2. Hono Framework Adapter + +**Function**: `createHonoAdapter(app: IObjectQL, options?: HonoAdapterOptions)` + +The Hono adapter enables ObjectQL to work seamlessly with the Hono web framework: + +```typescript +import { Hono } from 'hono'; +import { createHonoAdapter } from '@objectql/plugin-server'; + +const server = new Hono(); +const objectqlHandler = createHonoAdapter(app); +server.all('/api/*', objectqlHandler); +``` + +**Features**: +- Full JSON-RPC API support +- Complete REST API implementation +- Metadata API endpoints +- Error handling with proper HTTP status codes +- Type-safe integration + +### 3. Backward Compatibility + +The existing `@objectql/server` package remains fully functional: + +- All exports preserved +- Added deprecation notice pointing to new plugin +- All existing tests (129 tests) passing +- No breaking changes for existing users + +### 4. Example Implementation + +**Location**: `/examples/integrations/hono-server/` + +A complete working example demonstrating: +- Hono server setup +- ObjectQL integration using the new adapter +- CORS configuration +- Sample data creation +- Web UI with API documentation +- Test commands + +## Architecture + +### Plugin-Based Design + +``` +┌─────────────────────────────────────────┐ +│ ObjectQL Core │ +│ (Foundation packages) │ +└──────────────┬──────────────────────────┘ + │ + │ Plugin Interface + │ +┌──────────────▼──────────────────────────┐ +│ @objectql/plugin-server │ +│ │ +│ ┌─────────────────────────────────┐ │ +│ │ ServerPlugin │ │ +│ │ - setup(app: IObjectQL) │ │ +│ │ - start() │ │ +│ │ - stop() │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────┐ │ +│ │ Adapters │ │ +│ │ - Node.js HTTP │ │ +│ │ - REST │ │ +│ │ - GraphQL │ │ +│ │ - Hono ⭐ │ │ +│ └─────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +### Usage Patterns + +#### Pattern 1: Direct Plugin Usage + +```typescript +const app = new ObjectQL({ + datasources: { /* ... */ }, + plugins: [ + new ServerPlugin({ + port: 3000, + autoStart: true, + enableREST: true, + enableRPC: true + }) + ] +}); + +await app.init(); +``` + +#### Pattern 2: Hono Integration + +```typescript +const app = new ObjectQL({ /* ... */ }); +await app.init(); + +const server = new Hono(); +const objectqlHandler = createHonoAdapter(app); +server.all('/api/*', objectqlHandler); + +serve({ fetch: server.fetch, port: 3000 }); +``` + +#### Pattern 3: Express Integration (Traditional) + +```typescript +const app = new ObjectQL({ /* ... */ }); +await app.init(); + +const server = express(); +const objectqlHandler = createNodeHandler(app); +server.all('/api/*', objectqlHandler); + +server.listen(3000); +``` + +## Benefits + +1. **Modularity**: Server functionality is now a plugin, not core dependency +2. **Extensibility**: Easy to add new framework adapters (Fastify, Koa, etc.) +3. **Flexibility**: Choose your preferred web framework +4. **Edge Computing**: Hono adapter enables deployment to edge runtimes +5. **Type Safety**: Full TypeScript support throughout +6. **Backward Compatible**: Existing code continues to work + +## Testing + +All tests passing: +- **9 test suites** covering: + - Node.js adapter + - REST API + - GraphQL API + - Metadata API + - File uploads + - OpenAPI generation + - Custom routes +- **129 tests total** +- Manual testing of Hono server with curl commands ✅ + +## Files Changed/Added + +### New Files (21 files) +- `/packages/plugins/server/*` - Complete plugin package +- `/examples/integrations/hono-server/*` - Hono example + +### Modified Files (2 files) +- `/pnpm-workspace.yaml` - Added plugins workspace +- `/packages/runtime/server/src/index.ts` - Added deprecation notice + +## Future Enhancements + +Potential next steps: +1. Add more framework adapters (Fastify, Koa, etc.) +2. Create plugin-specific tests +3. Add performance benchmarks +4. Create deployment guides for edge platforms +5. Add WebSocket support +6. Create standalone server binary + +## References + +- Issue: "参考这个包以插件的方式重构服务端,@objectstack/plugin-hono-server" +- Hono Framework: https://hono.dev/ +- ObjectQL Plugin System: `/apps/site/content/docs/server/plugins.mdx` From 00c7c229b0194cd9912845003e59d3abd89cfe14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:08:03 +0000 Subject: [PATCH 6/6] Refactor @objectql/server as thin wrapper to @objectql/plugin-server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完全移除现有的 server 实现逻辑,按照新的插件方式重构。 - 删除 @objectql/server 中的所有实现文件 - 将 @objectql/server 改为仅从 @objectql/plugin-server 重新导出的兼容层 - 将所有测试移至 @objectql/plugin-server 包 - 更新示例使用新的 @objectql/plugin-server 包 - 所有 129 个测试仍然通过 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../integrations/express-server/package.json | 2 +- .../integrations/express-server/src/index.ts | 2 +- .../server/test/custom-routes.test.ts | 0 .../test/file-upload-integration.example.ts | 0 .../server/test/file-validation.test.ts | 0 .../server/test/graphql.test.ts | 0 .../server/test/integration-example.ts | 0 .../server/test/metadata.test.ts | 0 .../server/test/node.test.ts | 0 .../server/test/openapi.test.ts | 0 .../server/test/rest-advanced.test.ts | 0 .../server/test/rest.test.ts | 0 .../server/test/storage.test.ts | 0 packages/runtime/server/README.md | 170 ++---- packages/runtime/server/package.json | 14 +- .../runtime/server/src/adapters/graphql.ts | 559 ------------------ packages/runtime/server/src/adapters/node.ts | 293 --------- packages/runtime/server/src/adapters/rest.ts | 338 ----------- packages/runtime/server/src/file-handler.ts | 422 ------------- packages/runtime/server/src/index.ts | 31 +- packages/runtime/server/src/metadata.ts | 244 -------- packages/runtime/server/src/openapi.ts | 207 ------- packages/runtime/server/src/server.ts | 274 --------- packages/runtime/server/src/storage.ts | 179 ------ packages/runtime/server/src/templates.ts | 57 -- packages/runtime/server/src/types.ts | 181 ------ packages/runtime/server/src/utils.ts | 29 - .../runtime/server/test/re-export.test.ts | 55 ++ pnpm-lock.yaml | 23 +- 29 files changed, 138 insertions(+), 2942 deletions(-) rename packages/{runtime => plugins}/server/test/custom-routes.test.ts (100%) rename packages/{runtime => plugins}/server/test/file-upload-integration.example.ts (100%) rename packages/{runtime => plugins}/server/test/file-validation.test.ts (100%) rename packages/{runtime => plugins}/server/test/graphql.test.ts (100%) rename packages/{runtime => plugins}/server/test/integration-example.ts (100%) rename packages/{runtime => plugins}/server/test/metadata.test.ts (100%) rename packages/{runtime => plugins}/server/test/node.test.ts (100%) rename packages/{runtime => plugins}/server/test/openapi.test.ts (100%) rename packages/{runtime => plugins}/server/test/rest-advanced.test.ts (100%) rename packages/{runtime => plugins}/server/test/rest.test.ts (100%) rename packages/{runtime => plugins}/server/test/storage.test.ts (100%) delete mode 100644 packages/runtime/server/src/adapters/graphql.ts delete mode 100644 packages/runtime/server/src/adapters/node.ts delete mode 100644 packages/runtime/server/src/adapters/rest.ts delete mode 100644 packages/runtime/server/src/file-handler.ts delete mode 100644 packages/runtime/server/src/metadata.ts delete mode 100644 packages/runtime/server/src/openapi.ts delete mode 100644 packages/runtime/server/src/server.ts delete mode 100644 packages/runtime/server/src/storage.ts delete mode 100644 packages/runtime/server/src/templates.ts delete mode 100644 packages/runtime/server/src/types.ts delete mode 100644 packages/runtime/server/src/utils.ts create mode 100644 packages/runtime/server/test/re-export.test.ts diff --git a/examples/integrations/express-server/package.json b/examples/integrations/express-server/package.json index 9461a6b4..cdfe3124 100644 --- a/examples/integrations/express-server/package.json +++ b/examples/integrations/express-server/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@objectql/core": "workspace:*", - "@objectql/server": "workspace:*", + "@objectql/plugin-server": "workspace:*", "@objectql/types": "workspace:*", "@objectql/driver-sql": "workspace:*", "@objectql/platform-node": "workspace:*", diff --git a/examples/integrations/express-server/src/index.ts b/examples/integrations/express-server/src/index.ts index 124818ad..41c79e9d 100644 --- a/examples/integrations/express-server/src/index.ts +++ b/examples/integrations/express-server/src/index.ts @@ -10,7 +10,7 @@ import express from 'express'; import { ObjectQL } from '@objectql/core'; import { SqlDriver } from '@objectql/driver-sql'; import { ObjectLoader } from '@objectql/platform-node'; -import { createNodeHandler, createMetadataHandler, createRESTHandler } from '@objectql/server'; +import { createNodeHandler, createMetadataHandler, createRESTHandler } from '@objectql/plugin-server'; import * as path from 'path'; async function main() { diff --git a/packages/runtime/server/test/custom-routes.test.ts b/packages/plugins/server/test/custom-routes.test.ts similarity index 100% rename from packages/runtime/server/test/custom-routes.test.ts rename to packages/plugins/server/test/custom-routes.test.ts diff --git a/packages/runtime/server/test/file-upload-integration.example.ts b/packages/plugins/server/test/file-upload-integration.example.ts similarity index 100% rename from packages/runtime/server/test/file-upload-integration.example.ts rename to packages/plugins/server/test/file-upload-integration.example.ts diff --git a/packages/runtime/server/test/file-validation.test.ts b/packages/plugins/server/test/file-validation.test.ts similarity index 100% rename from packages/runtime/server/test/file-validation.test.ts rename to packages/plugins/server/test/file-validation.test.ts diff --git a/packages/runtime/server/test/graphql.test.ts b/packages/plugins/server/test/graphql.test.ts similarity index 100% rename from packages/runtime/server/test/graphql.test.ts rename to packages/plugins/server/test/graphql.test.ts diff --git a/packages/runtime/server/test/integration-example.ts b/packages/plugins/server/test/integration-example.ts similarity index 100% rename from packages/runtime/server/test/integration-example.ts rename to packages/plugins/server/test/integration-example.ts diff --git a/packages/runtime/server/test/metadata.test.ts b/packages/plugins/server/test/metadata.test.ts similarity index 100% rename from packages/runtime/server/test/metadata.test.ts rename to packages/plugins/server/test/metadata.test.ts diff --git a/packages/runtime/server/test/node.test.ts b/packages/plugins/server/test/node.test.ts similarity index 100% rename from packages/runtime/server/test/node.test.ts rename to packages/plugins/server/test/node.test.ts diff --git a/packages/runtime/server/test/openapi.test.ts b/packages/plugins/server/test/openapi.test.ts similarity index 100% rename from packages/runtime/server/test/openapi.test.ts rename to packages/plugins/server/test/openapi.test.ts diff --git a/packages/runtime/server/test/rest-advanced.test.ts b/packages/plugins/server/test/rest-advanced.test.ts similarity index 100% rename from packages/runtime/server/test/rest-advanced.test.ts rename to packages/plugins/server/test/rest-advanced.test.ts diff --git a/packages/runtime/server/test/rest.test.ts b/packages/plugins/server/test/rest.test.ts similarity index 100% rename from packages/runtime/server/test/rest.test.ts rename to packages/plugins/server/test/rest.test.ts diff --git a/packages/runtime/server/test/storage.test.ts b/packages/plugins/server/test/storage.test.ts similarity index 100% rename from packages/runtime/server/test/storage.test.ts rename to packages/plugins/server/test/storage.test.ts diff --git a/packages/runtime/server/README.md b/packages/runtime/server/README.md index 780e3af5..94257a97 100644 --- a/packages/runtime/server/README.md +++ b/packages/runtime/server/README.md @@ -1,151 +1,95 @@ # @objectql/server -Generic HTTP Server Adapter for ObjectQL. -Allows running ObjectQL on Node.js, Express, Next.js, etc. +> **⚠️ DEPRECATED**: This package has been replaced by `@objectql/plugin-server`. +> +> This package now serves as a compatibility layer that re-exports from `@objectql/plugin-server`. +> Please migrate to `@objectql/plugin-server` for the latest features and updates. -## Installation - -```bash -pnpm add @objectql/server -``` +## Migration Guide -## Usage +### From @objectql/server to @objectql/plugin-server -### Node.js (Raw HTTP) +**Old way (still works, but deprecated):** ```typescript import { createNodeHandler } from '@objectql/server'; -import { app } from './objectql'; // Your initialized ObjectQL instance -import { createServer } from 'http'; const handler = createNodeHandler(app); -const server = createServer(handler); -server.listen(3000); ``` -### Express +**New way (recommended):** ```typescript -import express from 'express'; -import { createNodeHandler } from '@objectql/server'; -import { app } from './objectql'; +import { createNodeHandler } from '@objectql/plugin-server'; -const server = express(); +const handler = createNodeHandler(app); +``` -// Optional: Mount express.json() if you want, but ObjectQL handles parsing too. -// server.use(express.json()); +### Using the Plugin Directly -// Mount the handler -server.all('/api/objectql', createNodeHandler(app)); +For new projects, use the plugin-based approach: -server.listen(3000); +```typescript +import { ObjectQL } from '@objectql/core'; +import { ServerPlugin } from '@objectql/plugin-server'; + +const app = new ObjectQL({ + datasources: { /* ... */ }, + plugins: [ + new ServerPlugin({ + port: 3000, + autoStart: true, + enableREST: true, + enableRPC: true + }) + ] +}); + +await app.init(); ``` -### Next.js (API Routes) +### Hono Framework Support -```typescript -// pages/api/objectql.ts -import { createNodeHandler } from '@objectql/server'; -import { app } from '../../lib/objectql'; +The new plugin package supports modern frameworks like Hono: -export const config = { - api: { - bodyParser: false, // ObjectQL handles body parsing - }, -}; +```typescript +import { Hono } from 'hono'; +import { createHonoAdapter } from '@objectql/plugin-server'; -export default createNodeHandler(app); +const server = new Hono(); +const objectqlHandler = createHonoAdapter(app); +server.all('/api/*', objectqlHandler); ``` -## API Response Format - -ObjectQL uses a standardized response format for all operations: - -### List Operations (find) - -List operations return data in an `items` array with optional pagination metadata: - -```json -{ - "items": [ - { - "id": "1001", - "name": "Contract A", - "amount": 5000 - }, - { - "id": "1002", - "name": "Contract B", - "amount": 3000 - } - ], - "meta": { - "total": 105, // Total number of records - "page": 1, // Current page number (1-indexed) - "size": 20, // Number of items per page - "pages": 6, // Total number of pages - "has_next": true // Whether there is a next page - } -} -``` +## Why the Change? -**Note:** The `meta` object is only included when pagination parameters (`limit` and/or `skip`) are used. +The server functionality has been refactored into a plugin-based architecture to: -### Single Item Operations (findOne, create, update, delete) +1. **Enable Framework Agnostic Design**: Support multiple web frameworks (Express, Hono, Fastify, etc.) +2. **Improve Modularity**: Server capabilities are now optional plugins +3. **Support Edge Computing**: Hono adapter enables deployment to edge runtimes +4. **Better Extensibility**: Easier to add new adapters and features -Single item operations return data in a `data` field: +## Installation -```json -{ - "data": { - "id": "1001", - "name": "Contract A", - "amount": 5000 - } -} -``` +For new projects, install the plugin package directly: -### Error Responses - -All errors follow a consistent format: - -```json -{ - "error": { - "code": "NOT_FOUND", - "message": "Record not found", - "details": { - "field": "id", - "reason": "No record found with the given ID" - } - } -} +```bash +pnpm add @objectql/plugin-server ``` -## REST API Endpoints - -The server exposes the following REST endpoints: - -- `GET /api/data/:object` - List records (supports `?limit=10&skip=0` for pagination) -- `GET /api/data/:object/:id` - Get single record -- `POST /api/data/:object` - Create record -- `PUT /api/data/:object/:id` - Update record -- `DELETE /api/data/:object/:id` - Delete record - -### Pagination Example +For legacy support (compatibility layer): ```bash -# Get first page (10 items) -GET /api/data/contracts?limit=10&skip=0 - -# Get second page (10 items) -GET /api/data/contracts?limit=10&skip=10 +pnpm add @objectql/server ``` -## Metadata API Endpoints +## Documentation + +For complete documentation, see: +- [@objectql/plugin-server README](../../plugins/server/README.md) +- [Examples](../../../examples/integrations/) -- `GET /api/metadata/object` - List all objects -- `GET /api/metadata/object/:name` - Get object definition -- `GET /api/metadata/object/:name/actions` - List object actions +## License -All metadata list endpoints return data in the standardized `items` format. +MIT diff --git a/packages/runtime/server/package.json b/packages/runtime/server/package.json index 1a5f28c5..698fcdd8 100644 --- a/packages/runtime/server/package.json +++ b/packages/runtime/server/package.json @@ -1,7 +1,7 @@ { "name": "@objectql/server", "version": "3.0.1", - "description": "HTTP server adapter for ObjectQL - Express/NestJS compatible with GraphQL and REST API support", + "description": "HTTP server adapter for ObjectQL - Compatibility layer for @objectql/plugin-server", "keywords": [ "objectql", "server", @@ -11,25 +11,23 @@ "graphql", "express", "nestjs", + "hono", "adapter", - "backend" + "backend", + "deprecated" ], "license": "MIT", "main": "dist/index.js", "types": "dist/index.d.ts", + "deprecated": "This package has been replaced by @objectql/plugin-server. Please update your imports.", "scripts": { "build": "tsc", "test": "jest" }, "dependencies": { - "@objectql/core": "workspace:*", - "@objectql/types": "workspace:*", - "graphql": "^16.8.1", - "@graphql-tools/schema": "^10.0.2", - "js-yaml": "^4.1.1" + "@objectql/plugin-server": "workspace:*" }, "devDependencies": { - "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.0", "typescript": "^5.3.0" } diff --git a/packages/runtime/server/src/adapters/graphql.ts b/packages/runtime/server/src/adapters/graphql.ts deleted file mode 100644 index 160c2b41..00000000 --- a/packages/runtime/server/src/adapters/graphql.ts +++ /dev/null @@ -1,559 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { IObjectQL, ObjectConfig, FieldConfig } from '@objectql/types'; -import { ObjectQLServer } from '../server'; -import { ErrorCode } from '../types'; -import { IncomingMessage, ServerResponse } from 'http'; -import { graphql, GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLList, GraphQLNonNull, GraphQLInputObjectType, GraphQLFieldConfigMap, GraphQLOutputType, GraphQLInputType } from 'graphql'; - -const APOLLO_SANDBOX_HTML = ` - - - -
- - - -`; - -/** - * Normalize ObjectQL response to use 'id' instead of '_id' - */ -function normalizeId(data: unknown): unknown { - if (!data) return data; - - if (Array.isArray(data)) { - return data.map(item => normalizeId(item)); - } - - if (typeof data === 'object') { - const normalized = { ...data as Record }; - - // Map _id to id if present - if ('_id' in normalized) { - normalized.id = normalized._id; - delete normalized._id; - } - - // Remove '@type' field as it's not needed in GraphQL - delete normalized['@type']; - - return normalized; - } - - return data; -} - -/** - * Map ObjectQL field types to GraphQL types - */ -function mapFieldTypeToGraphQL(field: FieldConfig, isInput: boolean = false): GraphQLOutputType | GraphQLInputType { - const type = field.type; - - switch (type) { - case 'text': - case 'textarea': - case 'markdown': - case 'html': - case 'email': - case 'url': - case 'phone': - case 'password': - return GraphQLString; - case 'number': - case 'currency': - case 'percent': - return GraphQLFloat; - case 'autonumber': - return GraphQLInt; - case 'boolean': - return GraphQLBoolean; - case 'date': - case 'datetime': - case 'time': - return GraphQLString; // ISO 8601 string format - case 'select': - // For select fields, we could create an enum type, but for simplicity use String - return GraphQLString; - case 'lookup': - case 'master_detail': - // For relationships, return ID reference - return GraphQLString; - case 'file': - case 'image': - // File fields return metadata object (simplified as String for now) - return GraphQLString; - case 'object': - case 'formula': - case 'summary': - case 'location': - case 'vector': - case 'grid': - // Return as JSON string - return GraphQLString; - default: - return GraphQLString; - } -} - -/** - * Sanitize field/object names to be valid GraphQL identifiers - * GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/ - */ -function sanitizeGraphQLName(name: string): string { - // Replace invalid characters with underscores - let sanitized = name.replace(/[^_a-zA-Z0-9]/g, '_'); - - // Ensure it starts with a letter or underscore - if (!/^[_a-zA-Z]/.test(sanitized)) { - sanitized = '_' + sanitized; - } - - return sanitized; -} - -/** - * Generate GraphQL schema from ObjectQL metadata - */ -export function generateGraphQLSchema(app: IObjectQL): GraphQLSchema { - const objects = app.metadata.list('object'); - - // Validate that there are objects to generate schema from - if (!objects || objects.length === 0) { - // Create a minimal schema with a dummy query to avoid GraphQL error - return new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields: { - _schema: { - type: GraphQLString, - description: 'Schema introspection placeholder', - resolve: () => 'No objects registered in ObjectQL metadata' - } - } - }) - }); - } - - const typeMap: Record = {}; - const inputTypeMap: Record = {}; - const deleteResultTypeMap: Record = {}; - - // Create a shared ObjectQL server instance to reuse across resolvers - // This is safe because ObjectQLServer is stateless - it only holds a reference to the app - // and creates fresh contexts for each request via handle() - const server = new ObjectQLServer(app); - - // First pass: Create all object types - for (const config of objects) { - const objectName = config.name; - - // Skip if no name or fields defined - if (!objectName || !config.fields || Object.keys(config.fields).length === 0) { - continue; - } - - const sanitizedTypeName = sanitizeGraphQLName(objectName.charAt(0).toUpperCase() + objectName.slice(1)); - - // Create output type - const fields: GraphQLFieldConfigMap = { - id: { type: new GraphQLNonNull(GraphQLString) } - }; - - for (const [fieldName, fieldConfig] of Object.entries(config.fields)) { - const sanitizedFieldName = sanitizeGraphQLName(fieldName); - const gqlType = mapFieldTypeToGraphQL(fieldConfig, false) as GraphQLOutputType; - fields[sanitizedFieldName] = { - type: fieldConfig.required ? new GraphQLNonNull(gqlType) : gqlType, - description: fieldConfig.label || fieldName - }; - } - - typeMap[objectName] = new GraphQLObjectType({ - name: sanitizedTypeName, - description: config.label || objectName, - fields - }); - - // Create input type for mutations - const inputFields: Record = {}; - - for (const [fieldName, fieldConfig] of Object.entries(config.fields)) { - const sanitizedFieldName = sanitizeGraphQLName(fieldName); - const gqlType = mapFieldTypeToGraphQL(fieldConfig, true) as GraphQLInputType; - inputFields[sanitizedFieldName] = { - type: gqlType, - description: fieldConfig.label || fieldName - }; - } - - inputTypeMap[objectName] = new GraphQLInputObjectType({ - name: sanitizedTypeName + 'Input', - description: `Input type for ${config.label || objectName}`, - fields: inputFields - }); - - // Create delete result type (shared across all delete mutations for this object) - deleteResultTypeMap[objectName] = new GraphQLObjectType({ - name: 'Delete' + sanitizedTypeName + 'Result', - fields: { - id: { type: new GraphQLNonNull(GraphQLString) }, - deleted: { type: new GraphQLNonNull(GraphQLBoolean) } - } - }); - } - - // Build query root - const queryFields: GraphQLFieldConfigMap = {}; - - for (const config of objects) { - const objectName = config.name; - - if (!objectName || !typeMap[objectName]) continue; - - // Query single record by ID - queryFields[objectName] = { - type: typeMap[objectName], - args: { - id: { type: new GraphQLNonNull(GraphQLString) } - }, - resolve: async (_, args) => { - const result = await server.handle({ - op: 'findOne', - object: objectName, - args: args.id - }); - - if (result.error) { - throw new Error(result.error.message); - } - - return normalizeId(result); - } - }; - - // Query list of records - // Using 'List' suffix to avoid naming conflicts and handle irregular plurals - queryFields[objectName + 'List'] = { - type: new GraphQLList(typeMap[objectName]), - args: { - limit: { type: GraphQLInt }, - skip: { type: GraphQLInt }, - filters: { type: GraphQLString }, // JSON string - fields: { type: new GraphQLList(GraphQLString) }, - sort: { type: GraphQLString } // JSON string - }, - resolve: async (_, args) => { - const queryArgs: any = {}; - if (args.limit) queryArgs.limit = args.limit; - if (args.skip) queryArgs.skip = args.skip; - if (args.fields) queryArgs.fields = args.fields; - if (args.filters) { - try { - queryArgs.filters = JSON.parse(args.filters); - } catch (e) { - throw new Error('Invalid filters JSON'); - } - } - if (args.sort) { - try { - queryArgs.sort = JSON.parse(args.sort); - } catch (e) { - throw new Error('Invalid sort JSON'); - } - } - - const result = await server.handle({ - op: 'find', - object: objectName, - args: queryArgs - }); - - if (result.error) { - throw new Error(result.error.message); - } - - return normalizeId(result.items || []); - } - }; - } - - const queryType = new GraphQLObjectType({ - name: 'Query', - fields: queryFields - }); - - // Build mutation root - const mutationFields: GraphQLFieldConfigMap = {}; - - for (const config of objects) { - const objectName = config.name; - - if (!objectName || !typeMap[objectName] || !inputTypeMap[objectName]) continue; - - const capitalizedName = sanitizeGraphQLName(objectName.charAt(0).toUpperCase() + objectName.slice(1)); - - // Create mutation - mutationFields['create' + capitalizedName] = { - type: typeMap[objectName], - args: { - input: { type: new GraphQLNonNull(inputTypeMap[objectName]) } - }, - resolve: async (_, args) => { - const result = await server.handle({ - op: 'create', - object: objectName, - args: args.input - }); - - if (result.error) { - throw new Error(result.error.message); - } - - return normalizeId(result); - } - }; - - // Update mutation - mutationFields['update' + capitalizedName] = { - type: typeMap[objectName], - args: { - id: { type: new GraphQLNonNull(GraphQLString) }, - input: { type: new GraphQLNonNull(inputTypeMap[objectName]) } - }, - resolve: async (_, args) => { - const result = await server.handle({ - op: 'update', - object: objectName, - args: { - id: args.id, - data: args.input - } - }); - - if (result.error) { - throw new Error(result.error.message); - } - - return normalizeId(result); - } - }; - - // Delete mutation - use shared delete result type - mutationFields['delete' + capitalizedName] = { - type: deleteResultTypeMap[objectName], - args: { - id: { type: new GraphQLNonNull(GraphQLString) } - }, - resolve: async (_, args) => { - const result = await server.handle({ - op: 'delete', - object: objectName, - args: { id: args.id } - }); - - if (result.error) { - throw new Error(result.error.message); - } - - return result; - } - }; - } - - const mutationType = new GraphQLObjectType({ - name: 'Mutation', - fields: mutationFields - }); - - return new GraphQLSchema({ - query: queryType, - mutation: mutationType - }); -} - -/** - * Parse GraphQL request body - */ -function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => body += chunk.toString()); - req.on('end', () => { - if (!body) return resolve({}); - try { - resolve(JSON.parse(body)); - } catch (e) { - reject(new Error('Invalid JSON')); - } - }); - req.on('error', reject); - }); -} - -/** - * Send JSON response - */ -function sendJSON(res: ServerResponse, statusCode: number, data: any) { - res.setHeader('Content-Type', 'application/json'); - res.statusCode = statusCode; - res.end(JSON.stringify(data)); -} - -/** - * Creates a GraphQL HTTP request handler for ObjectQL - * - * Endpoints: - * - POST /api/graphql - GraphQL queries and mutations - * - GET /api/graphql - GraphQL queries via URL parameters - */ -export function createGraphQLHandler(app: IObjectQL) { - // Generate schema once - Note: Schema is static after handler creation. - // If metadata changes at runtime, create a new handler or regenerate the schema. - const schema = generateGraphQLSchema(app); - - return async (req: IncomingMessage & { body?: any }, res: ServerResponse) => { - try { - // CORS headers - const requestOrigin = req.headers.origin; - const configuredOrigin = process.env.OBJECTQL_CORS_ORIGIN; - const isProduction = process.env.NODE_ENV === 'production'; - - if (!isProduction) { - res.setHeader('Access-Control-Allow-Origin', configuredOrigin || '*'); - } else if (configuredOrigin && (!requestOrigin || requestOrigin === configuredOrigin)) { - res.setHeader('Access-Control-Allow-Origin', configuredOrigin); - } - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (req.method === 'OPTIONS') { - res.statusCode = 200; - res.end(); - return; - } - - // HTML Playground Support (Apollo Sandbox) - // If it's a browser GET request without query params, show the playground - const acceptHeader = req.headers.accept || ''; - const urlObj = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`); - const hasQueryParams = urlObj.searchParams.has('query'); - - if (req.method === 'GET' && acceptHeader.includes('text/html') && !hasQueryParams) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(APOLLO_SANDBOX_HTML); - return; - } - - const url = req.url || ''; - const method = req.method || 'POST'; - - if (method !== 'GET' && method !== 'POST') { - sendJSON(res, 405, { - errors: [{ - message: 'Method not allowed. Use GET or POST.' - }] - }); - return; - } - - let query: string = ''; - let variables: any = null; - let operationName: string | null = null; - - if (method === 'GET') { - // Parse query string for GET requests - const urlObj = new URL(url, `http://${req.headers.host || 'localhost'}`); - query = urlObj.searchParams.get('query') || ''; - const varsParam = urlObj.searchParams.get('variables'); - if (varsParam) { - try { - variables = JSON.parse(varsParam); - } catch (e) { - sendJSON(res, 400, { - errors: [{ - message: 'Invalid variables JSON' - }] - }); - return; - } - } - operationName = urlObj.searchParams.get('operationName'); - } else { - // Parse body for POST requests - const body = req.body || await readBody(req); - query = body.query || ''; - variables = body.variables || null; - operationName = body.operationName || null; - } - - if (!query) { - sendJSON(res, 400, { - errors: [{ - message: 'Must provide query string' - }] - }); - return; - } - - // Execute GraphQL query - const result = await graphql({ - schema, - source: query, - variableValues: variables, - operationName, - contextValue: { app } - }); - - sendJSON(res, 200, result); - - } catch (e: any) { - console.error('[GraphQL Handler] Error:', e); - - const errorResponse: { - errors: Array<{ - message: string; - extensions: { - code: ErrorCode; - debug?: { - message?: string; - stack?: string; - }; - }; - }>; - } = { - errors: [{ - message: 'Internal server error', - extensions: { - code: ErrorCode.INTERNAL_ERROR - } - }] - }; - - // In non-production environments, include additional error details to aid debugging - if (typeof process !== 'undefined' && - process.env && - process.env.NODE_ENV !== 'production') { - const firstError = errorResponse.errors[0]; - firstError.extensions.debug = { - message: e && typeof e.message === 'string' ? e.message : undefined, - stack: e && typeof e.stack === 'string' ? e.stack : undefined - }; - } - - sendJSON(res, 500, errorResponse); - } - }; -} diff --git a/packages/runtime/server/src/adapters/node.ts b/packages/runtime/server/src/adapters/node.ts deleted file mode 100644 index 3542d580..00000000 --- a/packages/runtime/server/src/adapters/node.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { IObjectQL, ApiRouteConfig, resolveApiRoutes } from '@objectql/types'; -import { ObjectQLServer } from '../server'; -import { ObjectQLRequest, ErrorCode, IFileStorage } from '../types'; -import { IncomingMessage, ServerResponse } from 'http'; -import { generateOpenAPI } from '../openapi'; -import { createFileUploadHandler, createBatchFileUploadHandler, createFileDownloadHandler } from '../file-handler'; -import { LocalFileStorage } from '../storage'; -import { escapeRegexPath } from '../utils'; -import { getWelcomePageHtml } from '../templates'; - -/** - * Options for createNodeHandler - */ -export interface NodeHandlerOptions { - /** File storage provider (defaults to LocalFileStorage) */ - fileStorage?: IFileStorage; - /** Custom API route configuration */ - routes?: ApiRouteConfig; -} - -/** - * Creates a standard Node.js HTTP request handler. - */ -export function createNodeHandler(app: IObjectQL, options?: NodeHandlerOptions) { - const server = new ObjectQLServer(app); - const routes = resolveApiRoutes(options?.routes); - - // Initialize file storage - const defaultBaseUrl = process.env.OBJECTQL_BASE_URL || `http://localhost:3000${routes.files}`; - const fileStorage = options?.fileStorage || new LocalFileStorage({ - baseDir: process.env.OBJECTQL_UPLOAD_DIR || './uploads', - baseUrl: defaultBaseUrl - }); - - // Create file handlers - const uploadHandler = createFileUploadHandler(fileStorage, app); - const batchUploadHandler = createBatchFileUploadHandler(fileStorage, app); - const downloadHandler = createFileDownloadHandler(fileStorage); - - - return async (req: IncomingMessage & { body?: any }, res: ServerResponse) => { - // CORS Headers - const origin = req.headers.origin; - if (origin) { - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Access-Control-Allow-Credentials', 'true'); - } else { - res.setHeader('Access-Control-Allow-Origin', '*'); - } - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); - - // Handle preflight requests - if (req.method === 'OPTIONS') { - res.statusCode = 204; - res.end(); - return; - } - - // Handle OpenAPI spec request - if (req.method === 'GET' && req.url?.endsWith('/openapi.json')) { - const spec = generateOpenAPI(app); - res.setHeader('Content-Type', 'application/json'); - res.statusCode = 200; - res.end(JSON.stringify(spec)); - return; - } - - const handleRequest = async (json: any) => { - try { - // Determine Operation based on JSON or previously derived info - const qlReq: ObjectQLRequest = { - op: json.op, - object: json.object, - args: json.args, - user: json.user, - ai_context: json.ai_context - }; - - const result = await server.handle(qlReq); - - // Determine HTTP status code based on error - let statusCode = 200; - if (result.error) { - switch (result.error.code) { - case ErrorCode.INVALID_REQUEST: - case ErrorCode.VALIDATION_ERROR: - statusCode = 400; - break; - case ErrorCode.UNAUTHORIZED: - statusCode = 401; - break; - case ErrorCode.FORBIDDEN: - statusCode = 403; - break; - case ErrorCode.NOT_FOUND: - statusCode = 404; - break; - case ErrorCode.CONFLICT: - statusCode = 409; - break; - case ErrorCode.RATE_LIMIT_EXCEEDED: - statusCode = 429; - break; - default: - statusCode = 500; - } - } - - res.setHeader('Content-Type', 'application/json'); - res.statusCode = statusCode; - res.end(JSON.stringify(result)); - } catch (e) { - console.error(e); - res.statusCode = 500; - res.end(JSON.stringify({ - error: { - code: ErrorCode.INTERNAL_ERROR, - message: 'Internal Server Error' - } - })); - } - }; - - // Parse URL - const urlObj = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); - const pathName = urlObj.pathname; - const method = req.method; - - // 1. JSON-RPC: POST {rpcPath} - if (pathName === routes.rpc && method === 'POST') { - await processBody(req, async (json) => { - await handleRequest(json); - }, res); - return; - } - - // 2. REST API: {dataPath}/:object and {dataPath}/:object/:id - // Regex to match {dataPath}/objectName(/id)? - const escapedDataPath = escapeRegexPath(routes.data); - const restMatch = pathName.match(new RegExp(`^${escapedDataPath}/([^/]+)(?:/(.+))?$`)); - - if (restMatch) { - const objectName = restMatch[1]; - const id = restMatch[2]; - const query = Object.fromEntries(urlObj.searchParams.entries()); - - if (method === 'GET') { - // GET {dataPath}/:object/:id -> findOne - if (id) { - await handleRequest({ - op: 'findOne', - object: objectName, - args: id - }); - } - // GET {dataPath}/:object -> find (List) - else { - // Parse standard params - const args: any = {}; - if (query.fields) args.fields = (query.fields as string).split(','); - if (query.top) args.limit = parseInt(query.top as string); - if (query.skip) args.skip = parseInt(query.skip as string); - if (query.filter) { - try { - args.filters = JSON.parse(query.filter as string); - } catch (e) { - // ignore invalid filter json - } - } - await handleRequest({ op: 'find', object: objectName, args }); - } - return; - } - - if (method === 'POST' && !id) { - // POST {dataPath}/:object -> create - await processBody(req, async (body) => { - await handleRequest({ - op: 'create', - object: objectName, - args: body.data || body // Support enclosed in data or flat - }); - }, res); - return; - } - - if (method === 'PATCH' && id) { - // PATCH {dataPath}/:object/:id -> update - await processBody(req, async (body) => { - await handleRequest({ - op: 'update', - object: objectName, - args: { - id: id, - data: body.data || body - } - }); - }, res); - return; - } - - if (method === 'DELETE' && id) { - // DELETE {dataPath}/:object/:id -> delete - await handleRequest({ - op: 'delete', - object: objectName, - args: { id: id } - }); - return; - } - } - - // File Upload Endpoints - // POST {filesPath}/upload - Single file upload - if (pathName === `${routes.files}/upload` && method === 'POST') { - await uploadHandler(req, res); - return; - } - - // POST {filesPath}/upload/batch - Batch file upload - if (pathName === `${routes.files}/upload/batch` && method === 'POST') { - await batchUploadHandler(req, res); - return; - } - - // GET {filesPath}/:fileId - Download file - const escapedFilesPath = escapeRegexPath(routes.files); - const fileMatch = pathName.match(new RegExp(`^${escapedFilesPath}/([^/]+)$`)); - if (fileMatch && method === 'GET') { - const fileId = fileMatch[1]; - await downloadHandler(req, res, fileId); - return; - } - - // Fallback or 404 - if (req.method === 'POST') { - // Fallback for root POSTs if people forget {rpcPath} but send to /api something - await processBody(req, handleRequest, res); - return; - } - - // Special case for root: since we accept POST / (RPC), correct response for GET / is 405 - if (pathName === '/') { - if (method === 'GET') { - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.statusCode = 200; - res.end(getWelcomePageHtml(routes)); - return; - } - - res.setHeader('Allow', 'POST'); - res.statusCode = 405; - res.end(JSON.stringify({ error: { code: ErrorCode.INVALID_REQUEST, message: 'Method Not Allowed. Use POST for JSON-RPC.' } })); - return; - } - - res.statusCode = 404; - res.end(JSON.stringify({ error: { code: ErrorCode.NOT_FOUND, message: 'Not Found' } })); - }; -} - -// Helper to process body -async function processBody(req: IncomingMessage & { body?: any }, callback: (json: any) => Promise, res: ServerResponse) { - if (req.body && typeof req.body === 'object') { - return callback(req.body); - } - - let body = ''; - req.on('data', chunk => body += chunk); - req.on('end', async () => { - try { - const json = body ? JSON.parse(body) : {}; - await callback(json); - } catch (e) { - res.statusCode = 400; - res.end(JSON.stringify({ - error: { - code: 'INVALID_JSON', - message: 'Invalid JSON body' - } - })); - } - }); -} diff --git a/packages/runtime/server/src/adapters/rest.ts b/packages/runtime/server/src/adapters/rest.ts deleted file mode 100644 index d0396320..00000000 --- a/packages/runtime/server/src/adapters/rest.ts +++ /dev/null @@ -1,338 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { IObjectQL, ApiRouteConfig, resolveApiRoutes } from '@objectql/types'; -import { ObjectQLServer } from '../server'; -import { ObjectQLRequest, ErrorCode } from '../types'; -import { IncomingMessage, ServerResponse } from 'http'; -import { escapeRegexPath } from '../utils'; - -/** - * Parse query string parameters - */ -function parseQueryParams(url: string): Record { - const params: Record = {}; - const queryIndex = url.indexOf('?'); - if (queryIndex === -1) return params; - - const queryString = url.substring(queryIndex + 1); - const pairs = queryString.split('&'); - - for (const pair of pairs) { - const [key, value] = pair.split('='); - if (!key) continue; - - const decodedKey = decodeURIComponent(key); - const decodedValue = decodeURIComponent(value || ''); - - // Try to parse JSON values - try { - params[decodedKey] = JSON.parse(decodedValue); - } catch { - params[decodedKey] = decodedValue; - } - } - - return params; -} - -/** - * Read request body as JSON - */ -function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => body += chunk.toString()); - req.on('end', () => { - if (!body) return resolve({}); - try { - resolve(JSON.parse(body)); - } catch (e) { - reject(new Error('Invalid JSON')); - } - }); - req.on('error', reject); - }); -} - -/** - * Send JSON response - */ -function sendJSON(res: ServerResponse, statusCode: number, data: any) { - res.setHeader('Content-Type', 'application/json'); - res.statusCode = statusCode; - res.end(JSON.stringify(data)); -} - -/** - * Options for createRESTHandler - */ -export interface RESTHandlerOptions { - /** Custom API route configuration */ - routes?: ApiRouteConfig; -} - -/** - * Creates a REST-style HTTP request handler for ObjectQL - * - * Default Endpoints (configurable via routes option): - * - GET {dataPath}/:object - List records - * - GET {dataPath}/:object/:id - Get single record - * - POST {dataPath}/:object - Create record (or create many if array) - * - POST {dataPath}/:object/bulk-update - Update many records - * - POST {dataPath}/:object/bulk-delete - Delete many records - * - PUT {dataPath}/:object/:id - Update record - * - DELETE {dataPath}/:object/:id - Delete record - * - * @param app - ObjectQL application instance - * @param options - Optional configuration including custom routes - */ -export function createRESTHandler(app: IObjectQL, options?: RESTHandlerOptions) { - const server = new ObjectQLServer(app); - const routes = resolveApiRoutes(options?.routes); - const dataPath = routes.data; - - return async (req: IncomingMessage & { body?: any }, res: ServerResponse) => { - try { - // CORS headers - const requestOrigin = req.headers.origin; - const configuredOrigin = process.env.OBJECTQL_CORS_ORIGIN; - const isProduction = process.env.NODE_ENV === 'production'; - - // In development, allow all origins by default (or use configured override). - // In production, require an explicit OBJECTQL_CORS_ORIGIN to be set. - if (!isProduction) { - res.setHeader('Access-Control-Allow-Origin', configuredOrigin || '*'); - } else if (configuredOrigin && (!requestOrigin || requestOrigin === configuredOrigin)) { - res.setHeader('Access-Control-Allow-Origin', configuredOrigin); - } - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (req.method === 'OPTIONS') { - res.statusCode = 200; - res.end(); - return; - } - - const url = req.url || ''; - const method = req.method || 'GET'; - - // Parse URL: {dataPath}/:object or {dataPath}/:object/:id or {dataPath}/:object/bulk-* - const escapedPath = escapeRegexPath(dataPath); - const match = url.match(new RegExp(`^${escapedPath}/([^/\\?]+)(?:/([^/\\?]+))?(\\?.*)?$`)); - - if (!match) { - sendJSON(res, 404, { - error: { - code: ErrorCode.NOT_FOUND, - message: 'Invalid REST API endpoint' - } - }); - return; - } - - const [, objectName, id, queryString] = match; - const queryParams = queryString ? parseQueryParams(queryString) : {}; - - let qlRequest: ObjectQLRequest; - - switch (method) { - case 'GET': - if (id) { - // GET /api/data/:object/:id - Get single record - qlRequest = { - op: 'findOne', - object: objectName, - args: id - }; - } else { - // GET {dataPath}/:object - List records - const args: any = {}; - - // Parse query parameters - if (queryParams.filter) { - args.filters = queryParams.filter; - } - if (queryParams.fields) { - args.fields = queryParams.fields; - } - if (queryParams.sort) { - args.sort = Array.isArray(queryParams.sort) - ? queryParams.sort - : [[queryParams.sort, 'asc']]; - } - if (queryParams.top || queryParams.limit) { - args.limit = queryParams.top || queryParams.limit; - } - if (queryParams.skip || queryParams.offset) { - args.skip = queryParams.skip || queryParams.offset; - } - if (queryParams.expand) { - args.expand = queryParams.expand; - } - - qlRequest = { - op: 'find', - object: objectName, - args - }; - } - break; - - case 'POST': - const createBody = req.body || await readBody(req); - - // Check for bulk operations - if (id === 'bulk-update') { - // POST {dataPath}/:object/bulk-update - Update many records - qlRequest = { - op: 'updateMany', - object: objectName, - args: { - filters: createBody.filters, - data: createBody.data - } - }; - } else if (id === 'bulk-delete') { - // POST {dataPath}/:object/bulk-delete - Delete many records - qlRequest = { - op: 'deleteMany', - object: objectName, - args: { - filters: createBody.filters || {} - } - }; - } else if (Array.isArray(createBody)) { - // POST {dataPath}/:object with array - Create many records - qlRequest = { - op: 'createMany', - object: objectName, - args: createBody - }; - } else { - // POST {dataPath}/:object - Create single record - qlRequest = { - op: 'create', - object: objectName, - args: createBody - }; - } - break; - - case 'PUT': - case 'PATCH': - // PUT {dataPath}/:object/:id - Update record - if (!id) { - sendJSON(res, 400, { - error: { - code: ErrorCode.INVALID_REQUEST, - message: 'ID is required for update operation' - } - }); - return; - } - - const updateBody = req.body || await readBody(req); - qlRequest = { - op: 'update', - object: objectName, - args: { - id, - data: updateBody - } - }; - break; - - case 'DELETE': - // DELETE {dataPath}/:object/:id - Delete record - if (!id) { - sendJSON(res, 400, { - error: { - code: ErrorCode.INVALID_REQUEST, - message: 'ID is required for delete operation' - } - }); - return; - } - - qlRequest = { - op: 'delete', - object: objectName, - args: { id } - }; - break; - - default: - sendJSON(res, 405, { - error: { - code: ErrorCode.INVALID_REQUEST, - message: 'Method not allowed' - } - }); - return; - } - - // Execute the request - const result = await server.handle(qlRequest); - - if (!result) { - sendJSON(res, 404, { - error: { - code: ErrorCode.NOT_FOUND, - message: 'Resource not found' - } - }); - return; - } - - // Determine HTTP status code - let statusCode = 200; - if (result.error) { - switch (result.error.code) { - case ErrorCode.INVALID_REQUEST: - case ErrorCode.VALIDATION_ERROR: - statusCode = 400; - break; - case ErrorCode.UNAUTHORIZED: - statusCode = 401; - break; - case ErrorCode.FORBIDDEN: - statusCode = 403; - break; - case ErrorCode.NOT_FOUND: - statusCode = 404; - break; - case ErrorCode.CONFLICT: - statusCode = 409; - break; - case ErrorCode.RATE_LIMIT_EXCEEDED: - statusCode = 429; - break; - default: - statusCode = 500; - } - } else if (method === 'POST' && qlRequest.op === 'create') { - statusCode = 201; // Created - only for single create - } else if (method === 'POST' && qlRequest.op === 'createMany') { - statusCode = 201; // Created - for bulk create - } - - sendJSON(res, statusCode, result); - - } catch (e: any) { - console.error('[REST Handler] Error:', e); - sendJSON(res, 500, { - error: { - code: ErrorCode.INTERNAL_ERROR, - message: 'Internal server error' - } - }); - } - }; -} diff --git a/packages/runtime/server/src/file-handler.ts b/packages/runtime/server/src/file-handler.ts deleted file mode 100644 index 03846347..00000000 --- a/packages/runtime/server/src/file-handler.ts +++ /dev/null @@ -1,422 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { IncomingMessage, ServerResponse } from 'http'; -import { IFileStorage, AttachmentData, ErrorCode } from './types'; -import { IObjectQL, FieldConfig } from '@objectql/types'; - -/** - * Parse multipart/form-data request - */ -export function parseMultipart( - req: IncomingMessage, - boundary: string -): Promise<{ fields: Record; files: Array<{ fieldname: string; filename: string; mimeType: string; buffer: Buffer }> }> { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - - req.on('data', (chunk) => chunks.push(chunk)); - req.on('error', reject); - req.on('end', () => { - try { - const buffer = Buffer.concat(chunks); - const result = parseMultipartBuffer(buffer, boundary); - resolve(result); - } catch (error) { - reject(error); - } - }); - }); -} - -function parseMultipartBuffer( - buffer: Buffer, - boundary: string -): { fields: Record; files: Array<{ fieldname: string; filename: string; mimeType: string; buffer: Buffer }> } { - const fields: Record = {}; - const files: Array<{ fieldname: string; filename: string; mimeType: string; buffer: Buffer }> = []; - - const delimiter = Buffer.from(`--${boundary}`); - const parts = splitBuffer(buffer, delimiter); - - for (const part of parts) { - if (part.length === 0 || part.toString().trim() === '--') { - continue; - } - - // Find header/body separator (double CRLF) - const headerEnd = findSequence(part, Buffer.from('\r\n\r\n')); - if (headerEnd === -1) continue; - - const headerSection = part.slice(0, headerEnd).toString(); - const bodySection = part.slice(headerEnd + 4); - - // Parse Content-Disposition header - const dispositionMatch = headerSection.match(/Content-Disposition: form-data; name="([^"]+)"(?:; filename="([^"]+)")?/i); - if (!dispositionMatch) continue; - - const fieldname = dispositionMatch[1]; - const filename = dispositionMatch[2]; - - if (filename) { - // This is a file upload - const contentTypeMatch = headerSection.match(/Content-Type: (.+)/i); - const mimeType = contentTypeMatch ? contentTypeMatch[1].trim() : 'application/octet-stream'; - - // Remove trailing CRLF from body - let fileBuffer = bodySection; - if (fileBuffer.length >= 2 && fileBuffer[fileBuffer.length - 2] === 0x0d && fileBuffer[fileBuffer.length - 1] === 0x0a) { - fileBuffer = fileBuffer.slice(0, -2); - } - - files.push({ fieldname, filename, mimeType, buffer: fileBuffer }); - } else { - // This is a regular form field - let value = bodySection.toString('utf-8'); - if (value.endsWith('\r\n')) { - value = value.slice(0, -2); - } - fields[fieldname] = value; - } - } - - return { fields, files }; -} - -function splitBuffer(buffer: Buffer, delimiter: Buffer): Buffer[] { - const parts: Buffer[] = []; - let start = 0; - let pos = 0; - - while (pos <= buffer.length - delimiter.length) { - let match = true; - for (let i = 0; i < delimiter.length; i++) { - if (buffer[pos + i] !== delimiter[i]) { - match = false; - break; - } - } - - if (match) { - if (pos > start) { - parts.push(buffer.slice(start, pos)); - } - pos += delimiter.length; - start = pos; - } else { - pos++; - } - } - - if (start < buffer.length) { - parts.push(buffer.slice(start)); - } - - return parts; -} - -function findSequence(buffer: Buffer, sequence: Buffer): number { - for (let i = 0; i <= buffer.length - sequence.length; i++) { - let match = true; - for (let j = 0; j < sequence.length; j++) { - if (buffer[i + j] !== sequence[j]) { - match = false; - break; - } - } - if (match) return i; - } - return -1; -} - -/** - * Validate uploaded file against field configuration - */ -export function validateFile( - file: { filename: string; mimeType: string; buffer: Buffer }, - fieldConfig?: FieldConfig, - objectName?: string, - fieldName?: string -): { valid: boolean; error?: { code: string; message: string; details?: any } } { - // If no field config provided, allow the upload - if (!fieldConfig) { - return { valid: true }; - } - - const fileSize = file.buffer.length; - const fileName = file.filename; - const mimeType = file.mimeType; - - // Validate file size - if (fieldConfig.max_size && fileSize > fieldConfig.max_size) { - return { - valid: false, - error: { - code: 'FILE_TOO_LARGE', - message: `File size (${fileSize} bytes) exceeds maximum allowed size (${fieldConfig.max_size} bytes)`, - details: { - file: fileName, - size: fileSize, - max_size: fieldConfig.max_size - } - } - }; - } - - if (fieldConfig.min_size && fileSize < fieldConfig.min_size) { - return { - valid: false, - error: { - code: 'FILE_TOO_SMALL', - message: `File size (${fileSize} bytes) is below minimum required size (${fieldConfig.min_size} bytes)`, - details: { - file: fileName, - size: fileSize, - min_size: fieldConfig.min_size - } - } - }; - } - - // Validate file type/extension - if (fieldConfig.accept && Array.isArray(fieldConfig.accept) && fieldConfig.accept.length > 0) { - const fileExt = fileName.substring(fileName.lastIndexOf('.')).toLowerCase(); - const acceptedExtensions = fieldConfig.accept.map(ext => ext.toLowerCase()); - - if (!acceptedExtensions.includes(fileExt)) { - return { - valid: false, - error: { - code: 'FILE_TYPE_NOT_ALLOWED', - message: `File type '${fileExt}' is not allowed. Allowed types: ${acceptedExtensions.join(', ')}`, - details: { - file: fileName, - extension: fileExt, - allowed: acceptedExtensions - } - } - }; - } - } - - return { valid: true }; -} - -/** - * Send error response - */ -export function sendError(res: ServerResponse, statusCode: number, code: string, message: string, details?: any) { - res.setHeader('Content-Type', 'application/json'); - res.statusCode = statusCode; - res.end(JSON.stringify({ - error: { - code, - message, - details - } - })); -} - -/** - * Send success response - */ -export function sendSuccess(res: ServerResponse, data: any) { - res.setHeader('Content-Type', 'application/json'); - res.statusCode = 200; - res.end(JSON.stringify({ data })); -} - -/** - * Extract user ID from authorization header - * @internal This is a placeholder implementation. In production, integrate with actual auth middleware. - */ -function extractUserId(authHeader: string | undefined): string | undefined { - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return undefined; - } - - // TODO: In production, decode JWT or validate token properly - // This is a placeholder implementation - console.warn('[Security] File upload authentication is using placeholder implementation. Integrate with actual auth system.'); - return 'user_from_token'; -} - -/** - * Create file upload handler - */ -export function createFileUploadHandler(storage: IFileStorage, app: IObjectQL) { - return async (req: IncomingMessage, res: ServerResponse) => { - try { - // Check content type - const contentType = req.headers['content-type']; - if (!contentType || !contentType.startsWith('multipart/form-data')) { - sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Content-Type must be multipart/form-data'); - return; - } - - // Extract boundary - const boundaryMatch = contentType.match(/boundary=(.+)/); - if (!boundaryMatch) { - sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Missing boundary in Content-Type'); - return; - } - - const boundary = boundaryMatch[1]; - - // Parse multipart data - const { fields, files } = await parseMultipart(req, boundary); - - if (files.length === 0) { - sendError(res, 400, ErrorCode.INVALID_REQUEST, 'No file provided'); - return; - } - - // Get field configuration if object and field are specified - let fieldConfig: FieldConfig | undefined; - if (fields.object && fields.field) { - const objectConfig = (app as any).getObject(fields.object); - if (objectConfig && objectConfig.fields) { - fieldConfig = objectConfig.fields[fields.field]; - } - } - - // Single file upload - const file = files[0]; - - // Validate file - const validation = validateFile(file, fieldConfig, fields.object, fields.field); - if (!validation.valid) { - sendError(res, 400, validation.error!.code, validation.error!.message, validation.error!.details); - return; - } - - // Extract user ID from authorization header - const userId = extractUserId(req.headers.authorization); - - // Save file - const attachmentData = await storage.save( - file.buffer, - file.filename, - file.mimeType, - { - folder: fields.folder, - object: fields.object, - field: fields.field, - userId - } - ); - - sendSuccess(res, attachmentData); - } catch (error) { - console.error('File upload error:', error); - sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'File upload failed'); - } - }; -} - -/** - * Create batch file upload handler - */ -export function createBatchFileUploadHandler(storage: IFileStorage, app: IObjectQL) { - return async (req: IncomingMessage, res: ServerResponse) => { - try { - // Check content type - const contentType = req.headers['content-type']; - if (!contentType || !contentType.startsWith('multipart/form-data')) { - sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Content-Type must be multipart/form-data'); - return; - } - - // Extract boundary - const boundaryMatch = contentType.match(/boundary=(.+)/); - if (!boundaryMatch) { - sendError(res, 400, ErrorCode.INVALID_REQUEST, 'Missing boundary in Content-Type'); - return; - } - - const boundary = boundaryMatch[1]; - - // Parse multipart data - const { fields, files } = await parseMultipart(req, boundary); - - if (files.length === 0) { - sendError(res, 400, ErrorCode.INVALID_REQUEST, 'No files provided'); - return; - } - - // Get field configuration if object and field are specified - let fieldConfig: FieldConfig | undefined; - if (fields.object && fields.field) { - const objectConfig = (app as any).getObject(fields.object); - if (objectConfig && objectConfig.fields) { - fieldConfig = objectConfig.fields[fields.field]; - } - } - - // Extract user ID from authorization header - const userId = extractUserId(req.headers.authorization); - - // Upload all files - const uploadedFiles: AttachmentData[] = []; - - for (const file of files) { - // Validate each file - const validation = validateFile(file, fieldConfig, fields.object, fields.field); - if (!validation.valid) { - sendError(res, 400, validation.error!.code, validation.error!.message, validation.error!.details); - return; - } - - // Save file - const attachmentData = await storage.save( - file.buffer, - file.filename, - file.mimeType, - { - folder: fields.folder, - object: fields.object, - field: fields.field, - userId - } - ); - - uploadedFiles.push(attachmentData); - } - - sendSuccess(res, uploadedFiles); - } catch (error) { - console.error('Batch file upload error:', error); - sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Batch file upload failed'); - } - }; -} - -/** - * Create file download handler - */ -export function createFileDownloadHandler(storage: IFileStorage) { - return async (req: IncomingMessage, res: ServerResponse, fileId: string) => { - try { - const file = await storage.get(fileId); - - if (!file) { - sendError(res, 404, ErrorCode.NOT_FOUND, 'File not found'); - return; - } - - // Set appropriate headers - res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('Content-Length', file.length); - res.statusCode = 200; - res.end(file); - } catch (error) { - console.error('File download error:', error); - sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'File download failed'); - } - }; -} diff --git a/packages/runtime/server/src/index.ts b/packages/runtime/server/src/index.ts index c26c5b12..1bec962d 100644 --- a/packages/runtime/server/src/index.ts +++ b/packages/runtime/server/src/index.ts @@ -7,29 +7,26 @@ */ /** - * @deprecated This package is deprecated. Use @objectql/plugin-server instead. + * @deprecated This package has been replaced by @objectql/plugin-server + * + * This package now serves as a compatibility layer that re-exports from @objectql/plugin-server. + * Please update your imports to use @objectql/plugin-server directly: * * @example * ```typescript - * // Old way (still supported for backward compatibility) + * // Old (deprecated, but still works): * import { createNodeHandler } from '@objectql/server'; * - * // New way (recommended) + * // New (recommended): + * import { createNodeHandler } from '@objectql/plugin-server'; + * + * // Or use the plugin directly: * import { ServerPlugin } from '@objectql/plugin-server'; * ``` + * + * All server functionality has been moved to @objectql/plugin-server + * to enable a plugin-based architecture with support for multiple frameworks. */ -export * from './types'; -export * from './utils'; -export * from './openapi'; -export * from './server'; -export * from './metadata'; -export * from './storage'; -export * from './file-handler'; -// We export createNodeHandler from root for convenience, -// but in the future we might encourage 'import ... from @objectql/server/node' -export * from './adapters/node'; -// Export REST adapter -export * from './adapters/rest'; -// Export GraphQL adapter -export * from './adapters/graphql'; +// Re-export everything from the plugin package +export * from '@objectql/plugin-server'; diff --git a/packages/runtime/server/src/metadata.ts b/packages/runtime/server/src/metadata.ts deleted file mode 100644 index ae655f6e..00000000 --- a/packages/runtime/server/src/metadata.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { IObjectQL, ApiRouteConfig, resolveApiRoutes } from '@objectql/types'; -import { IncomingMessage, ServerResponse } from 'http'; -import { ErrorCode } from './types'; -import { escapeRegexPath } from './utils'; - -function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => body += chunk.toString()); - req.on('end', () => { - if (!body) return resolve({}); - try { - resolve(JSON.parse(body)); - } catch (e) { - reject(e); - } - }); - req.on('error', reject); - }); -} - -/** - * Options for createMetadataHandler - */ -export interface MetadataHandlerOptions { - /** Custom API route configuration */ - routes?: ApiRouteConfig; -} - -/** - * Creates a handler for metadata endpoints. - * These endpoints expose information about registered objects and other metadata. - * - * @param app - ObjectQL application instance - * @param options - Optional configuration including custom routes - */ -export function createMetadataHandler(app: IObjectQL, options?: MetadataHandlerOptions) { - const routes = resolveApiRoutes(options?.routes); - const metadataPath = routes.metadata; - return async (req: IncomingMessage, res: ServerResponse) => { - // Parse the URL - const url = req.url || ''; - const method = req.method; - - // CORS headers for development - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - - if (req.method === 'OPTIONS') { - res.statusCode = 200; - res.end(); - return; - } - - try { - // Helper to send JSON - const sendJson = (data: any, status = 200) => { - res.setHeader('Content-Type', 'application/json'); - res.statusCode = status; - res.end(JSON.stringify(data)); - }; - - const sendError = (code: ErrorCode, message: string, status = 400) => { - sendJson({ error: { code, message } }, status); - }; - - // --------------------------------------------------------- - // 1. List Entries (GET {metadataPath}/:type) - // --------------------------------------------------------- - - // Generic List: {metadataPath}/:type - // Also handles legacy {metadataPath} (defaults to objects) - const escapedPath = escapeRegexPath(metadataPath); - const listMatch = url.match(new RegExp(`^${escapedPath}/([^/]+)$`)); - const isRootMetadata = url === metadataPath; - - if (method === 'GET' && (listMatch || isRootMetadata)) { - let type = isRootMetadata ? 'object' : listMatch![1]; - if (type === 'objects') type = 'object'; // Alias behavior - - if (type === 'object') { - const configs = app.getConfigs(); - const objects = Object.values(configs).map(obj => ({ - name: obj.name, - label: obj.label || obj.name, - icon: obj.icon, - description: obj.description, - fields: obj.fields || {} - })); - // Return standardized format with items - return sendJson({ items: objects }); - } - - const entries = app.metadata.list(type); - // Return standardized list format - return sendJson({ - items: entries - }); - } - - // --------------------------------------------------------- - // 2. Get Single Entry (GET {metadataPath}/:type/:id) - // --------------------------------------------------------- - - const detailMatch = url.match(new RegExp(`^${escapedPath}/([^/]+)/([^/\\?]+)$`)); - - if (method === 'GET' && detailMatch) { - let [, type, id] = detailMatch; - if (type === 'objects') type = 'object'; - - // Handle Object Special Logic (Field Formatting) - if (type === 'object') { - const metadata = app.getObject(id); - if (!metadata) { - return sendError(ErrorCode.NOT_FOUND, `Object '${id}' not found`, 404); - } - - // Convert fields to map with name populated - const fields: Record = {}; - if (metadata.fields) { - Object.entries(metadata.fields).forEach(([key, field]) => { - fields[key] = { - ...field, - name: field.name || key - }; - }); - } - - return sendJson({ - ...metadata, - fields - }); - } else { - // Generic Metadata (View, Form, etc.) - const content = app.metadata.get(type, id); - if (!content) { - return sendError(ErrorCode.NOT_FOUND, `${type} '${id}' not found`, 404); - } - return sendJson(content); - } - } - - // --------------------------------------------------------- - // 3. Update Entry (POST/PUT {metadataPath}/:type/:id) - // --------------------------------------------------------- - if ((method === 'POST' || method === 'PUT') && detailMatch) { - let [, type, id] = detailMatch; - if (type === 'objects') type = 'object'; - - const body = await readBody(req); - try { - // await app.updateMetadata(type, id, body); - // return sendJson({ success: true }); - return sendError(ErrorCode.INTERNAL_ERROR, 'Metadata updates via API are temporarily disabled in this architectural version.', 501); - } catch (e: any) { - const isUserError = e.message.startsWith('Cannot update') || e.message.includes('not found'); - return sendError( - isUserError ? ErrorCode.INVALID_REQUEST : ErrorCode.INTERNAL_ERROR, - e.message, - isUserError ? 400 : 500 - ); - } - } - - // --------------------------------------------------------- - // 4. Object Sub-resources (Fields, Actions) - // --------------------------------------------------------- - - // GET {metadataPath}/object/:name/fields/:field - // Legacy path support. - const fieldMatch = url.match(new RegExp(`^${escapedPath}/(?:objects|object)/([^/]+)/fields/([^/\\?]+)$`)); - if (method === 'GET' && fieldMatch) { - const [, objectName, fieldName] = fieldMatch; - const metadata = app.getObject(objectName); - - if (!metadata) return sendError(ErrorCode.NOT_FOUND, `Object '${objectName}' not found`, 404); - - const field = metadata.fields?.[fieldName]; - if (!field) return sendError(ErrorCode.NOT_FOUND, `Field '${fieldName}' not found`, 404); - - return sendJson({ - name: field.name || fieldName, - type: field.type, - label: field.label, - required: field.required, - unique: field.unique, - defaultValue: field.defaultValue, - options: field.options, - min: field.min, - max: field.max, - minLength: field.minLength, - maxLength: field.maxLength, - validation: field.validation - }); - } - - // GET {metadataPath}/object/:name/actions - const actionsMatch = url.match(new RegExp(`^${escapedPath}/(?:objects|object)/([^/]+)/actions$`)); - if (method === 'GET' && actionsMatch) { - const [, objectName] = actionsMatch; - const metadata = app.getObject(objectName); - - if (!metadata) return sendError(ErrorCode.NOT_FOUND, `Object '${objectName}' not found`, 404); - - const actions = metadata.actions || {}; - const formattedActions = Object.entries(actions).map(([key, action]) => { - const actionConfig = action as any; - const hasFields = !!actionConfig.fields && Object.keys(actionConfig.fields).length > 0; - return { - name: key, - type: actionConfig.type || (hasFields ? 'record' : 'global'), - label: actionConfig.label || key, - params: actionConfig.params || {}, - description: actionConfig.description - }; - }); - - return sendJson({ items: formattedActions }); - } - - // Not found - sendError(ErrorCode.NOT_FOUND, 'Not Found', 404); - - } catch (e: any) { - console.error('[Metadata Handler] Error:', e); - res.statusCode = 500; - res.end(JSON.stringify({ - error: { - code: ErrorCode.INTERNAL_ERROR, - message: 'Internal Server Error' - } - })); - } - }; -} diff --git a/packages/runtime/server/src/openapi.ts b/packages/runtime/server/src/openapi.ts deleted file mode 100644 index e122295d..00000000 --- a/packages/runtime/server/src/openapi.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { IObjectQL, ObjectConfig, FieldConfig, ApiRouteConfig, resolveApiRoutes } from '@objectql/types'; - -interface OpenAPISchema { - openapi: string; - info: { - title: string; - version: string; - }; - paths: Record; - components: { - schemas: Record; - }; -} - -export function generateOpenAPI(app: IObjectQL, routeConfig?: ApiRouteConfig): OpenAPISchema { - const registry = (app as any).metadata; // Direct access or via interface - const objects = registry.list('object') as ObjectConfig[]; - const routes = resolveApiRoutes(routeConfig); - - const paths: Record = {}; - const schemas: Record = {}; - - - // 1. JSON-RPC Endpoint - paths[routes.rpc] = { - post: { - summary: 'JSON-RPC Entry Point', - description: 'Execute any ObjectQL operation via a JSON body.', - tags: ['System'], - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - op: { type: 'string', enum: ['find', 'findOne', 'create', 'update', 'delete', 'count', 'action'] }, - object: { type: 'string' }, - args: { type: 'object' } - }, - required: ['op', 'object'] - } - } - } - }, - responses: { - 200: { - description: 'Operation Result', - content: { - 'application/json': { - schema: { type: 'object' } // Dynamic result - } - } - } - } - } - }; - - // 2. Generate Schemas - for (const obj of objects) { - const schemaName = obj.name; - const properties: Record = {}; - - for (const [fieldName, field] of Object.entries(obj.fields)) { - properties[fieldName] = mapFieldTypeToOpenAPI(field); - } - - schemas[schemaName] = { - type: 'object', - properties - }; - } - - // 3. REST API Paths - for (const obj of objects) { - const name = obj.name; - const basePath = `${routes.data}/${name}`; // Standard REST Path - - // GET {dataPath}/:name (List) - paths[basePath] = { - get: { - summary: `List ${name}`, - tags: [name], - parameters: [ - { name: 'filter', in: 'query', schema: { type: 'string' }, description: 'JSON filter args' }, - { name: 'fields', in: 'query', schema: { type: 'string' }, description: 'Comma-separated fields to return' }, - { name: 'top', in: 'query', schema: { type: 'integer' }, description: 'Limit' }, - { name: 'skip', in: 'query', schema: { type: 'integer' }, description: 'Offset' } - ], - responses: { - 200: { - description: `List of ${name}`, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - data: { type: 'array', items: { $ref: `#/components/schemas/${name}` } } - } - } - } - } - } - } - }, - post: { - summary: `Create ${name}`, - tags: [name], - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - data: { $ref: `#/components/schemas/${name}` } - } - } - } - } - }, - responses: { - 200: { description: 'Created' } - } - } - }; - - // /api/data/:name/:id - paths[`${basePath}/{id}`] = { - get: { - summary: `Get ${name}`, - tags: [name], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { - 200: { - description: 'Item', - content: { - 'application/json': { - schema: { $ref: `#/components/schemas/${name}` } - } - } - } - } - }, - patch: { - summary: `Update ${name}`, - tags: [name], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - data: { type: 'object' } - } - } - } - } - }, - responses: { - 200: { description: 'Updated' } - } - }, - delete: { - summary: `Delete ${name}`, - tags: [name], - parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], - responses: { - 200: { description: 'Deleted' } - } - } - }; - } - - return { - openapi: '3.0.0', - info: { - title: 'ObjectQL API', - version: '1.0.0' - }, - paths, - components: { - schemas - } - }; -} - -function mapFieldTypeToOpenAPI(field: FieldConfig | string): any { - const type = typeof field === 'string' ? field : field.type; - - switch (type) { - case 'string': return { type: 'string' }; - case 'integer': return { type: 'integer' }; - case 'float': return { type: 'number' }; - case 'boolean': return { type: 'boolean' }; - case 'date': return { type: 'string', format: 'date-time' }; - case 'json': return { type: 'object' }; - default: return { type: 'string' }; // Fallback or relationship ID - } -} diff --git a/packages/runtime/server/src/server.ts b/packages/runtime/server/src/server.ts deleted file mode 100644 index 732672df..00000000 --- a/packages/runtime/server/src/server.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { IObjectQL, ObjectQLContext } from '@objectql/types'; -import { ObjectQLRequest, ObjectQLResponse, ErrorCode } from './types'; - -export class ObjectQLServer { - constructor(private app: IObjectQL) {} - - /** - * The core handler that processes a JSON request object and returns a result. - * This is framework-agnostic. - */ - async handle(req: ObjectQLRequest): Promise { - try { - // Log AI context if provided - if (req.ai_context) { - console.log('[ObjectQL AI Context]', { - object: req.object, - op: req.op, - intent: req.ai_context.intent, - natural_language: req.ai_context.natural_language, - use_case: req.ai_context.use_case - }); - } - - // 1. Build Context - // TODO: integrate with real session/auth - const contextOptions = { - userId: req.user?.id, - roles: req.user?.roles || [], - // TODO: spaceId - }; - - // Note: Currently IObjectQL.createContext implies we have access to it. - // But IObjectQL interface in @objectql/types usually doesn't expose createContext (it's on the class). - // We need to cast or fix the interface. Assuming 'app' behaves like ObjectQL class. - const app = this.app as any; - if (typeof app.createContext !== 'function') { - return this.errorResponse( - ErrorCode.INTERNAL_ERROR, - "The provided ObjectQL instance does not support createContext." - ); - } - - const ctx: ObjectQLContext = app.createContext(contextOptions); - - // Validate object exists - const objectConfig = app.getObject(req.object); - if (!objectConfig) { - return this.errorResponse( - ErrorCode.NOT_FOUND, - `Object '${req.object}' not found` - ); - } - - const repo = ctx.object(req.object); - - let result: any; - - switch (req.op) { - case 'find': - result = await repo.find(req.args); - // For find operations, return items array with pagination metadata - return this.buildListResponse(result, req.args, repo); - case 'findOne': - // Support both string ID and query object - result = await repo.findOne(req.args); - if (result) { - return { ...result, '@type': req.object }; - } - return result; - case 'create': - result = await repo.create(req.args); - if (result) { - return { ...result, '@type': req.object }; - } - return result; - case 'update': - result = await repo.update(req.args.id, req.args.data); - if (result) { - return { ...result, '@type': req.object }; - } - return result; - case 'delete': - result = await repo.delete(req.args.id); - if (!result) { - return this.errorResponse( - ErrorCode.NOT_FOUND, - `Record with id '${req.args.id}' not found for delete` - ); - } - // Return standardized delete response with object type - return { - id: req.args.id, - deleted: true, - '@type': req.object - }; - case 'count': - result = await repo.count(req.args); - return { count: result, '@type': req.object }; - case 'action': - // Map generic args to ActionContext - result = await app.executeAction(req.object, req.args.action, { - ...ctx, // Pass context (user, etc.) - id: req.args.id, - input: req.args.input || req.args.params // Support both for convenience - }); - if (result && typeof result === 'object') { - return { ...result, '@type': req.object }; - } - return result; - case 'createMany': - // Bulk create operation - if (!Array.isArray(req.args)) { - return this.errorResponse( - ErrorCode.INVALID_REQUEST, - 'createMany expects args to be an array of records' - ); - } - result = await repo.createMany(req.args); - return { - items: result, - count: Array.isArray(result) ? result.length : 0, - '@type': req.object - }; - case 'updateMany': - // Bulk update operation - // args should be { filters, data } - if (!req.args || typeof req.args !== 'object' || !req.args.data) { - return this.errorResponse( - ErrorCode.INVALID_REQUEST, - 'updateMany expects args to be an object with { filters, data }' - ); - } - result = await repo.updateMany(req.args.filters || {}, req.args.data); - return { - count: result, - '@type': req.object - }; - case 'deleteMany': - // Bulk delete operation - // args should be { filters } - if (!req.args || typeof req.args !== 'object') { - return this.errorResponse( - ErrorCode.INVALID_REQUEST, - 'deleteMany expects args to be an object with { filters }' - ); - } - result = await repo.deleteMany(req.args.filters || {}); - return { - count: result, - '@type': req.object - }; - default: - return this.errorResponse( - ErrorCode.INVALID_REQUEST, - `Unknown operation: ${req.op}` - ); - } - - } catch (e: any) { - return this.handleError(e); - } - } - - /** - * Build a standardized list response with pagination metadata - */ - private async buildListResponse(items: any[], args: any, repo: any): Promise { - const response: ObjectQLResponse = { - items - }; - - // Calculate pagination metadata if limit/skip are present - if (args && (args.limit || args.skip)) { - const skip = args.skip || 0; - const limit = args.limit || items.length; - - // Get total count - use the same arguments as the query to ensure consistency - const total = await repo.count(args || {}); - - const size = limit; - const page = limit > 0 ? Math.floor(skip / limit) + 1 : 1; - const pages = limit > 0 ? Math.ceil(total / limit) : 1; - const has_next = skip + items.length < total; - - response.meta = { - total, - page, - size, - pages, - has_next - }; - } - - return response; - } - - /** - * Handle errors and convert them to appropriate error responses - */ - private handleError(error: any): ObjectQLResponse { - console.error('[ObjectQL Server] Error:', error); - - // Handle validation errors - if (error.name === 'ValidationError' || error.code === 'VALIDATION_ERROR') { - return this.errorResponse( - ErrorCode.VALIDATION_ERROR, - 'Validation failed', - { fields: error.fields || error.details } - ); - } - - // Handle permission errors - if (error.name === 'PermissionError' || error.code === 'FORBIDDEN') { - return this.errorResponse( - ErrorCode.FORBIDDEN, - error.message || 'You do not have permission to access this resource', - error.details - ); - } - - // Handle not found errors - if (error.name === 'NotFoundError' || error.code === 'NOT_FOUND') { - return this.errorResponse( - ErrorCode.NOT_FOUND, - error.message || 'Resource not found' - ); - } - - // Handle conflict errors (e.g., unique constraint violations) - if (error.name === 'ConflictError' || error.code === 'CONFLICT') { - return this.errorResponse( - ErrorCode.CONFLICT, - error.message || 'Resource conflict', - error.details - ); - } - - // Handle database errors - if (error.name === 'DatabaseError' || error.code?.startsWith('DB_')) { - return this.errorResponse( - ErrorCode.DATABASE_ERROR, - 'Database operation failed', - { originalError: error.message } - ); - } - - // Default to internal error - return this.errorResponse( - ErrorCode.INTERNAL_ERROR, - error.message || 'An error occurred' - ); - } - - /** - * Create a standardized error response - */ - private errorResponse(code: ErrorCode, message: string, details?: any): ObjectQLResponse { - return { - error: { - code, - message, - details - } - }; - } -} diff --git a/packages/runtime/server/src/storage.ts b/packages/runtime/server/src/storage.ts deleted file mode 100644 index 5f5d2bef..00000000 --- a/packages/runtime/server/src/storage.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { IFileStorage, AttachmentData, FileStorageOptions } from './types'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as crypto from 'crypto'; - -/** - * Local filesystem storage implementation for file attachments - */ -export class LocalFileStorage implements IFileStorage { - private baseDir: string; - private baseUrl: string; - - constructor(options: { baseDir: string; baseUrl: string }) { - this.baseDir = options.baseDir; - this.baseUrl = options.baseUrl; - - // Ensure base directory exists - if (!fs.existsSync(this.baseDir)) { - fs.mkdirSync(this.baseDir, { recursive: true }); - } - } - - async save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise { - // Generate unique ID for the file - const id = crypto.randomBytes(16).toString('hex'); - const ext = path.extname(filename); - const basename = path.basename(filename, ext); - const storedFilename = `${id}${ext}`; - - // Determine storage path - let folder = options?.folder || 'uploads'; - if (options?.object) { - folder = path.join(folder, options.object); - } - - const folderPath = path.join(this.baseDir, folder); - if (!fs.existsSync(folderPath)) { - fs.mkdirSync(folderPath, { recursive: true }); - } - - const filePath = path.join(folderPath, storedFilename); - - // Write file to disk (async for better performance) - await fs.promises.writeFile(filePath, file); - - // Generate public URL - const url = this.getPublicUrl(path.join(folder, storedFilename)); - - const attachmentData: AttachmentData = { - id, - name: storedFilename, - url, - size: file.length, - type: mimeType, - original_name: filename, - uploaded_at: new Date().toISOString(), - uploaded_by: options?.userId - }; - - return attachmentData; - } - - async get(fileId: string): Promise { - try { - // Search for file in the upload directory - const found = this.findFile(this.baseDir, fileId); - if (!found) { - return null; - } - // Use async read for better performance - return await fs.promises.readFile(found); - } catch (error) { - console.error('Error reading file:', error); - return null; - } - } - - async delete(fileId: string): Promise { - try { - const found = this.findFile(this.baseDir, fileId); - if (!found) { - return false; - } - // Use async unlink for better performance - await fs.promises.unlink(found); - return true; - } catch (error) { - console.error('Error deleting file:', error); - return false; - } - } - - getPublicUrl(filePath: string): string { - // Normalize path separators for URLs - const normalizedPath = filePath.replace(/\\/g, '/'); - return `${this.baseUrl}/${normalizedPath}`; - } - - /** - * Recursively search for a file by ID - */ - private findFile(dir: string, fileId: string): string | null { - const files = fs.readdirSync(dir); - - for (const file of files) { - const filePath = path.join(dir, file); - const stat = fs.statSync(filePath); - - if (stat.isDirectory()) { - const found = this.findFile(filePath, fileId); - if (found) { - return found; - } - } else if (file.startsWith(fileId)) { - return filePath; - } - } - - return null; - } -} - -/** - * Memory storage implementation for testing - */ -export class MemoryFileStorage implements IFileStorage { - private files = new Map(); - private baseUrl: string; - - constructor(options: { baseUrl: string }) { - this.baseUrl = options.baseUrl; - } - - async save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise { - const id = crypto.randomBytes(16).toString('hex'); - const ext = path.extname(filename); - const storedFilename = `${id}${ext}`; - - const attachmentData: AttachmentData = { - id, - name: storedFilename, - url: this.getPublicUrl(storedFilename), - size: file.length, - type: mimeType, - original_name: filename, - uploaded_at: new Date().toISOString(), - uploaded_by: options?.userId - }; - - this.files.set(id, { buffer: file, metadata: attachmentData }); - - return attachmentData; - } - - async get(fileId: string): Promise { - const entry = this.files.get(fileId); - return entry ? entry.buffer : null; - } - - async delete(fileId: string): Promise { - return this.files.delete(fileId); - } - - getPublicUrl(filePath: string): string { - return `${this.baseUrl}/${filePath}`; - } - - clear(): void { - this.files.clear(); - } -} diff --git a/packages/runtime/server/src/templates.ts b/packages/runtime/server/src/templates.ts deleted file mode 100644 index 647d8b99..00000000 --- a/packages/runtime/server/src/templates.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -export function getWelcomePageHtml(routes: { rpc: string; data: string; }) { - return ` - - - ObjectQL Server - - - -
- Running -
-

ObjectQL Server

-

The server is operational and ready to accept requests.

- -
-

API Endpoints

-
    -
  • JSON-RPC: POST ${routes.rpc}
  • -
  • REST API: GET ${routes.data}/:object
  • -
  • OpenAPI Spec: /openapi.json
  • -
-
- -
-

Useful Links

- -
- -

- Powered by ObjectQL -

- -`; -} diff --git a/packages/runtime/server/src/types.ts b/packages/runtime/server/src/types.ts deleted file mode 100644 index 49be18b3..00000000 --- a/packages/runtime/server/src/types.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -// src/types.ts - -/** - * Standardized error codes for ObjectQL API - */ -export enum ErrorCode { - INVALID_REQUEST = 'INVALID_REQUEST', - VALIDATION_ERROR = 'VALIDATION_ERROR', - UNAUTHORIZED = 'UNAUTHORIZED', - FORBIDDEN = 'FORBIDDEN', - NOT_FOUND = 'NOT_FOUND', - CONFLICT = 'CONFLICT', - INTERNAL_ERROR = 'INTERNAL_ERROR', - DATABASE_ERROR = 'DATABASE_ERROR', - RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED' -} - -/** - * AI context for better logging, debugging, and AI processing - */ -export interface AIContext { - intent?: string; - natural_language?: string; - use_case?: string; - [key: string]: unknown; -} - -/** - * ObjectQL JSON-RPC style request - */ -export interface ObjectQLRequest { - // Identity provided by the framework adapter (e.g. from session) - user?: { - id: string; - roles: string[]; - [key: string]: any; - }; - - // The actual operation - op: 'find' | 'findOne' | 'create' | 'update' | 'delete' | 'count' | 'action' | 'createMany' | 'updateMany' | 'deleteMany'; - object: string; - - // Arguments - args: any; - - // Optional AI context for explainability - ai_context?: AIContext; -} - -/** - * Error details structure - */ -export interface ErrorDetails { - field?: string; - reason?: string; - fields?: Record; - required_permission?: string; - user_roles?: string[]; - retry_after?: number; - [key: string]: unknown; -} - -/** - * Pagination metadata - */ -export interface PaginationMeta { - total: number; // Total number of records - page?: number; // Current page number (1-indexed, e.g. page 1 corresponds to skip=0) - size?: number; // Number of items per page - pages?: number; // Total number of pages - has_next?: boolean; // Whether there is a next page -} - -/** - * ObjectQL API response - */ -export interface ObjectQLResponse { - // For list operations (find) - items?: any[]; - - // Pagination metadata (for list operations) - meta?: PaginationMeta; - - // Error information - error?: { - code: ErrorCode | string; - message: string; - details?: ErrorDetails | any; // Allow flexible details structure - }; - - // For single item operations, the response is the object itself with '@type' field - // This allows any additional fields from the actual data object - [key: string]: any; -} - -/** - * Attachment/File metadata structure - */ -export interface AttachmentData { - /** Unique identifier for this file */ - id: string; - /** File name (e.g., "invoice.pdf") */ - name: string; - /** Publicly accessible URL to the file */ - url: string; - /** File size in bytes */ - size: number; - /** MIME type (e.g., "application/pdf", "image/jpeg") */ - type: string; - /** Original filename as uploaded by user */ - original_name?: string; - /** Upload timestamp (ISO 8601) */ - uploaded_at?: string; - /** User ID who uploaded the file */ - uploaded_by?: string; -} - -/** - * Image-specific attachment data with metadata - */ -export interface ImageAttachmentData extends AttachmentData { - /** Image width in pixels */ - width?: number; - /** Image height in pixels */ - height?: number; - /** Thumbnail URL (if generated) */ - thumbnail_url?: string; - /** Alternative sizes/versions */ - variants?: { - small?: string; - medium?: string; - large?: string; - }; -} - -/** - * File storage provider interface - */ -export interface IFileStorage { - /** - * Save a file and return its metadata - */ - save(file: Buffer, filename: string, mimeType: string, options?: FileStorageOptions): Promise; - - /** - * Retrieve a file by its ID or path - */ - get(fileId: string): Promise; - - /** - * Delete a file - */ - delete(fileId: string): Promise; - - /** - * Generate a public URL for a file - */ - getPublicUrl(fileId: string): string; -} - -/** - * Options for file storage operations - */ -export interface FileStorageOptions { - /** Logical folder/path for organization */ - folder?: string; - /** Object name (for context/validation) */ - object?: string; - /** Field name (for validation against field config) */ - field?: string; - /** User ID who uploaded the file */ - userId?: string; -} diff --git a/packages/runtime/server/src/utils.ts b/packages/runtime/server/src/utils.ts deleted file mode 100644 index 88163a35..00000000 --- a/packages/runtime/server/src/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * ObjectQL - * Copyright (c) 2026-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * Utility functions for server operations - */ - -/** - * Escapes special regex characters in a path string for use in RegExp - * @param path - The path string to escape - * @returns Escaped path string safe for use in RegExp - */ -export function escapeRegexPath(path: string): string { - return path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Normalizes a path to ensure it starts with a forward slash - * @param path - The path string to normalize - * @returns Normalized path string starting with '/' - */ -export function normalizePath(path: string): string { - return path.startsWith('/') ? path : `/${path}`; -} diff --git a/packages/runtime/server/test/re-export.test.ts b/packages/runtime/server/test/re-export.test.ts new file mode 100644 index 00000000..e3d5cb7f --- /dev/null +++ b/packages/runtime/server/test/re-export.test.ts @@ -0,0 +1,55 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Compatibility test to ensure @objectql/server properly re-exports from @objectql/plugin-server + */ + +describe('@objectql/server compatibility layer', () => { + it('should re-export createNodeHandler', () => { + const { createNodeHandler } = require('../src/index'); + expect(createNodeHandler).toBeDefined(); + expect(typeof createNodeHandler).toBe('function'); + }); + + it('should re-export createRESTHandler', () => { + const { createRESTHandler } = require('../src/index'); + expect(createRESTHandler).toBeDefined(); + expect(typeof createRESTHandler).toBe('function'); + }); + + it('should re-export createGraphQLHandler', () => { + const { createGraphQLHandler } = require('../src/index'); + expect(createGraphQLHandler).toBeDefined(); + expect(typeof createGraphQLHandler).toBe('function'); + }); + + it('should re-export createMetadataHandler', () => { + const { createMetadataHandler } = require('../src/index'); + expect(createMetadataHandler).toBeDefined(); + expect(typeof createMetadataHandler).toBe('function'); + }); + + it('should re-export createHonoAdapter', () => { + const { createHonoAdapter } = require('../src/index'); + expect(createHonoAdapter).toBeDefined(); + expect(typeof createHonoAdapter).toBe('function'); + }); + + it('should re-export ServerPlugin', () => { + const { ServerPlugin } = require('../src/index'); + expect(ServerPlugin).toBeDefined(); + expect(typeof ServerPlugin).toBe('function'); + }); + + it('should re-export ObjectQLServer', () => { + const { ObjectQLServer } = require('../src/index'); + expect(ObjectQLServer).toBeDefined(); + expect(typeof ObjectQLServer).toBe('function'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f4e565a..c218d8a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,9 +208,9 @@ importers: '@objectql/platform-node': specifier: workspace:* version: link:../../../packages/foundation/platform-node - '@objectql/server': + '@objectql/plugin-server': specifier: workspace:* - version: link:../../../packages/runtime/server + version: link:../../../packages/plugins/server '@objectql/types': specifier: workspace:* version: link:../../../packages/foundation/types @@ -583,25 +583,10 @@ importers: packages/runtime/server: dependencies: - '@graphql-tools/schema': - specifier: ^10.0.2 - version: 10.0.31(graphql@16.12.0) - '@objectql/core': - specifier: workspace:* - version: link:../../foundation/core - '@objectql/types': + '@objectql/plugin-server': specifier: workspace:* - version: link:../../foundation/types - graphql: - specifier: ^16.8.1 - version: 16.12.0 - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 + version: link:../../plugins/server devDependencies: - '@types/js-yaml': - specifier: ^4.0.9 - version: 4.0.9 '@types/node': specifier: ^20.10.0 version: 20.19.29