s3db.js 11.3.2 → 12.0.1
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 +36945 -15510
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +66 -1
- package/dist/s3db.es.js +36914 -15534
- 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 +35 -15
- package/src/behaviors/user-managed.js +13 -6
- package/src/client.class.js +79 -49
- 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 +97 -47
- 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 +544 -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 +354 -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/replicator.plugin.js +2 -1
- 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 +315 -36
- package/src/s3db.d.ts +66 -1
- 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,1240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI Generator - Generate OpenAPI 3.1 specification from s3db.js resources
|
|
3
|
+
*
|
|
4
|
+
* Automatically creates OpenAPI documentation based on resource schemas
|
|
5
|
+
* Note: OpenAPI 3.2.0 is not yet supported by Redoc v2.5.1
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Map s3db.js field types to OpenAPI types
|
|
10
|
+
* @param {string} fieldType - s3db.js field type
|
|
11
|
+
* @returns {Object} OpenAPI type definition
|
|
12
|
+
*/
|
|
13
|
+
function mapFieldTypeToOpenAPI(fieldType) {
|
|
14
|
+
const type = fieldType.split('|')[0].trim();
|
|
15
|
+
|
|
16
|
+
const typeMap = {
|
|
17
|
+
'string': { type: 'string' },
|
|
18
|
+
'number': { type: 'number' },
|
|
19
|
+
'integer': { type: 'integer' },
|
|
20
|
+
'boolean': { type: 'boolean' },
|
|
21
|
+
'array': { type: 'array', items: { type: 'string' } },
|
|
22
|
+
'object': { type: 'object' },
|
|
23
|
+
'json': { type: 'object' },
|
|
24
|
+
'secret': { type: 'string', format: 'password' },
|
|
25
|
+
'email': { type: 'string', format: 'email' },
|
|
26
|
+
'url': { type: 'string', format: 'uri' },
|
|
27
|
+
'date': { type: 'string', format: 'date' },
|
|
28
|
+
'datetime': { type: 'string', format: 'date-time' },
|
|
29
|
+
'ip4': { type: 'string', format: 'ipv4', description: 'IPv4 address' },
|
|
30
|
+
'ip6': { type: 'string', format: 'ipv6', description: 'IPv6 address' },
|
|
31
|
+
'embedding': { type: 'array', items: { type: 'number' }, description: 'Vector embedding' }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Handle embedding:N notation
|
|
35
|
+
if (type.startsWith('embedding:')) {
|
|
36
|
+
const length = parseInt(type.split(':')[1]);
|
|
37
|
+
return {
|
|
38
|
+
type: 'array',
|
|
39
|
+
items: { type: 'number' },
|
|
40
|
+
minItems: length,
|
|
41
|
+
maxItems: length,
|
|
42
|
+
description: `Vector embedding (${length} dimensions)`
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return typeMap[type] || { type: 'string' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract validation rules from field definition
|
|
51
|
+
* @param {string} fieldDef - Field definition string
|
|
52
|
+
* @returns {Object} Validation rules
|
|
53
|
+
*/
|
|
54
|
+
function extractValidationRules(fieldDef) {
|
|
55
|
+
const rules = {};
|
|
56
|
+
const parts = fieldDef.split('|');
|
|
57
|
+
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
const [rule, value] = part.split(':').map(s => s.trim());
|
|
60
|
+
|
|
61
|
+
switch (rule) {
|
|
62
|
+
case 'required':
|
|
63
|
+
rules.required = true;
|
|
64
|
+
break;
|
|
65
|
+
case 'min':
|
|
66
|
+
rules.minimum = parseFloat(value);
|
|
67
|
+
break;
|
|
68
|
+
case 'max':
|
|
69
|
+
rules.maximum = parseFloat(value);
|
|
70
|
+
break;
|
|
71
|
+
case 'minlength':
|
|
72
|
+
rules.minLength = parseInt(value);
|
|
73
|
+
break;
|
|
74
|
+
case 'maxlength':
|
|
75
|
+
rules.maxLength = parseInt(value);
|
|
76
|
+
break;
|
|
77
|
+
case 'pattern':
|
|
78
|
+
rules.pattern = value;
|
|
79
|
+
break;
|
|
80
|
+
case 'enum':
|
|
81
|
+
rules.enum = value.split(',').map(v => v.trim());
|
|
82
|
+
break;
|
|
83
|
+
case 'default':
|
|
84
|
+
rules.default = value;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return rules;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate OpenAPI schema for a resource
|
|
94
|
+
* @param {Object} resource - s3db.js Resource instance
|
|
95
|
+
* @returns {Object} OpenAPI schema definition
|
|
96
|
+
*/
|
|
97
|
+
function generateResourceSchema(resource) {
|
|
98
|
+
const properties = {};
|
|
99
|
+
const required = [];
|
|
100
|
+
|
|
101
|
+
const attributes = resource.config?.attributes || resource.attributes || {};
|
|
102
|
+
|
|
103
|
+
// Add system-generated id field (always present in responses)
|
|
104
|
+
properties.id = {
|
|
105
|
+
type: 'string',
|
|
106
|
+
description: 'Unique identifier for the resource',
|
|
107
|
+
example: '2_gDTpeU6EI0e8B92n_R3Y',
|
|
108
|
+
readOnly: true
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (const [fieldName, fieldDef] of Object.entries(attributes)) {
|
|
112
|
+
// Handle object notation
|
|
113
|
+
if (typeof fieldDef === 'object' && fieldDef.type) {
|
|
114
|
+
const baseType = mapFieldTypeToOpenAPI(fieldDef.type);
|
|
115
|
+
properties[fieldName] = {
|
|
116
|
+
...baseType,
|
|
117
|
+
description: fieldDef.description || undefined
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (fieldDef.required) {
|
|
121
|
+
required.push(fieldName);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Handle nested object properties
|
|
125
|
+
if (fieldDef.type === 'object' && fieldDef.props) {
|
|
126
|
+
properties[fieldName].properties = {};
|
|
127
|
+
for (const [propName, propDef] of Object.entries(fieldDef.props)) {
|
|
128
|
+
const propType = typeof propDef === 'string' ? propDef : propDef.type;
|
|
129
|
+
properties[fieldName].properties[propName] = mapFieldTypeToOpenAPI(propType);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Handle array items
|
|
134
|
+
if (fieldDef.type === 'array' && fieldDef.items) {
|
|
135
|
+
properties[fieldName].items = mapFieldTypeToOpenAPI(fieldDef.items);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Handle string notation
|
|
139
|
+
else if (typeof fieldDef === 'string') {
|
|
140
|
+
const baseType = mapFieldTypeToOpenAPI(fieldDef);
|
|
141
|
+
const rules = extractValidationRules(fieldDef);
|
|
142
|
+
|
|
143
|
+
properties[fieldName] = {
|
|
144
|
+
...baseType,
|
|
145
|
+
...rules
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (rules.required) {
|
|
149
|
+
required.push(fieldName);
|
|
150
|
+
delete properties[fieldName].required; // Move to schema-level required array
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties,
|
|
158
|
+
required: required.length > 0 ? required : undefined
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Generate OpenAPI paths for a resource
|
|
164
|
+
* @param {Object} resource - s3db.js Resource instance
|
|
165
|
+
* @param {string} version - Resource version
|
|
166
|
+
* @param {Object} config - Resource configuration
|
|
167
|
+
* @returns {Object} OpenAPI paths
|
|
168
|
+
*/
|
|
169
|
+
function generateResourcePaths(resource, version, config = {}) {
|
|
170
|
+
const resourceName = resource.name;
|
|
171
|
+
const basePath = `/${version}/${resourceName}`;
|
|
172
|
+
const schema = generateResourceSchema(resource);
|
|
173
|
+
const methods = config.methods || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
174
|
+
const authMethods = config.auth || [];
|
|
175
|
+
const requiresAuth = authMethods && authMethods.length > 0;
|
|
176
|
+
|
|
177
|
+
const paths = {};
|
|
178
|
+
|
|
179
|
+
// Security schemes
|
|
180
|
+
const security = [];
|
|
181
|
+
if (requiresAuth) {
|
|
182
|
+
if (authMethods.includes('jwt')) security.push({ bearerAuth: [] });
|
|
183
|
+
if (authMethods.includes('apiKey')) security.push({ apiKeyAuth: [] });
|
|
184
|
+
if (authMethods.includes('basic')) security.push({ basicAuth: [] });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Extract partition information for documentation
|
|
188
|
+
// Partitions are stored in resource.config.options.partitions
|
|
189
|
+
const partitions = resource.config?.options?.partitions || resource.config?.partitions || resource.partitions || {};
|
|
190
|
+
const partitionNames = Object.keys(partitions);
|
|
191
|
+
const hasPartitions = partitionNames.length > 0;
|
|
192
|
+
|
|
193
|
+
// Build partition documentation
|
|
194
|
+
let partitionDescription = 'Partition name for filtering';
|
|
195
|
+
let partitionValuesDescription = 'Partition values as JSON string';
|
|
196
|
+
let partitionExample = undefined;
|
|
197
|
+
let partitionValuesExample = undefined;
|
|
198
|
+
|
|
199
|
+
if (hasPartitions) {
|
|
200
|
+
// Build detailed partition description
|
|
201
|
+
const partitionDocs = partitionNames.map(name => {
|
|
202
|
+
const partition = partitions[name];
|
|
203
|
+
const fields = Object.keys(partition.fields || {});
|
|
204
|
+
const fieldTypes = Object.entries(partition.fields || {})
|
|
205
|
+
.map(([field, type]) => `${field}: ${type}`)
|
|
206
|
+
.join(', ');
|
|
207
|
+
return `- **${name}**: Filters by ${fields.join(', ')} (${fieldTypes})`;
|
|
208
|
+
}).join('\n');
|
|
209
|
+
|
|
210
|
+
partitionDescription = `Available partitions:\n${partitionDocs}`;
|
|
211
|
+
|
|
212
|
+
// Build partition values description with examples
|
|
213
|
+
const examplePartition = partitionNames[0];
|
|
214
|
+
const exampleFields = partitions[examplePartition]?.fields || {};
|
|
215
|
+
const exampleFieldsDoc = Object.entries(exampleFields)
|
|
216
|
+
.map(([field, type]) => `"${field}": <${type} value>`)
|
|
217
|
+
.join(', ');
|
|
218
|
+
|
|
219
|
+
partitionValuesDescription = `Partition field values as JSON string. Must match the structure of the selected partition.\n\nExample for "${examplePartition}" partition: \`{"${Object.keys(exampleFields)[0]}": "value"}\``;
|
|
220
|
+
|
|
221
|
+
// Set examples
|
|
222
|
+
partitionExample = examplePartition;
|
|
223
|
+
const firstField = Object.keys(exampleFields)[0];
|
|
224
|
+
const firstFieldType = exampleFields[firstField];
|
|
225
|
+
let exampleValue = 'example';
|
|
226
|
+
if (firstFieldType === 'number' || firstFieldType === 'integer') {
|
|
227
|
+
exampleValue = 123;
|
|
228
|
+
} else if (firstFieldType === 'boolean') {
|
|
229
|
+
exampleValue = true;
|
|
230
|
+
}
|
|
231
|
+
partitionValuesExample = JSON.stringify({ [firstField]: exampleValue });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Extract partition fields for query parameters (filtering)
|
|
235
|
+
// Only fields that are part of partitions can be efficiently filtered
|
|
236
|
+
const attributeQueryParams = [];
|
|
237
|
+
|
|
238
|
+
if (hasPartitions) {
|
|
239
|
+
const partitionFieldsSet = new Set();
|
|
240
|
+
|
|
241
|
+
// Collect all unique fields from all partitions
|
|
242
|
+
for (const [partitionName, partition] of Object.entries(partitions)) {
|
|
243
|
+
const fields = partition.fields || {};
|
|
244
|
+
for (const fieldName of Object.keys(fields)) {
|
|
245
|
+
partitionFieldsSet.add(fieldName);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Create query parameters only for partition fields
|
|
250
|
+
const attributes = resource.config?.attributes || resource.attributes || {};
|
|
251
|
+
|
|
252
|
+
for (const fieldName of partitionFieldsSet) {
|
|
253
|
+
const fieldDef = attributes[fieldName];
|
|
254
|
+
if (!fieldDef) continue; // Skip if field doesn't exist in schema
|
|
255
|
+
|
|
256
|
+
// Get field type
|
|
257
|
+
let fieldType;
|
|
258
|
+
if (typeof fieldDef === 'object' && fieldDef.type) {
|
|
259
|
+
fieldType = fieldDef.type;
|
|
260
|
+
} else if (typeof fieldDef === 'string') {
|
|
261
|
+
fieldType = fieldDef.split('|')[0].trim();
|
|
262
|
+
} else {
|
|
263
|
+
fieldType = 'string';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Map to OpenAPI type
|
|
267
|
+
const openAPIType = mapFieldTypeToOpenAPI(fieldType);
|
|
268
|
+
|
|
269
|
+
// Create query parameter with partition info
|
|
270
|
+
attributeQueryParams.push({
|
|
271
|
+
name: fieldName,
|
|
272
|
+
in: 'query',
|
|
273
|
+
description: `Filter by ${fieldName} field (indexed via partitions for efficient querying). Value will be parsed as JSON if possible, otherwise treated as string.`,
|
|
274
|
+
required: false,
|
|
275
|
+
schema: openAPIType
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// List endpoint with filtering support
|
|
281
|
+
if (methods.includes('GET')) {
|
|
282
|
+
paths[basePath] = {
|
|
283
|
+
get: {
|
|
284
|
+
tags: [resourceName],
|
|
285
|
+
summary: `List ${resourceName}`,
|
|
286
|
+
description: `Retrieve a paginated list of ${resourceName}. Supports filtering by passing any resource field as a query parameter (e.g., ?status=active&year=2024). Values are parsed as JSON if possible, otherwise treated as strings.
|
|
287
|
+
|
|
288
|
+
**Pagination**: Use \`limit\` and \`offset\` to paginate results. For example:
|
|
289
|
+
- First page (10 items): \`?limit=10&offset=0\`
|
|
290
|
+
- Second page: \`?limit=10&offset=10\`
|
|
291
|
+
- Third page: \`?limit=10&offset=20\`
|
|
292
|
+
|
|
293
|
+
The response includes pagination metadata in the \`pagination\` object with total count and page information.${hasPartitions ? '\n\n**Partitioning**: This resource supports partitioned queries for optimized filtering. Use the `partition` and `partitionValues` parameters together.' : ''}`,
|
|
294
|
+
parameters: [
|
|
295
|
+
{
|
|
296
|
+
name: 'limit',
|
|
297
|
+
in: 'query',
|
|
298
|
+
description: 'Maximum number of items to return per page (page size)',
|
|
299
|
+
schema: { type: 'integer', default: 100, minimum: 1, maximum: 1000 },
|
|
300
|
+
example: 10
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: 'offset',
|
|
304
|
+
in: 'query',
|
|
305
|
+
description: 'Number of items to skip before starting to return results. Use for pagination: offset = (page - 1) * limit',
|
|
306
|
+
schema: { type: 'integer', default: 0, minimum: 0 },
|
|
307
|
+
example: 0
|
|
308
|
+
},
|
|
309
|
+
...(hasPartitions ? [
|
|
310
|
+
{
|
|
311
|
+
name: 'partition',
|
|
312
|
+
in: 'query',
|
|
313
|
+
description: partitionDescription,
|
|
314
|
+
schema: {
|
|
315
|
+
type: 'string',
|
|
316
|
+
enum: partitionNames
|
|
317
|
+
},
|
|
318
|
+
example: partitionExample
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: 'partitionValues',
|
|
322
|
+
in: 'query',
|
|
323
|
+
description: partitionValuesDescription,
|
|
324
|
+
schema: { type: 'string' },
|
|
325
|
+
example: partitionValuesExample
|
|
326
|
+
}
|
|
327
|
+
] : []),
|
|
328
|
+
...attributeQueryParams
|
|
329
|
+
],
|
|
330
|
+
responses: {
|
|
331
|
+
200: {
|
|
332
|
+
description: 'Successful response',
|
|
333
|
+
content: {
|
|
334
|
+
'application/json': {
|
|
335
|
+
schema: {
|
|
336
|
+
type: 'object',
|
|
337
|
+
properties: {
|
|
338
|
+
success: { type: 'boolean', example: true },
|
|
339
|
+
data: {
|
|
340
|
+
type: 'array',
|
|
341
|
+
items: schema
|
|
342
|
+
},
|
|
343
|
+
pagination: {
|
|
344
|
+
type: 'object',
|
|
345
|
+
description: 'Pagination metadata for the current request',
|
|
346
|
+
properties: {
|
|
347
|
+
total: {
|
|
348
|
+
type: 'integer',
|
|
349
|
+
description: 'Total number of items available',
|
|
350
|
+
example: 150
|
|
351
|
+
},
|
|
352
|
+
page: {
|
|
353
|
+
type: 'integer',
|
|
354
|
+
description: 'Current page number (1-indexed)',
|
|
355
|
+
example: 1
|
|
356
|
+
},
|
|
357
|
+
pageSize: {
|
|
358
|
+
type: 'integer',
|
|
359
|
+
description: 'Number of items per page (same as limit parameter)',
|
|
360
|
+
example: 10
|
|
361
|
+
},
|
|
362
|
+
pageCount: {
|
|
363
|
+
type: 'integer',
|
|
364
|
+
description: 'Total number of pages available',
|
|
365
|
+
example: 15
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
headers: {
|
|
374
|
+
'X-Total-Count': {
|
|
375
|
+
description: 'Total number of records',
|
|
376
|
+
schema: { type: 'integer' }
|
|
377
|
+
},
|
|
378
|
+
'X-Page-Count': {
|
|
379
|
+
description: 'Total number of pages',
|
|
380
|
+
schema: { type: 'integer' }
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
security: security.length > 0 ? security : undefined
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Get by ID endpoint
|
|
391
|
+
if (methods.includes('GET')) {
|
|
392
|
+
paths[`${basePath}/{id}`] = {
|
|
393
|
+
get: {
|
|
394
|
+
tags: [resourceName],
|
|
395
|
+
summary: `Get ${resourceName} by ID`,
|
|
396
|
+
description: `Retrieve a single ${resourceName} by its ID${hasPartitions ? '. Optionally specify a partition for more efficient retrieval.' : ''}`,
|
|
397
|
+
parameters: [
|
|
398
|
+
{
|
|
399
|
+
name: 'id',
|
|
400
|
+
in: 'path',
|
|
401
|
+
required: true,
|
|
402
|
+
description: `${resourceName} ID`,
|
|
403
|
+
schema: { type: 'string' }
|
|
404
|
+
},
|
|
405
|
+
...(hasPartitions ? [
|
|
406
|
+
{
|
|
407
|
+
name: 'partition',
|
|
408
|
+
in: 'query',
|
|
409
|
+
description: partitionDescription,
|
|
410
|
+
schema: {
|
|
411
|
+
type: 'string',
|
|
412
|
+
enum: partitionNames
|
|
413
|
+
},
|
|
414
|
+
example: partitionExample
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'partitionValues',
|
|
418
|
+
in: 'query',
|
|
419
|
+
description: partitionValuesDescription,
|
|
420
|
+
schema: { type: 'string' },
|
|
421
|
+
example: partitionValuesExample
|
|
422
|
+
}
|
|
423
|
+
] : [])
|
|
424
|
+
],
|
|
425
|
+
responses: {
|
|
426
|
+
200: {
|
|
427
|
+
description: 'Successful response',
|
|
428
|
+
content: {
|
|
429
|
+
'application/json': {
|
|
430
|
+
schema: {
|
|
431
|
+
type: 'object',
|
|
432
|
+
properties: {
|
|
433
|
+
success: { type: 'boolean', example: true },
|
|
434
|
+
data: schema
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
404: {
|
|
441
|
+
description: 'Resource not found',
|
|
442
|
+
content: {
|
|
443
|
+
'application/json': {
|
|
444
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
security: security.length > 0 ? security : undefined
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Create endpoint
|
|
455
|
+
if (methods.includes('POST')) {
|
|
456
|
+
if (!paths[basePath]) paths[basePath] = {};
|
|
457
|
+
paths[basePath].post = {
|
|
458
|
+
tags: [resourceName],
|
|
459
|
+
summary: `Create ${resourceName}`,
|
|
460
|
+
description: `Create a new ${resourceName}`,
|
|
461
|
+
requestBody: {
|
|
462
|
+
required: true,
|
|
463
|
+
content: {
|
|
464
|
+
'application/json': {
|
|
465
|
+
schema: schema
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
responses: {
|
|
470
|
+
201: {
|
|
471
|
+
description: 'Resource created successfully',
|
|
472
|
+
content: {
|
|
473
|
+
'application/json': {
|
|
474
|
+
schema: {
|
|
475
|
+
type: 'object',
|
|
476
|
+
properties: {
|
|
477
|
+
success: { type: 'boolean', example: true },
|
|
478
|
+
data: schema
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
headers: {
|
|
484
|
+
Location: {
|
|
485
|
+
description: 'URL of the created resource',
|
|
486
|
+
schema: { type: 'string' }
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
400: {
|
|
491
|
+
description: 'Validation error',
|
|
492
|
+
content: {
|
|
493
|
+
'application/json': {
|
|
494
|
+
schema: { $ref: '#/components/schemas/ValidationError' }
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
security: security.length > 0 ? security : undefined
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Update (full) endpoint
|
|
504
|
+
if (methods.includes('PUT')) {
|
|
505
|
+
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
506
|
+
paths[`${basePath}/{id}`].put = {
|
|
507
|
+
tags: [resourceName],
|
|
508
|
+
summary: `Update ${resourceName} (full)`,
|
|
509
|
+
description: `Fully update a ${resourceName}`,
|
|
510
|
+
parameters: [
|
|
511
|
+
{
|
|
512
|
+
name: 'id',
|
|
513
|
+
in: 'path',
|
|
514
|
+
required: true,
|
|
515
|
+
schema: { type: 'string' }
|
|
516
|
+
}
|
|
517
|
+
],
|
|
518
|
+
requestBody: {
|
|
519
|
+
required: true,
|
|
520
|
+
content: {
|
|
521
|
+
'application/json': {
|
|
522
|
+
schema: schema
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
responses: {
|
|
527
|
+
200: {
|
|
528
|
+
description: 'Resource updated successfully',
|
|
529
|
+
content: {
|
|
530
|
+
'application/json': {
|
|
531
|
+
schema: {
|
|
532
|
+
type: 'object',
|
|
533
|
+
properties: {
|
|
534
|
+
success: { type: 'boolean', example: true },
|
|
535
|
+
data: schema
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
404: {
|
|
542
|
+
description: 'Resource not found',
|
|
543
|
+
content: {
|
|
544
|
+
'application/json': {
|
|
545
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
security: security.length > 0 ? security : undefined
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Update (partial) endpoint
|
|
555
|
+
if (methods.includes('PATCH')) {
|
|
556
|
+
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
557
|
+
paths[`${basePath}/{id}`].patch = {
|
|
558
|
+
tags: [resourceName],
|
|
559
|
+
summary: `Update ${resourceName} (partial)`,
|
|
560
|
+
description: `Partially update a ${resourceName}`,
|
|
561
|
+
parameters: [
|
|
562
|
+
{
|
|
563
|
+
name: 'id',
|
|
564
|
+
in: 'path',
|
|
565
|
+
required: true,
|
|
566
|
+
schema: { type: 'string' }
|
|
567
|
+
}
|
|
568
|
+
],
|
|
569
|
+
requestBody: {
|
|
570
|
+
required: true,
|
|
571
|
+
content: {
|
|
572
|
+
'application/json': {
|
|
573
|
+
schema: {
|
|
574
|
+
...schema,
|
|
575
|
+
required: undefined // Partial updates don't require all fields
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
responses: {
|
|
581
|
+
200: {
|
|
582
|
+
description: 'Resource updated successfully',
|
|
583
|
+
content: {
|
|
584
|
+
'application/json': {
|
|
585
|
+
schema: {
|
|
586
|
+
type: 'object',
|
|
587
|
+
properties: {
|
|
588
|
+
success: { type: 'boolean', example: true },
|
|
589
|
+
data: schema
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
404: {
|
|
596
|
+
description: 'Resource not found',
|
|
597
|
+
content: {
|
|
598
|
+
'application/json': {
|
|
599
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
security: security.length > 0 ? security : undefined
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Delete endpoint
|
|
609
|
+
if (methods.includes('DELETE')) {
|
|
610
|
+
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
611
|
+
paths[`${basePath}/{id}`].delete = {
|
|
612
|
+
tags: [resourceName],
|
|
613
|
+
summary: `Delete ${resourceName}`,
|
|
614
|
+
description: `Delete a ${resourceName} by ID`,
|
|
615
|
+
parameters: [
|
|
616
|
+
{
|
|
617
|
+
name: 'id',
|
|
618
|
+
in: 'path',
|
|
619
|
+
required: true,
|
|
620
|
+
schema: { type: 'string' }
|
|
621
|
+
}
|
|
622
|
+
],
|
|
623
|
+
responses: {
|
|
624
|
+
204: {
|
|
625
|
+
description: 'Resource deleted successfully'
|
|
626
|
+
},
|
|
627
|
+
404: {
|
|
628
|
+
description: 'Resource not found',
|
|
629
|
+
content: {
|
|
630
|
+
'application/json': {
|
|
631
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
security: security.length > 0 ? security : undefined
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// HEAD endpoint - Get resource statistics
|
|
641
|
+
if (methods.includes('HEAD')) {
|
|
642
|
+
if (!paths[basePath]) paths[basePath] = {};
|
|
643
|
+
paths[basePath].head = {
|
|
644
|
+
tags: [resourceName],
|
|
645
|
+
summary: `Get ${resourceName} statistics`,
|
|
646
|
+
description: `Get statistics about ${resourceName} collection without retrieving data. Returns statistics in response headers.`,
|
|
647
|
+
responses: {
|
|
648
|
+
200: {
|
|
649
|
+
description: 'Statistics retrieved successfully',
|
|
650
|
+
headers: {
|
|
651
|
+
'X-Total-Count': {
|
|
652
|
+
description: 'Total number of records',
|
|
653
|
+
schema: { type: 'integer' }
|
|
654
|
+
},
|
|
655
|
+
'X-Resource-Version': {
|
|
656
|
+
description: 'Current resource version',
|
|
657
|
+
schema: { type: 'string' }
|
|
658
|
+
},
|
|
659
|
+
'X-Schema-Fields': {
|
|
660
|
+
description: 'Number of schema fields',
|
|
661
|
+
schema: { type: 'integer' }
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
security: security.length > 0 ? security : undefined
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// HEAD for individual resource
|
|
670
|
+
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
671
|
+
paths[`${basePath}/{id}`].head = {
|
|
672
|
+
tags: [resourceName],
|
|
673
|
+
summary: `Check if ${resourceName} exists`,
|
|
674
|
+
description: `Check if a ${resourceName} exists without retrieving its data`,
|
|
675
|
+
parameters: [
|
|
676
|
+
{
|
|
677
|
+
name: 'id',
|
|
678
|
+
in: 'path',
|
|
679
|
+
required: true,
|
|
680
|
+
schema: { type: 'string' }
|
|
681
|
+
}
|
|
682
|
+
],
|
|
683
|
+
responses: {
|
|
684
|
+
200: {
|
|
685
|
+
description: 'Resource exists',
|
|
686
|
+
headers: {
|
|
687
|
+
'Last-Modified': {
|
|
688
|
+
description: 'Last modification date',
|
|
689
|
+
schema: { type: 'string', format: 'date-time' }
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
404: {
|
|
694
|
+
description: 'Resource not found'
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
security: security.length > 0 ? security : undefined
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// OPTIONS endpoint - Get resource metadata
|
|
702
|
+
if (methods.includes('OPTIONS')) {
|
|
703
|
+
if (!paths[basePath]) paths[basePath] = {};
|
|
704
|
+
paths[basePath].options = {
|
|
705
|
+
tags: [resourceName],
|
|
706
|
+
summary: `Get ${resourceName} metadata`,
|
|
707
|
+
description: `Get complete metadata about ${resourceName} resource including schema, allowed methods, endpoints, and query parameters`,
|
|
708
|
+
responses: {
|
|
709
|
+
200: {
|
|
710
|
+
description: 'Metadata retrieved successfully',
|
|
711
|
+
headers: {
|
|
712
|
+
'Allow': {
|
|
713
|
+
description: 'Allowed HTTP methods',
|
|
714
|
+
schema: { type: 'string', example: 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS' }
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
content: {
|
|
718
|
+
'application/json': {
|
|
719
|
+
schema: {
|
|
720
|
+
type: 'object',
|
|
721
|
+
properties: {
|
|
722
|
+
resource: { type: 'string' },
|
|
723
|
+
version: { type: 'string' },
|
|
724
|
+
totalRecords: { type: 'integer' },
|
|
725
|
+
allowedMethods: {
|
|
726
|
+
type: 'array',
|
|
727
|
+
items: { type: 'string' }
|
|
728
|
+
},
|
|
729
|
+
schema: {
|
|
730
|
+
type: 'array',
|
|
731
|
+
items: {
|
|
732
|
+
type: 'object',
|
|
733
|
+
properties: {
|
|
734
|
+
name: { type: 'string' },
|
|
735
|
+
type: { type: 'string' },
|
|
736
|
+
rules: { type: 'array', items: { type: 'string' } }
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
endpoints: {
|
|
741
|
+
type: 'object',
|
|
742
|
+
properties: {
|
|
743
|
+
list: { type: 'string' },
|
|
744
|
+
get: { type: 'string' },
|
|
745
|
+
create: { type: 'string' },
|
|
746
|
+
update: { type: 'string' },
|
|
747
|
+
delete: { type: 'string' }
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
queryParameters: { type: 'object' }
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
// OPTIONS for individual resource
|
|
760
|
+
if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
|
|
761
|
+
paths[`${basePath}/{id}`].options = {
|
|
762
|
+
tags: [resourceName],
|
|
763
|
+
summary: `Get allowed methods for ${resourceName} item`,
|
|
764
|
+
description: `Get allowed HTTP methods for individual ${resourceName} operations`,
|
|
765
|
+
parameters: [
|
|
766
|
+
{
|
|
767
|
+
name: 'id',
|
|
768
|
+
in: 'path',
|
|
769
|
+
required: true,
|
|
770
|
+
schema: { type: 'string' }
|
|
771
|
+
}
|
|
772
|
+
],
|
|
773
|
+
responses: {
|
|
774
|
+
204: {
|
|
775
|
+
description: 'Methods retrieved successfully',
|
|
776
|
+
headers: {
|
|
777
|
+
'Allow': {
|
|
778
|
+
description: 'Allowed HTTP methods',
|
|
779
|
+
schema: { type: 'string', example: 'GET, PUT, PATCH, DELETE, HEAD, OPTIONS' }
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return paths;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Generate complete OpenAPI 3.0 specification
|
|
792
|
+
* @param {Object} database - s3db.js Database instance
|
|
793
|
+
* @param {Object} config - API configuration
|
|
794
|
+
* @returns {Object} OpenAPI 3.0 specification
|
|
795
|
+
*/
|
|
796
|
+
export function generateOpenAPISpec(database, config = {}) {
|
|
797
|
+
const {
|
|
798
|
+
title = 's3db.js API',
|
|
799
|
+
version = '1.0.0',
|
|
800
|
+
description = 'Auto-generated REST API documentation for s3db.js resources',
|
|
801
|
+
serverUrl = 'http://localhost:3000',
|
|
802
|
+
auth = {},
|
|
803
|
+
resources: resourceConfigs = {}
|
|
804
|
+
} = config;
|
|
805
|
+
|
|
806
|
+
const spec = {
|
|
807
|
+
openapi: '3.1.0',
|
|
808
|
+
info: {
|
|
809
|
+
title,
|
|
810
|
+
version,
|
|
811
|
+
description,
|
|
812
|
+
contact: {
|
|
813
|
+
name: 's3db.js',
|
|
814
|
+
url: 'https://github.com/forattini-dev/s3db.js'
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
servers: [
|
|
818
|
+
{
|
|
819
|
+
url: serverUrl,
|
|
820
|
+
description: 'API Server'
|
|
821
|
+
}
|
|
822
|
+
],
|
|
823
|
+
paths: {},
|
|
824
|
+
components: {
|
|
825
|
+
schemas: {
|
|
826
|
+
Error: {
|
|
827
|
+
type: 'object',
|
|
828
|
+
properties: {
|
|
829
|
+
success: { type: 'boolean', example: false },
|
|
830
|
+
error: {
|
|
831
|
+
type: 'object',
|
|
832
|
+
properties: {
|
|
833
|
+
message: { type: 'string' },
|
|
834
|
+
code: { type: 'string' },
|
|
835
|
+
details: { type: 'object' }
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
ValidationError: {
|
|
841
|
+
type: 'object',
|
|
842
|
+
properties: {
|
|
843
|
+
success: { type: 'boolean', example: false },
|
|
844
|
+
error: {
|
|
845
|
+
type: 'object',
|
|
846
|
+
properties: {
|
|
847
|
+
message: { type: 'string', example: 'Validation failed' },
|
|
848
|
+
code: { type: 'string', example: 'VALIDATION_ERROR' },
|
|
849
|
+
details: {
|
|
850
|
+
type: 'object',
|
|
851
|
+
properties: {
|
|
852
|
+
errors: {
|
|
853
|
+
type: 'array',
|
|
854
|
+
items: {
|
|
855
|
+
type: 'object',
|
|
856
|
+
properties: {
|
|
857
|
+
field: { type: 'string' },
|
|
858
|
+
message: { type: 'string' },
|
|
859
|
+
expected: { type: 'string' },
|
|
860
|
+
actual: {}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
securitySchemes: {}
|
|
872
|
+
},
|
|
873
|
+
tags: []
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
// Add security schemes
|
|
877
|
+
if (auth.jwt?.enabled) {
|
|
878
|
+
spec.components.securitySchemes.bearerAuth = {
|
|
879
|
+
type: 'http',
|
|
880
|
+
scheme: 'bearer',
|
|
881
|
+
bearerFormat: 'JWT',
|
|
882
|
+
description: 'JWT authentication'
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (auth.apiKey?.enabled) {
|
|
887
|
+
spec.components.securitySchemes.apiKeyAuth = {
|
|
888
|
+
type: 'apiKey',
|
|
889
|
+
in: 'header',
|
|
890
|
+
name: auth.apiKey.headerName || 'X-API-Key',
|
|
891
|
+
description: 'API Key authentication'
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (auth.basic?.enabled) {
|
|
896
|
+
spec.components.securitySchemes.basicAuth = {
|
|
897
|
+
type: 'http',
|
|
898
|
+
scheme: 'basic',
|
|
899
|
+
description: 'HTTP Basic authentication'
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Generate paths for each resource
|
|
904
|
+
const resources = database.resources;
|
|
905
|
+
|
|
906
|
+
for (const [name, resource] of Object.entries(resources)) {
|
|
907
|
+
// Skip plugin resources unless explicitly configured
|
|
908
|
+
if (name.startsWith('plg_') && !resourceConfigs[name]) {
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Get resource configuration
|
|
913
|
+
const config = resourceConfigs[name] || {
|
|
914
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
915
|
+
auth: false
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
// Determine version
|
|
919
|
+
const version = resource.config?.currentVersion || resource.version || 'v1';
|
|
920
|
+
|
|
921
|
+
// Generate paths
|
|
922
|
+
const paths = generateResourcePaths(resource, version, config);
|
|
923
|
+
|
|
924
|
+
// Merge paths
|
|
925
|
+
Object.assign(spec.paths, paths);
|
|
926
|
+
|
|
927
|
+
// Add tag
|
|
928
|
+
spec.tags.push({
|
|
929
|
+
name: name,
|
|
930
|
+
description: `Operations for ${name} resource`
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
// Add schema to components
|
|
934
|
+
spec.components.schemas[name] = generateResourceSchema(resource);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Add authentication endpoints if enabled
|
|
938
|
+
if (auth.jwt?.enabled || auth.apiKey?.enabled || auth.basic?.enabled) {
|
|
939
|
+
spec.paths['/auth/login'] = {
|
|
940
|
+
post: {
|
|
941
|
+
tags: ['Authentication'],
|
|
942
|
+
summary: 'Login',
|
|
943
|
+
description: 'Authenticate with username and password',
|
|
944
|
+
requestBody: {
|
|
945
|
+
required: true,
|
|
946
|
+
content: {
|
|
947
|
+
'application/json': {
|
|
948
|
+
schema: {
|
|
949
|
+
type: 'object',
|
|
950
|
+
properties: {
|
|
951
|
+
username: { type: 'string' },
|
|
952
|
+
password: { type: 'string', format: 'password' }
|
|
953
|
+
},
|
|
954
|
+
required: ['username', 'password']
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
},
|
|
959
|
+
responses: {
|
|
960
|
+
200: {
|
|
961
|
+
description: 'Login successful',
|
|
962
|
+
content: {
|
|
963
|
+
'application/json': {
|
|
964
|
+
schema: {
|
|
965
|
+
type: 'object',
|
|
966
|
+
properties: {
|
|
967
|
+
success: { type: 'boolean', example: true },
|
|
968
|
+
data: {
|
|
969
|
+
type: 'object',
|
|
970
|
+
properties: {
|
|
971
|
+
token: { type: 'string' },
|
|
972
|
+
user: { type: 'object' }
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
401: {
|
|
981
|
+
description: 'Invalid credentials',
|
|
982
|
+
content: {
|
|
983
|
+
'application/json': {
|
|
984
|
+
schema: { $ref: '#/components/schemas/Error' }
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
spec.paths['/auth/register'] = {
|
|
993
|
+
post: {
|
|
994
|
+
tags: ['Authentication'],
|
|
995
|
+
summary: 'Register',
|
|
996
|
+
description: 'Register a new user',
|
|
997
|
+
requestBody: {
|
|
998
|
+
required: true,
|
|
999
|
+
content: {
|
|
1000
|
+
'application/json': {
|
|
1001
|
+
schema: {
|
|
1002
|
+
type: 'object',
|
|
1003
|
+
properties: {
|
|
1004
|
+
username: { type: 'string', minLength: 3 },
|
|
1005
|
+
password: { type: 'string', format: 'password', minLength: 8 },
|
|
1006
|
+
email: { type: 'string', format: 'email' }
|
|
1007
|
+
},
|
|
1008
|
+
required: ['username', 'password']
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
responses: {
|
|
1014
|
+
201: {
|
|
1015
|
+
description: 'User registered successfully',
|
|
1016
|
+
content: {
|
|
1017
|
+
'application/json': {
|
|
1018
|
+
schema: {
|
|
1019
|
+
type: 'object',
|
|
1020
|
+
properties: {
|
|
1021
|
+
success: { type: 'boolean', example: true },
|
|
1022
|
+
data: {
|
|
1023
|
+
type: 'object',
|
|
1024
|
+
properties: {
|
|
1025
|
+
token: { type: 'string' },
|
|
1026
|
+
user: { type: 'object' }
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
spec.tags.push({
|
|
1039
|
+
name: 'Authentication',
|
|
1040
|
+
description: 'Authentication endpoints'
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Add health endpoints for Kubernetes probes
|
|
1045
|
+
spec.paths['/health'] = {
|
|
1046
|
+
get: {
|
|
1047
|
+
tags: ['Health'],
|
|
1048
|
+
summary: 'Generic Health Check',
|
|
1049
|
+
description: 'Generic health check endpoint that includes references to liveness and readiness probes',
|
|
1050
|
+
responses: {
|
|
1051
|
+
200: {
|
|
1052
|
+
description: 'API is healthy',
|
|
1053
|
+
content: {
|
|
1054
|
+
'application/json': {
|
|
1055
|
+
schema: {
|
|
1056
|
+
type: 'object',
|
|
1057
|
+
properties: {
|
|
1058
|
+
success: { type: 'boolean', example: true },
|
|
1059
|
+
data: {
|
|
1060
|
+
type: 'object',
|
|
1061
|
+
properties: {
|
|
1062
|
+
status: { type: 'string', example: 'ok' },
|
|
1063
|
+
uptime: { type: 'number', description: 'Process uptime in seconds' },
|
|
1064
|
+
timestamp: { type: 'string', format: 'date-time' },
|
|
1065
|
+
checks: {
|
|
1066
|
+
type: 'object',
|
|
1067
|
+
properties: {
|
|
1068
|
+
liveness: { type: 'string', example: '/health/live' },
|
|
1069
|
+
readiness: { type: 'string', example: '/health/ready' }
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
spec.paths['/health/live'] = {
|
|
1084
|
+
get: {
|
|
1085
|
+
tags: ['Health'],
|
|
1086
|
+
summary: 'Liveness Probe',
|
|
1087
|
+
description: 'Kubernetes liveness probe - checks if the application is alive. If this fails, Kubernetes will restart the pod.',
|
|
1088
|
+
responses: {
|
|
1089
|
+
200: {
|
|
1090
|
+
description: 'Application is alive',
|
|
1091
|
+
content: {
|
|
1092
|
+
'application/json': {
|
|
1093
|
+
schema: {
|
|
1094
|
+
type: 'object',
|
|
1095
|
+
properties: {
|
|
1096
|
+
success: { type: 'boolean', example: true },
|
|
1097
|
+
data: {
|
|
1098
|
+
type: 'object',
|
|
1099
|
+
properties: {
|
|
1100
|
+
status: { type: 'string', example: 'alive' },
|
|
1101
|
+
timestamp: { type: 'string', format: 'date-time' }
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
spec.paths['/health/ready'] = {
|
|
1114
|
+
get: {
|
|
1115
|
+
tags: ['Health'],
|
|
1116
|
+
summary: 'Readiness Probe',
|
|
1117
|
+
description: 'Kubernetes readiness probe - checks if the application is ready to receive traffic. If this fails, Kubernetes will remove the pod from service endpoints.',
|
|
1118
|
+
responses: {
|
|
1119
|
+
200: {
|
|
1120
|
+
description: 'Application is ready to receive traffic',
|
|
1121
|
+
content: {
|
|
1122
|
+
'application/json': {
|
|
1123
|
+
schema: {
|
|
1124
|
+
type: 'object',
|
|
1125
|
+
properties: {
|
|
1126
|
+
success: { type: 'boolean', example: true },
|
|
1127
|
+
data: {
|
|
1128
|
+
type: 'object',
|
|
1129
|
+
properties: {
|
|
1130
|
+
status: { type: 'string', example: 'ready' },
|
|
1131
|
+
database: {
|
|
1132
|
+
type: 'object',
|
|
1133
|
+
properties: {
|
|
1134
|
+
connected: { type: 'boolean', example: true },
|
|
1135
|
+
resources: { type: 'integer', example: 5 }
|
|
1136
|
+
}
|
|
1137
|
+
},
|
|
1138
|
+
timestamp: { type: 'string', format: 'date-time' }
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
503: {
|
|
1147
|
+
description: 'Application is not ready',
|
|
1148
|
+
content: {
|
|
1149
|
+
'application/json': {
|
|
1150
|
+
schema: {
|
|
1151
|
+
type: 'object',
|
|
1152
|
+
properties: {
|
|
1153
|
+
success: { type: 'boolean', example: false },
|
|
1154
|
+
error: {
|
|
1155
|
+
type: 'object',
|
|
1156
|
+
properties: {
|
|
1157
|
+
message: { type: 'string', example: 'Service not ready' },
|
|
1158
|
+
code: { type: 'string', example: 'NOT_READY' },
|
|
1159
|
+
details: {
|
|
1160
|
+
type: 'object',
|
|
1161
|
+
properties: {
|
|
1162
|
+
database: {
|
|
1163
|
+
type: 'object',
|
|
1164
|
+
properties: {
|
|
1165
|
+
connected: { type: 'boolean', example: false },
|
|
1166
|
+
resources: { type: 'integer', example: 0 }
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
spec.tags.push({
|
|
1183
|
+
name: 'Health',
|
|
1184
|
+
description: 'Health check endpoints for monitoring and Kubernetes probes'
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// Add Prometheus metrics endpoint if MetricsPlugin is active
|
|
1188
|
+
const metricsPlugin = database.plugins?.metrics || database.plugins?.MetricsPlugin;
|
|
1189
|
+
if (metricsPlugin && metricsPlugin.config?.prometheus?.enabled) {
|
|
1190
|
+
const metricsPath = metricsPlugin.config.prometheus.path || '/metrics';
|
|
1191
|
+
const isIntegrated = metricsPlugin.config.prometheus.mode !== 'standalone';
|
|
1192
|
+
|
|
1193
|
+
// Only add to OpenAPI if using integrated mode (same server)
|
|
1194
|
+
if (isIntegrated) {
|
|
1195
|
+
spec.paths[metricsPath] = {
|
|
1196
|
+
get: {
|
|
1197
|
+
tags: ['Monitoring'],
|
|
1198
|
+
summary: 'Prometheus Metrics',
|
|
1199
|
+
description: 'Exposes application metrics in Prometheus text-based exposition format for monitoring and observability. ' +
|
|
1200
|
+
'Metrics include operation counts, durations, errors, uptime, and resource statistics.',
|
|
1201
|
+
responses: {
|
|
1202
|
+
200: {
|
|
1203
|
+
description: 'Metrics in Prometheus format',
|
|
1204
|
+
content: {
|
|
1205
|
+
'text/plain': {
|
|
1206
|
+
schema: {
|
|
1207
|
+
type: 'string',
|
|
1208
|
+
example: '# HELP s3db_operations_total Total number of operations by type and resource\n' +
|
|
1209
|
+
'# TYPE s3db_operations_total counter\n' +
|
|
1210
|
+
's3db_operations_total{operation="insert",resource="cars"} 1523\n' +
|
|
1211
|
+
's3db_operations_total{operation="update",resource="cars"} 342\n\n' +
|
|
1212
|
+
'# HELP s3db_operation_duration_seconds Average operation duration in seconds\n' +
|
|
1213
|
+
'# TYPE s3db_operation_duration_seconds gauge\n' +
|
|
1214
|
+
's3db_operation_duration_seconds{operation="insert",resource="cars"} 0.045\n\n' +
|
|
1215
|
+
'# HELP s3db_operation_errors_total Total number of operation errors\n' +
|
|
1216
|
+
'# TYPE s3db_operation_errors_total counter\n' +
|
|
1217
|
+
's3db_operation_errors_total{operation="insert",resource="cars"} 12\n'
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
spec.tags.push({
|
|
1227
|
+
name: 'Monitoring',
|
|
1228
|
+
description: 'Monitoring and observability endpoints (Prometheus)'
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
return spec;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
export default {
|
|
1237
|
+
generateOpenAPISpec,
|
|
1238
|
+
generateResourceSchema,
|
|
1239
|
+
generateResourcePaths
|
|
1240
|
+
};
|