s3db.js 11.3.2 → 12.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -8
- package/dist/s3db.cjs.js +36664 -15480
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +57 -0
- package/dist/s3db.es.js +36661 -15531
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +58 -0
- package/mcp/tools/documentation.js +434 -0
- package/mcp/tools/index.js +4 -0
- package/package.json +27 -6
- package/src/behaviors/user-managed.js +13 -6
- package/src/client.class.js +41 -46
- package/src/concerns/base62.js +85 -0
- package/src/concerns/dictionary-encoding.js +294 -0
- package/src/concerns/geo-encoding.js +256 -0
- package/src/concerns/high-performance-inserter.js +34 -30
- package/src/concerns/ip.js +325 -0
- package/src/concerns/metadata-encoding.js +345 -66
- package/src/concerns/money.js +193 -0
- package/src/concerns/partition-queue.js +7 -4
- package/src/concerns/plugin-storage.js +39 -19
- package/src/database.class.js +76 -74
- package/src/errors.js +0 -4
- package/src/plugins/api/auth/api-key-auth.js +88 -0
- package/src/plugins/api/auth/basic-auth.js +154 -0
- package/src/plugins/api/auth/index.js +112 -0
- package/src/plugins/api/auth/jwt-auth.js +169 -0
- package/src/plugins/api/index.js +539 -0
- package/src/plugins/api/middlewares/index.js +15 -0
- package/src/plugins/api/middlewares/validator.js +185 -0
- package/src/plugins/api/routes/auth-routes.js +241 -0
- package/src/plugins/api/routes/resource-routes.js +304 -0
- package/src/plugins/api/server.js +350 -0
- package/src/plugins/api/utils/error-handler.js +147 -0
- package/src/plugins/api/utils/openapi-generator.js +1240 -0
- package/src/plugins/api/utils/response-formatter.js +218 -0
- package/src/plugins/backup/streaming-exporter.js +132 -0
- package/src/plugins/backup.plugin.js +103 -50
- package/src/plugins/cache/s3-cache.class.js +95 -47
- package/src/plugins/cache.plugin.js +107 -9
- package/src/plugins/concerns/plugin-dependencies.js +313 -0
- package/src/plugins/concerns/prometheus-formatter.js +255 -0
- package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
- package/src/plugins/consumers/sqs-consumer.js +4 -0
- package/src/plugins/costs.plugin.js +255 -39
- package/src/plugins/eventual-consistency/helpers.js +15 -1
- package/src/plugins/geo.plugin.js +873 -0
- package/src/plugins/importer/index.js +1020 -0
- package/src/plugins/index.js +11 -0
- package/src/plugins/metrics.plugin.js +163 -4
- package/src/plugins/queue-consumer.plugin.js +6 -27
- package/src/plugins/relation.errors.js +139 -0
- package/src/plugins/relation.plugin.js +1242 -0
- package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
- package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
- package/src/plugins/replicators/index.js +28 -3
- package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
- package/src/plugins/replicators/mysql-replicator.class.js +558 -0
- package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
- package/src/plugins/replicators/postgres-replicator.class.js +182 -7
- package/src/plugins/replicators/s3db-replicator.class.js +1 -12
- package/src/plugins/replicators/schema-sync.helper.js +601 -0
- package/src/plugins/replicators/sqs-replicator.class.js +11 -9
- package/src/plugins/replicators/turso-replicator.class.js +416 -0
- package/src/plugins/replicators/webhook-replicator.class.js +612 -0
- package/src/plugins/state-machine.plugin.js +122 -68
- package/src/plugins/tfstate/README.md +745 -0
- package/src/plugins/tfstate/base-driver.js +80 -0
- package/src/plugins/tfstate/errors.js +112 -0
- package/src/plugins/tfstate/filesystem-driver.js +129 -0
- package/src/plugins/tfstate/index.js +2660 -0
- package/src/plugins/tfstate/s3-driver.js +192 -0
- package/src/plugins/ttl.plugin.js +536 -0
- package/src/resource.class.js +14 -10
- package/src/s3db.d.ts +57 -0
- package/src/schema.class.js +366 -32
- package/SECURITY.md +0 -76
- package/src/partition-drivers/base-partition-driver.js +0 -106
- package/src/partition-drivers/index.js +0 -66
- package/src/partition-drivers/memory-partition-driver.js +0 -289
- package/src/partition-drivers/sqs-partition-driver.js +0 -337
- package/src/partition-drivers/sync-partition-driver.js +0 -38
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource Routes - Dynamic RESTful routes for s3db.js resources
|
|
3
|
+
*
|
|
4
|
+
* Automatically generates REST endpoints for each resource
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { asyncHandler } from '../utils/error-handler.js';
|
|
9
|
+
import * as formatter from '../utils/response-formatter.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create routes for a resource
|
|
13
|
+
* @param {Object} resource - s3db.js Resource instance
|
|
14
|
+
* @param {string} version - Resource version (e.g., 'v1', 'v1')
|
|
15
|
+
* @param {Object} config - Route configuration
|
|
16
|
+
* @returns {Hono} Hono app with resource routes
|
|
17
|
+
*/
|
|
18
|
+
export function createResourceRoutes(resource, version, config = {}) {
|
|
19
|
+
const app = new Hono();
|
|
20
|
+
const {
|
|
21
|
+
methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
|
|
22
|
+
customMiddleware = [],
|
|
23
|
+
enableValidation = true
|
|
24
|
+
} = config;
|
|
25
|
+
|
|
26
|
+
const resourceName = resource.name;
|
|
27
|
+
const basePath = `/${version}/${resourceName}`;
|
|
28
|
+
|
|
29
|
+
// Apply custom middleware
|
|
30
|
+
customMiddleware.forEach(middleware => {
|
|
31
|
+
app.use('*', middleware);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// LIST - GET /{version}/{resource}
|
|
35
|
+
if (methods.includes('GET')) {
|
|
36
|
+
app.get('/', asyncHandler(async (c) => {
|
|
37
|
+
const query = c.req.query();
|
|
38
|
+
const limit = parseInt(query.limit) || 100;
|
|
39
|
+
const offset = parseInt(query.offset) || 0;
|
|
40
|
+
const partition = query.partition;
|
|
41
|
+
const partitionValues = query.partitionValues
|
|
42
|
+
? JSON.parse(query.partitionValues)
|
|
43
|
+
: undefined;
|
|
44
|
+
|
|
45
|
+
// Extract filters from query string (any key that's not limit, offset, partition, partitionValues, sort)
|
|
46
|
+
const reservedKeys = ['limit', 'offset', 'partition', 'partitionValues', 'sort'];
|
|
47
|
+
const filters = {};
|
|
48
|
+
for (const [key, value] of Object.entries(query)) {
|
|
49
|
+
if (!reservedKeys.includes(key)) {
|
|
50
|
+
// Try to parse as JSON for complex values
|
|
51
|
+
try {
|
|
52
|
+
filters[key] = JSON.parse(value);
|
|
53
|
+
} catch {
|
|
54
|
+
// Keep as string if not valid JSON
|
|
55
|
+
filters[key] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let items;
|
|
61
|
+
let total;
|
|
62
|
+
|
|
63
|
+
// Use query if filters are present
|
|
64
|
+
if (Object.keys(filters).length > 0) {
|
|
65
|
+
items = await resource.query(filters, { limit: limit + offset });
|
|
66
|
+
items = items.slice(offset, offset + limit);
|
|
67
|
+
total = items.length;
|
|
68
|
+
} else if (partition && partitionValues) {
|
|
69
|
+
// Query specific partition
|
|
70
|
+
items = await resource.listPartition({
|
|
71
|
+
partition,
|
|
72
|
+
partitionValues,
|
|
73
|
+
limit: limit + offset
|
|
74
|
+
});
|
|
75
|
+
items = items.slice(offset, offset + limit);
|
|
76
|
+
total = items.length;
|
|
77
|
+
} else {
|
|
78
|
+
// Regular list
|
|
79
|
+
items = await resource.list({ limit: limit + offset });
|
|
80
|
+
items = items.slice(offset, offset + limit);
|
|
81
|
+
total = items.length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const response = formatter.list(items, {
|
|
85
|
+
total,
|
|
86
|
+
page: Math.floor(offset / limit) + 1,
|
|
87
|
+
pageSize: limit,
|
|
88
|
+
pageCount: Math.ceil(total / limit)
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Set pagination headers
|
|
92
|
+
c.header('X-Total-Count', total.toString());
|
|
93
|
+
c.header('X-Page-Count', Math.ceil(total / limit).toString());
|
|
94
|
+
|
|
95
|
+
return c.json(response, response._status);
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// GET ONE - GET /{version}/{resource}/:id
|
|
100
|
+
if (methods.includes('GET')) {
|
|
101
|
+
app.get('/:id', asyncHandler(async (c) => {
|
|
102
|
+
const id = c.req.param('id');
|
|
103
|
+
const query = c.req.query();
|
|
104
|
+
const partition = query.partition;
|
|
105
|
+
const partitionValues = query.partitionValues
|
|
106
|
+
? JSON.parse(query.partitionValues)
|
|
107
|
+
: undefined;
|
|
108
|
+
|
|
109
|
+
let item;
|
|
110
|
+
|
|
111
|
+
if (partition && partitionValues) {
|
|
112
|
+
// Get from specific partition
|
|
113
|
+
item = await resource.getFromPartition({
|
|
114
|
+
id,
|
|
115
|
+
partitionName: partition,
|
|
116
|
+
partitionValues
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
// Regular get
|
|
120
|
+
item = await resource.get(id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!item) {
|
|
124
|
+
const response = formatter.notFound(resourceName, id);
|
|
125
|
+
return c.json(response, response._status);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const response = formatter.success(item);
|
|
129
|
+
return c.json(response, response._status);
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// CREATE - POST /{version}/{resource}
|
|
134
|
+
if (methods.includes('POST')) {
|
|
135
|
+
app.post('/', asyncHandler(async (c) => {
|
|
136
|
+
const data = await c.req.json();
|
|
137
|
+
|
|
138
|
+
// Validation middleware will run if enabled
|
|
139
|
+
const item = await resource.insert(data);
|
|
140
|
+
|
|
141
|
+
const location = `${basePath}/${item.id}`;
|
|
142
|
+
const response = formatter.created(item, location);
|
|
143
|
+
|
|
144
|
+
c.header('Location', location);
|
|
145
|
+
return c.json(response, response._status);
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// UPDATE (full) - PUT /{version}/{resource}/:id
|
|
150
|
+
if (methods.includes('PUT')) {
|
|
151
|
+
app.put('/:id', asyncHandler(async (c) => {
|
|
152
|
+
const id = c.req.param('id');
|
|
153
|
+
const data = await c.req.json();
|
|
154
|
+
|
|
155
|
+
// Check if exists
|
|
156
|
+
const existing = await resource.get(id);
|
|
157
|
+
if (!existing) {
|
|
158
|
+
const response = formatter.notFound(resourceName, id);
|
|
159
|
+
return c.json(response, response._status);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Full update
|
|
163
|
+
const updated = await resource.update(id, data);
|
|
164
|
+
|
|
165
|
+
const response = formatter.success(updated);
|
|
166
|
+
return c.json(response, response._status);
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// UPDATE (partial) - PATCH /{version}/{resource}/:id
|
|
171
|
+
if (methods.includes('PATCH')) {
|
|
172
|
+
app.patch('/:id', asyncHandler(async (c) => {
|
|
173
|
+
const id = c.req.param('id');
|
|
174
|
+
const data = await c.req.json();
|
|
175
|
+
|
|
176
|
+
// Check if exists
|
|
177
|
+
const existing = await resource.get(id);
|
|
178
|
+
if (!existing) {
|
|
179
|
+
const response = formatter.notFound(resourceName, id);
|
|
180
|
+
return c.json(response, response._status);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Partial update (merge with existing)
|
|
184
|
+
const merged = { ...existing, ...data, id };
|
|
185
|
+
const updated = await resource.update(id, merged);
|
|
186
|
+
|
|
187
|
+
const response = formatter.success(updated);
|
|
188
|
+
return c.json(response, response._status);
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// DELETE - DELETE /{version}/{resource}/:id
|
|
193
|
+
if (methods.includes('DELETE')) {
|
|
194
|
+
app.delete('/:id', asyncHandler(async (c) => {
|
|
195
|
+
const id = c.req.param('id');
|
|
196
|
+
|
|
197
|
+
// Check if exists
|
|
198
|
+
const existing = await resource.get(id);
|
|
199
|
+
if (!existing) {
|
|
200
|
+
const response = formatter.notFound(resourceName, id);
|
|
201
|
+
return c.json(response, response._status);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await resource.delete(id);
|
|
205
|
+
|
|
206
|
+
const response = formatter.noContent();
|
|
207
|
+
return c.json(response, response._status);
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// HEAD - HEAD /{version}/{resource}
|
|
212
|
+
if (methods.includes('HEAD')) {
|
|
213
|
+
app.on('HEAD', '/', asyncHandler(async (c) => {
|
|
214
|
+
// Get statistics
|
|
215
|
+
const total = await resource.count();
|
|
216
|
+
|
|
217
|
+
// Get all items to calculate stats (for small datasets)
|
|
218
|
+
// For large datasets, this might need optimization
|
|
219
|
+
const allItems = await resource.list({ limit: 1000 });
|
|
220
|
+
|
|
221
|
+
// Calculate statistics
|
|
222
|
+
const stats = {
|
|
223
|
+
total,
|
|
224
|
+
version: resource.config?.currentVersion || resource.version || 'v1'
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Add resource-specific stats
|
|
228
|
+
c.header('X-Total-Count', total.toString());
|
|
229
|
+
c.header('X-Resource-Version', stats.version);
|
|
230
|
+
|
|
231
|
+
// Add schema info
|
|
232
|
+
c.header('X-Schema-Fields', Object.keys(resource.config?.attributes || {}).length.toString());
|
|
233
|
+
|
|
234
|
+
return c.body(null, 200);
|
|
235
|
+
}));
|
|
236
|
+
|
|
237
|
+
app.on('HEAD', '/:id', asyncHandler(async (c) => {
|
|
238
|
+
const id = c.req.param('id');
|
|
239
|
+
const item = await resource.get(id);
|
|
240
|
+
|
|
241
|
+
if (!item) {
|
|
242
|
+
return c.body(null, 404);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Add metadata headers
|
|
246
|
+
if (item.updatedAt) {
|
|
247
|
+
c.header('Last-Modified', new Date(item.updatedAt).toUTCString());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return c.body(null, 200);
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// OPTIONS - OPTIONS /{version}/{resource}
|
|
255
|
+
if (methods.includes('OPTIONS')) {
|
|
256
|
+
app.options('/', asyncHandler(async (c) => {
|
|
257
|
+
c.header('Allow', methods.join(', '));
|
|
258
|
+
|
|
259
|
+
// Return metadata about the resource
|
|
260
|
+
const total = await resource.count();
|
|
261
|
+
const schema = resource.config?.attributes || {};
|
|
262
|
+
const version = resource.config?.currentVersion || resource.version || 'v1';
|
|
263
|
+
|
|
264
|
+
const metadata = {
|
|
265
|
+
resource: resourceName,
|
|
266
|
+
version,
|
|
267
|
+
totalRecords: total,
|
|
268
|
+
allowedMethods: methods,
|
|
269
|
+
schema: Object.entries(schema).map(([name, def]) => ({
|
|
270
|
+
name,
|
|
271
|
+
type: typeof def === 'string' ? def.split('|')[0] : def.type,
|
|
272
|
+
rules: typeof def === 'string' ? def.split('|').slice(1) : []
|
|
273
|
+
})),
|
|
274
|
+
endpoints: {
|
|
275
|
+
list: `/${version}/${resourceName}`,
|
|
276
|
+
get: `/${version}/${resourceName}/:id`,
|
|
277
|
+
create: `/${version}/${resourceName}`,
|
|
278
|
+
update: `/${version}/${resourceName}/:id`,
|
|
279
|
+
delete: `/${version}/${resourceName}/:id`
|
|
280
|
+
},
|
|
281
|
+
queryParameters: {
|
|
282
|
+
limit: 'number (1-1000, default: 100)',
|
|
283
|
+
offset: 'number (min: 0, default: 0)',
|
|
284
|
+
partition: 'string (partition name)',
|
|
285
|
+
partitionValues: 'JSON string',
|
|
286
|
+
'[any field]': 'any (filter by field value)'
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
return c.json(metadata);
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
app.options('/:id', (c) => {
|
|
294
|
+
c.header('Allow', methods.filter(m => m !== 'POST').join(', '));
|
|
295
|
+
return c.body(null, 204);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return app;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export default {
|
|
303
|
+
createResourceRoutes
|
|
304
|
+
};
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Server - Hono-based HTTP server for s3db.js API Plugin
|
|
3
|
+
*
|
|
4
|
+
* Manages HTTP server lifecycle and routing
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { serve } from '@hono/node-server';
|
|
9
|
+
import { swaggerUI } from '@hono/swagger-ui';
|
|
10
|
+
import { createResourceRoutes } from './routes/resource-routes.js';
|
|
11
|
+
import { errorHandler } from './utils/error-handler.js';
|
|
12
|
+
import * as formatter from './utils/response-formatter.js';
|
|
13
|
+
import { generateOpenAPISpec } from './utils/openapi-generator.js';
|
|
14
|
+
|
|
15
|
+
export class ApiServer {
|
|
16
|
+
/**
|
|
17
|
+
* Create API server
|
|
18
|
+
* @param {Object} options - Server options
|
|
19
|
+
* @param {number} options.port - Server port
|
|
20
|
+
* @param {string} options.host - Server host
|
|
21
|
+
* @param {Object} options.database - s3db.js database instance
|
|
22
|
+
* @param {Object} options.resources - Resource configuration
|
|
23
|
+
* @param {Array} options.middlewares - Global middlewares
|
|
24
|
+
*/
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.options = {
|
|
27
|
+
port: options.port || 3000,
|
|
28
|
+
host: options.host || '0.0.0.0',
|
|
29
|
+
database: options.database,
|
|
30
|
+
resources: options.resources || {},
|
|
31
|
+
middlewares: options.middlewares || [],
|
|
32
|
+
verbose: options.verbose || false,
|
|
33
|
+
auth: options.auth || {},
|
|
34
|
+
docsEnabled: options.docsEnabled !== false, // Enable /docs by default
|
|
35
|
+
docsUI: options.docsUI || 'redoc', // 'swagger' or 'redoc'
|
|
36
|
+
maxBodySize: options.maxBodySize || 10 * 1024 * 1024, // 10MB default
|
|
37
|
+
rootHandler: options.rootHandler, // Custom handler for root path, if not provided redirects to /docs
|
|
38
|
+
apiInfo: {
|
|
39
|
+
title: options.apiTitle || 's3db.js API',
|
|
40
|
+
version: options.apiVersion || '1.0.0',
|
|
41
|
+
description: options.apiDescription || 'Auto-generated REST API for s3db.js resources'
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
this.app = new Hono();
|
|
46
|
+
this.server = null;
|
|
47
|
+
this.isRunning = false;
|
|
48
|
+
this.openAPISpec = null;
|
|
49
|
+
|
|
50
|
+
this._setupRoutes();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Setup all routes
|
|
55
|
+
* @private
|
|
56
|
+
*/
|
|
57
|
+
_setupRoutes() {
|
|
58
|
+
// Apply global middlewares
|
|
59
|
+
this.options.middlewares.forEach(middleware => {
|
|
60
|
+
this.app.use('*', middleware);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Body size limit middleware (only for POST, PUT, PATCH)
|
|
64
|
+
this.app.use('*', async (c, next) => {
|
|
65
|
+
const method = c.req.method;
|
|
66
|
+
|
|
67
|
+
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
68
|
+
const contentLength = c.req.header('content-length');
|
|
69
|
+
|
|
70
|
+
if (contentLength) {
|
|
71
|
+
const size = parseInt(contentLength);
|
|
72
|
+
|
|
73
|
+
if (size > this.options.maxBodySize) {
|
|
74
|
+
const response = formatter.payloadTooLarge(size, this.options.maxBodySize);
|
|
75
|
+
c.header('Connection', 'close'); // Close connection for large payloads
|
|
76
|
+
return c.json(response, response._status);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await next();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Kubernetes Liveness Probe - checks if app is alive
|
|
85
|
+
// If this fails, k8s will restart the pod
|
|
86
|
+
this.app.get('/health/live', (c) => {
|
|
87
|
+
// Simple check: if we can respond, we're alive
|
|
88
|
+
const response = formatter.success({
|
|
89
|
+
status: 'alive',
|
|
90
|
+
timestamp: new Date().toISOString()
|
|
91
|
+
});
|
|
92
|
+
return c.json(response);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Kubernetes Readiness Probe - checks if app is ready to receive traffic
|
|
96
|
+
// If this fails, k8s will remove pod from service endpoints
|
|
97
|
+
this.app.get('/health/ready', (c) => {
|
|
98
|
+
// Check if database is connected and resources are loaded
|
|
99
|
+
const isReady = this.options.database &&
|
|
100
|
+
this.options.database.connected &&
|
|
101
|
+
Object.keys(this.options.database.resources).length > 0;
|
|
102
|
+
|
|
103
|
+
if (!isReady) {
|
|
104
|
+
const response = formatter.error('Service not ready', {
|
|
105
|
+
status: 503,
|
|
106
|
+
code: 'NOT_READY',
|
|
107
|
+
details: {
|
|
108
|
+
database: {
|
|
109
|
+
connected: this.options.database?.connected || false,
|
|
110
|
+
resources: Object.keys(this.options.database?.resources || {}).length
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return c.json(response, 503);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const response = formatter.success({
|
|
118
|
+
status: 'ready',
|
|
119
|
+
database: {
|
|
120
|
+
connected: true,
|
|
121
|
+
resources: Object.keys(this.options.database.resources).length
|
|
122
|
+
},
|
|
123
|
+
timestamp: new Date().toISOString()
|
|
124
|
+
});
|
|
125
|
+
return c.json(response);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Generic Health Check endpoint
|
|
129
|
+
this.app.get('/health', (c) => {
|
|
130
|
+
const response = formatter.success({
|
|
131
|
+
status: 'ok',
|
|
132
|
+
uptime: process.uptime(),
|
|
133
|
+
timestamp: new Date().toISOString(),
|
|
134
|
+
checks: {
|
|
135
|
+
liveness: '/health/live',
|
|
136
|
+
readiness: '/health/ready'
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return c.json(response);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Root endpoint - custom handler or redirect to docs
|
|
143
|
+
this.app.get('/', (c) => {
|
|
144
|
+
// If user provided a custom root handler, use it
|
|
145
|
+
if (this.options.rootHandler) {
|
|
146
|
+
return this.options.rootHandler(c);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Otherwise, redirect to docs
|
|
150
|
+
return c.redirect('/docs', 302);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// OpenAPI spec endpoint
|
|
154
|
+
if (this.options.docsEnabled) {
|
|
155
|
+
this.app.get('/openapi.json', (c) => {
|
|
156
|
+
if (!this.openAPISpec) {
|
|
157
|
+
this.openAPISpec = this._generateOpenAPISpec();
|
|
158
|
+
}
|
|
159
|
+
return c.json(this.openAPISpec);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// API Documentation UI endpoint
|
|
163
|
+
if (this.options.docsUI === 'swagger') {
|
|
164
|
+
// Swagger UI (legacy, less pretty)
|
|
165
|
+
this.app.get('/docs', swaggerUI({
|
|
166
|
+
url: '/openapi.json'
|
|
167
|
+
}));
|
|
168
|
+
} else {
|
|
169
|
+
// Redoc (modern, beautiful design!)
|
|
170
|
+
this.app.get('/docs', (c) => {
|
|
171
|
+
return c.html(`<!DOCTYPE html>
|
|
172
|
+
<html lang="en">
|
|
173
|
+
<head>
|
|
174
|
+
<meta charset="UTF-8">
|
|
175
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
176
|
+
<title>${this.options.apiInfo.title} - API Documentation</title>
|
|
177
|
+
<style>
|
|
178
|
+
body {
|
|
179
|
+
margin: 0;
|
|
180
|
+
padding: 0;
|
|
181
|
+
}
|
|
182
|
+
</style>
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<redoc spec-url="/openapi.json"></redoc>
|
|
186
|
+
<script src="https://cdn.redoc.ly/redoc/v2.5.1/bundles/redoc.standalone.js"></script>
|
|
187
|
+
</body>
|
|
188
|
+
</html>`);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Setup resource routes
|
|
194
|
+
this._setupResourceRoutes();
|
|
195
|
+
|
|
196
|
+
// Global error handler
|
|
197
|
+
this.app.onError((err, c) => {
|
|
198
|
+
return errorHandler(err, c);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// 404 handler
|
|
202
|
+
this.app.notFound((c) => {
|
|
203
|
+
const response = formatter.error('Route not found', {
|
|
204
|
+
status: 404,
|
|
205
|
+
code: 'NOT_FOUND',
|
|
206
|
+
details: {
|
|
207
|
+
path: c.req.path,
|
|
208
|
+
method: c.req.method
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
return c.json(response, 404);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Setup routes for all resources
|
|
217
|
+
* @private
|
|
218
|
+
*/
|
|
219
|
+
_setupResourceRoutes() {
|
|
220
|
+
const { database, resources: resourceConfigs } = this.options;
|
|
221
|
+
|
|
222
|
+
// Get all resources from database
|
|
223
|
+
const resources = database.resources;
|
|
224
|
+
|
|
225
|
+
for (const [name, resource] of Object.entries(resources)) {
|
|
226
|
+
// Skip plugin resources unless explicitly included
|
|
227
|
+
if (name.startsWith('plg_') && !resourceConfigs[name]) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Get resource configuration
|
|
232
|
+
const config = resourceConfigs[name] || {
|
|
233
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
|
|
234
|
+
auth: false
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Determine version
|
|
238
|
+
const version = resource.config?.currentVersion || resource.version || 'v1';
|
|
239
|
+
|
|
240
|
+
// Create resource routes
|
|
241
|
+
const resourceApp = createResourceRoutes(resource, version, {
|
|
242
|
+
methods: config.methods,
|
|
243
|
+
customMiddleware: config.customMiddleware || [],
|
|
244
|
+
enableValidation: config.validation !== false
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Mount resource routes
|
|
248
|
+
this.app.route(`/${version}/${name}`, resourceApp);
|
|
249
|
+
|
|
250
|
+
if (this.options.verbose) {
|
|
251
|
+
console.log(`[API Plugin] Mounted routes for resource '${name}' at /${version}/${name}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Start the server
|
|
258
|
+
* @returns {Promise<void>}
|
|
259
|
+
*/
|
|
260
|
+
async start() {
|
|
261
|
+
if (this.isRunning) {
|
|
262
|
+
console.warn('[API Plugin] Server is already running');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const { port, host } = this.options;
|
|
267
|
+
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
try {
|
|
270
|
+
this.server = serve({
|
|
271
|
+
fetch: this.app.fetch,
|
|
272
|
+
port,
|
|
273
|
+
hostname: host
|
|
274
|
+
}, (info) => {
|
|
275
|
+
this.isRunning = true;
|
|
276
|
+
console.log(`[API Plugin] Server listening on http://${info.address}:${info.port}`);
|
|
277
|
+
resolve();
|
|
278
|
+
});
|
|
279
|
+
} catch (err) {
|
|
280
|
+
reject(err);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Stop the server
|
|
287
|
+
* @returns {Promise<void>}
|
|
288
|
+
*/
|
|
289
|
+
async stop() {
|
|
290
|
+
if (!this.isRunning) {
|
|
291
|
+
console.warn('[API Plugin] Server is not running');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (this.server && typeof this.server.close === 'function') {
|
|
296
|
+
await new Promise((resolve) => {
|
|
297
|
+
this.server.close(() => {
|
|
298
|
+
this.isRunning = false;
|
|
299
|
+
console.log('[API Plugin] Server stopped');
|
|
300
|
+
resolve();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
} else {
|
|
304
|
+
// For some Hono adapters, server might not have close method
|
|
305
|
+
this.isRunning = false;
|
|
306
|
+
console.log('[API Plugin] Server stopped');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Get server info
|
|
312
|
+
* @returns {Object} Server information
|
|
313
|
+
*/
|
|
314
|
+
getInfo() {
|
|
315
|
+
return {
|
|
316
|
+
isRunning: this.isRunning,
|
|
317
|
+
port: this.options.port,
|
|
318
|
+
host: this.options.host,
|
|
319
|
+
resources: Object.keys(this.options.database.resources).length
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Get Hono app instance
|
|
325
|
+
* @returns {Hono} Hono app
|
|
326
|
+
*/
|
|
327
|
+
getApp() {
|
|
328
|
+
return this.app;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Generate OpenAPI specification
|
|
333
|
+
* @private
|
|
334
|
+
* @returns {Object} OpenAPI spec
|
|
335
|
+
*/
|
|
336
|
+
_generateOpenAPISpec() {
|
|
337
|
+
const { port, host, database, resources, auth, apiInfo } = this.options;
|
|
338
|
+
|
|
339
|
+
return generateOpenAPISpec(database, {
|
|
340
|
+
title: apiInfo.title,
|
|
341
|
+
version: apiInfo.version,
|
|
342
|
+
description: apiInfo.description,
|
|
343
|
+
serverUrl: `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`,
|
|
344
|
+
auth,
|
|
345
|
+
resources
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export default ApiServer;
|