s3db.js 12.2.0 → 12.2.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/dist/s3db.es.js CHANGED
@@ -1,9 +1,6 @@
1
1
  import crypto$1, { createHash } from 'crypto';
2
2
  import { customAlphabet, urlAlphabet } from 'nanoid';
3
3
  import EventEmitter from 'events';
4
- import { Hono } from 'hono';
5
- import { serve } from '@hono/node-server';
6
- import { swaggerUI } from '@hono/swagger-ui';
7
4
  import { mkdir, copyFile, unlink, stat, access, readdir, writeFile, readFile, rm, watch } from 'fs/promises';
8
5
  import fs, { createReadStream, createWriteStream, realpathSync as realpathSync$1, readlinkSync, readdirSync, readdir as readdir$2, lstatSync, existsSync } from 'fs';
9
6
  import { pipeline } from 'stream/promises';
@@ -2451,1968 +2448,6 @@ const PluginObject = {
2451
2448
  }
2452
2449
  };
2453
2450
 
2454
- function success(data, options = {}) {
2455
- const { status = 200, meta = {} } = options;
2456
- return {
2457
- success: true,
2458
- data,
2459
- meta: {
2460
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2461
- ...meta
2462
- },
2463
- _status: status
2464
- };
2465
- }
2466
- function error(error2, options = {}) {
2467
- const { status = 500, code = "INTERNAL_ERROR", details = {} } = options;
2468
- const errorMessage = error2 instanceof Error ? error2.message : error2;
2469
- const errorStack = error2 instanceof Error && process.env.NODE_ENV !== "production" ? error2.stack : void 0;
2470
- return {
2471
- success: false,
2472
- error: {
2473
- message: errorMessage,
2474
- code,
2475
- details,
2476
- stack: errorStack
2477
- },
2478
- meta: {
2479
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2480
- },
2481
- _status: status
2482
- };
2483
- }
2484
- function list(items, pagination = {}) {
2485
- const { total, page, pageSize, pageCount } = pagination;
2486
- return {
2487
- success: true,
2488
- data: items,
2489
- pagination: {
2490
- total: total || items.length,
2491
- page: page || 1,
2492
- pageSize: pageSize || items.length,
2493
- pageCount: pageCount || 1
2494
- },
2495
- meta: {
2496
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2497
- },
2498
- _status: 200
2499
- };
2500
- }
2501
- function created(data, location) {
2502
- return {
2503
- success: true,
2504
- data,
2505
- meta: {
2506
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2507
- location
2508
- },
2509
- _status: 201
2510
- };
2511
- }
2512
- function noContent() {
2513
- return {
2514
- success: true,
2515
- data: null,
2516
- meta: {
2517
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2518
- },
2519
- _status: 204
2520
- };
2521
- }
2522
- function notFound(resource, id) {
2523
- return error(`${resource} with id '${id}' not found`, {
2524
- status: 404,
2525
- code: "NOT_FOUND",
2526
- details: { resource, id }
2527
- });
2528
- }
2529
- function payloadTooLarge(size, limit) {
2530
- return error("Request payload too large", {
2531
- status: 413,
2532
- code: "PAYLOAD_TOO_LARGE",
2533
- details: {
2534
- receivedSize: size,
2535
- maxSize: limit,
2536
- receivedMB: (size / 1024 / 1024).toFixed(2),
2537
- maxMB: (limit / 1024 / 1024).toFixed(2)
2538
- }
2539
- });
2540
- }
2541
-
2542
- const errorStatusMap = {
2543
- "ValidationError": 400,
2544
- "InvalidResourceItem": 400,
2545
- "ResourceNotFound": 404,
2546
- "NoSuchKey": 404,
2547
- "NoSuchBucket": 404,
2548
- "PartitionError": 400,
2549
- "CryptoError": 500,
2550
- "SchemaError": 400,
2551
- "QueueError": 500,
2552
- "ResourceError": 500
2553
- };
2554
- function getStatusFromError(err) {
2555
- if (err.name && errorStatusMap[err.name]) {
2556
- return errorStatusMap[err.name];
2557
- }
2558
- if (err.constructor && err.constructor.name && errorStatusMap[err.constructor.name]) {
2559
- return errorStatusMap[err.constructor.name];
2560
- }
2561
- if (err.message) {
2562
- if (err.message.includes("not found") || err.message.includes("does not exist")) {
2563
- return 404;
2564
- }
2565
- if (err.message.includes("validation") || err.message.includes("invalid")) {
2566
- return 400;
2567
- }
2568
- if (err.message.includes("unauthorized") || err.message.includes("authentication")) {
2569
- return 401;
2570
- }
2571
- if (err.message.includes("forbidden") || err.message.includes("permission")) {
2572
- return 403;
2573
- }
2574
- }
2575
- return 500;
2576
- }
2577
- function errorHandler(err, c) {
2578
- const status = getStatusFromError(err);
2579
- const code = err.name || "INTERNAL_ERROR";
2580
- const details = {};
2581
- if (err.resource) details.resource = err.resource;
2582
- if (err.bucket) details.bucket = err.bucket;
2583
- if (err.key) details.key = err.key;
2584
- if (err.operation) details.operation = err.operation;
2585
- if (err.suggestion) details.suggestion = err.suggestion;
2586
- if (err.availableResources) details.availableResources = err.availableResources;
2587
- const response = error(err, {
2588
- status,
2589
- code,
2590
- details
2591
- });
2592
- if (status >= 500) {
2593
- console.error("[API Plugin] Error:", {
2594
- message: err.message,
2595
- code,
2596
- status,
2597
- stack: err.stack,
2598
- details
2599
- });
2600
- } else if (status >= 400 && status < 500 && c.get("verbose")) {
2601
- console.warn("[API Plugin] Client error:", {
2602
- message: err.message,
2603
- code,
2604
- status,
2605
- details
2606
- });
2607
- }
2608
- return c.json(response, response._status);
2609
- }
2610
- function asyncHandler(fn) {
2611
- return async (c) => {
2612
- try {
2613
- return await fn(c);
2614
- } catch (err) {
2615
- return errorHandler(err, c);
2616
- }
2617
- };
2618
- }
2619
-
2620
- function createResourceRoutes(resource, version, config = {}) {
2621
- const app = new Hono();
2622
- const {
2623
- methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
2624
- customMiddleware = [],
2625
- enableValidation = true
2626
- } = config;
2627
- const resourceName = resource.name;
2628
- const basePath = `/${version}/${resourceName}`;
2629
- customMiddleware.forEach((middleware) => {
2630
- app.use("*", middleware);
2631
- });
2632
- if (methods.includes("GET")) {
2633
- app.get("/", asyncHandler(async (c) => {
2634
- const query = c.req.query();
2635
- const limit = parseInt(query.limit) || 100;
2636
- const offset = parseInt(query.offset) || 0;
2637
- const partition = query.partition;
2638
- const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
2639
- const reservedKeys = ["limit", "offset", "partition", "partitionValues", "sort"];
2640
- const filters = {};
2641
- for (const [key, value] of Object.entries(query)) {
2642
- if (!reservedKeys.includes(key)) {
2643
- try {
2644
- filters[key] = JSON.parse(value);
2645
- } catch {
2646
- filters[key] = value;
2647
- }
2648
- }
2649
- }
2650
- let items;
2651
- let total;
2652
- if (Object.keys(filters).length > 0) {
2653
- items = await resource.query(filters, { limit: limit + offset });
2654
- items = items.slice(offset, offset + limit);
2655
- total = items.length;
2656
- } else if (partition && partitionValues) {
2657
- items = await resource.listPartition({
2658
- partition,
2659
- partitionValues,
2660
- limit: limit + offset
2661
- });
2662
- items = items.slice(offset, offset + limit);
2663
- total = items.length;
2664
- } else {
2665
- items = await resource.list({ limit: limit + offset });
2666
- items = items.slice(offset, offset + limit);
2667
- total = items.length;
2668
- }
2669
- const response = list(items, {
2670
- total,
2671
- page: Math.floor(offset / limit) + 1,
2672
- pageSize: limit,
2673
- pageCount: Math.ceil(total / limit)
2674
- });
2675
- c.header("X-Total-Count", total.toString());
2676
- c.header("X-Page-Count", Math.ceil(total / limit).toString());
2677
- return c.json(response, response._status);
2678
- }));
2679
- }
2680
- if (methods.includes("GET")) {
2681
- app.get("/:id", asyncHandler(async (c) => {
2682
- const id = c.req.param("id");
2683
- const query = c.req.query();
2684
- const partition = query.partition;
2685
- const partitionValues = query.partitionValues ? JSON.parse(query.partitionValues) : void 0;
2686
- let item;
2687
- if (partition && partitionValues) {
2688
- item = await resource.getFromPartition({
2689
- id,
2690
- partitionName: partition,
2691
- partitionValues
2692
- });
2693
- } else {
2694
- item = await resource.get(id);
2695
- }
2696
- if (!item) {
2697
- const response2 = notFound(resourceName, id);
2698
- return c.json(response2, response2._status);
2699
- }
2700
- const response = success(item);
2701
- return c.json(response, response._status);
2702
- }));
2703
- }
2704
- if (methods.includes("POST")) {
2705
- app.post("/", asyncHandler(async (c) => {
2706
- const data = await c.req.json();
2707
- const item = await resource.insert(data);
2708
- const location = `${basePath}/${item.id}`;
2709
- const response = created(item, location);
2710
- c.header("Location", location);
2711
- return c.json(response, response._status);
2712
- }));
2713
- }
2714
- if (methods.includes("PUT")) {
2715
- app.put("/:id", asyncHandler(async (c) => {
2716
- const id = c.req.param("id");
2717
- const data = await c.req.json();
2718
- const existing = await resource.get(id);
2719
- if (!existing) {
2720
- const response2 = notFound(resourceName, id);
2721
- return c.json(response2, response2._status);
2722
- }
2723
- const updated = await resource.update(id, data);
2724
- const response = success(updated);
2725
- return c.json(response, response._status);
2726
- }));
2727
- }
2728
- if (methods.includes("PATCH")) {
2729
- app.patch("/:id", asyncHandler(async (c) => {
2730
- const id = c.req.param("id");
2731
- const data = await c.req.json();
2732
- const existing = await resource.get(id);
2733
- if (!existing) {
2734
- const response2 = notFound(resourceName, id);
2735
- return c.json(response2, response2._status);
2736
- }
2737
- const merged = { ...existing, ...data, id };
2738
- const updated = await resource.update(id, merged);
2739
- const response = success(updated);
2740
- return c.json(response, response._status);
2741
- }));
2742
- }
2743
- if (methods.includes("DELETE")) {
2744
- app.delete("/:id", asyncHandler(async (c) => {
2745
- const id = c.req.param("id");
2746
- const existing = await resource.get(id);
2747
- if (!existing) {
2748
- const response2 = notFound(resourceName, id);
2749
- return c.json(response2, response2._status);
2750
- }
2751
- await resource.delete(id);
2752
- const response = noContent();
2753
- return c.json(response, response._status);
2754
- }));
2755
- }
2756
- if (methods.includes("HEAD")) {
2757
- app.on("HEAD", "/", asyncHandler(async (c) => {
2758
- const total = await resource.count();
2759
- const allItems = await resource.list({ limit: 1e3 });
2760
- const stats = {
2761
- total,
2762
- version: resource.config?.currentVersion || resource.version || "v1"
2763
- };
2764
- c.header("X-Total-Count", total.toString());
2765
- c.header("X-Resource-Version", stats.version);
2766
- c.header("X-Schema-Fields", Object.keys(resource.config?.attributes || {}).length.toString());
2767
- return c.body(null, 200);
2768
- }));
2769
- app.on("HEAD", "/:id", asyncHandler(async (c) => {
2770
- const id = c.req.param("id");
2771
- const item = await resource.get(id);
2772
- if (!item) {
2773
- return c.body(null, 404);
2774
- }
2775
- if (item.updatedAt) {
2776
- c.header("Last-Modified", new Date(item.updatedAt).toUTCString());
2777
- }
2778
- return c.body(null, 200);
2779
- }));
2780
- }
2781
- if (methods.includes("OPTIONS")) {
2782
- app.options("/", asyncHandler(async (c) => {
2783
- c.header("Allow", methods.join(", "));
2784
- const total = await resource.count();
2785
- const schema = resource.config?.attributes || {};
2786
- const version2 = resource.config?.currentVersion || resource.version || "v1";
2787
- const metadata = {
2788
- resource: resourceName,
2789
- version: version2,
2790
- totalRecords: total,
2791
- allowedMethods: methods,
2792
- schema: Object.entries(schema).map(([name, def]) => ({
2793
- name,
2794
- type: typeof def === "string" ? def.split("|")[0] : def.type,
2795
- rules: typeof def === "string" ? def.split("|").slice(1) : []
2796
- })),
2797
- endpoints: {
2798
- list: `/${version2}/${resourceName}`,
2799
- get: `/${version2}/${resourceName}/:id`,
2800
- create: `/${version2}/${resourceName}`,
2801
- update: `/${version2}/${resourceName}/:id`,
2802
- delete: `/${version2}/${resourceName}/:id`
2803
- },
2804
- queryParameters: {
2805
- limit: "number (1-1000, default: 100)",
2806
- offset: "number (min: 0, default: 0)",
2807
- partition: "string (partition name)",
2808
- partitionValues: "JSON string",
2809
- "[any field]": "any (filter by field value)"
2810
- }
2811
- };
2812
- return c.json(metadata);
2813
- }));
2814
- app.options("/:id", (c) => {
2815
- c.header("Allow", methods.filter((m) => m !== "POST").join(", "));
2816
- return c.body(null, 204);
2817
- });
2818
- }
2819
- return app;
2820
- }
2821
- function createRelationalRoutes(sourceResource, relationName, relationConfig, version) {
2822
- const app = new Hono();
2823
- const resourceName = sourceResource.name;
2824
- const relatedResourceName = relationConfig.resource;
2825
- app.get("/:id", asyncHandler(async (c) => {
2826
- const id = c.req.param("id");
2827
- const query = c.req.query();
2828
- const source = await sourceResource.get(id);
2829
- if (!source) {
2830
- const response = notFound(resourceName, id);
2831
- return c.json(response, response._status);
2832
- }
2833
- const result = await sourceResource.get(id, {
2834
- include: [relationName]
2835
- });
2836
- const relatedData = result[relationName];
2837
- if (!relatedData) {
2838
- if (relationConfig.type === "hasMany" || relationConfig.type === "belongsToMany") {
2839
- const response = list([], {
2840
- total: 0,
2841
- page: 1,
2842
- pageSize: 100,
2843
- pageCount: 0
2844
- });
2845
- return c.json(response, response._status);
2846
- } else {
2847
- const response = notFound(relatedResourceName, "related resource");
2848
- return c.json(response, response._status);
2849
- }
2850
- }
2851
- if (relationConfig.type === "hasMany" || relationConfig.type === "belongsToMany") {
2852
- const items = Array.isArray(relatedData) ? relatedData : [relatedData];
2853
- const limit = parseInt(query.limit) || 100;
2854
- const offset = parseInt(query.offset) || 0;
2855
- const paginatedItems = items.slice(offset, offset + limit);
2856
- const response = list(paginatedItems, {
2857
- total: items.length,
2858
- page: Math.floor(offset / limit) + 1,
2859
- pageSize: limit,
2860
- pageCount: Math.ceil(items.length / limit)
2861
- });
2862
- c.header("X-Total-Count", items.length.toString());
2863
- c.header("X-Page-Count", Math.ceil(items.length / limit).toString());
2864
- return c.json(response, response._status);
2865
- } else {
2866
- const response = success(relatedData);
2867
- return c.json(response, response._status);
2868
- }
2869
- }));
2870
- return app;
2871
- }
2872
-
2873
- function mapFieldTypeToOpenAPI(fieldType) {
2874
- const type = fieldType.split("|")[0].trim();
2875
- const typeMap = {
2876
- "string": { type: "string" },
2877
- "number": { type: "number" },
2878
- "integer": { type: "integer" },
2879
- "boolean": { type: "boolean" },
2880
- "array": { type: "array", items: { type: "string" } },
2881
- "object": { type: "object" },
2882
- "json": { type: "object" },
2883
- "secret": { type: "string", format: "password" },
2884
- "email": { type: "string", format: "email" },
2885
- "url": { type: "string", format: "uri" },
2886
- "date": { type: "string", format: "date" },
2887
- "datetime": { type: "string", format: "date-time" },
2888
- "ip4": { type: "string", format: "ipv4", description: "IPv4 address" },
2889
- "ip6": { type: "string", format: "ipv6", description: "IPv6 address" },
2890
- "embedding": { type: "array", items: { type: "number" }, description: "Vector embedding" }
2891
- };
2892
- if (type.startsWith("embedding:")) {
2893
- const length = parseInt(type.split(":")[1]);
2894
- return {
2895
- type: "array",
2896
- items: { type: "number" },
2897
- minItems: length,
2898
- maxItems: length,
2899
- description: `Vector embedding (${length} dimensions)`
2900
- };
2901
- }
2902
- return typeMap[type] || { type: "string" };
2903
- }
2904
- function extractValidationRules(fieldDef) {
2905
- const rules = {};
2906
- const parts = fieldDef.split("|");
2907
- for (const part of parts) {
2908
- const [rule, value] = part.split(":").map((s) => s.trim());
2909
- switch (rule) {
2910
- case "required":
2911
- rules.required = true;
2912
- break;
2913
- case "min":
2914
- rules.minimum = parseFloat(value);
2915
- break;
2916
- case "max":
2917
- rules.maximum = parseFloat(value);
2918
- break;
2919
- case "minlength":
2920
- rules.minLength = parseInt(value);
2921
- break;
2922
- case "maxlength":
2923
- rules.maxLength = parseInt(value);
2924
- break;
2925
- case "pattern":
2926
- rules.pattern = value;
2927
- break;
2928
- case "enum":
2929
- rules.enum = value.split(",").map((v) => v.trim());
2930
- break;
2931
- case "default":
2932
- rules.default = value;
2933
- break;
2934
- }
2935
- }
2936
- return rules;
2937
- }
2938
- function generateResourceSchema(resource) {
2939
- const properties = {};
2940
- const required = [];
2941
- const attributes = resource.config?.attributes || resource.attributes || {};
2942
- const resourceDescription = resource.config?.description;
2943
- const attributeDescriptions = typeof resourceDescription === "object" ? resourceDescription.attributes || {} : {};
2944
- properties.id = {
2945
- type: "string",
2946
- description: "Unique identifier for the resource",
2947
- example: "2_gDTpeU6EI0e8B92n_R3Y",
2948
- readOnly: true
2949
- };
2950
- for (const [fieldName, fieldDef] of Object.entries(attributes)) {
2951
- if (typeof fieldDef === "object" && fieldDef.type) {
2952
- const baseType = mapFieldTypeToOpenAPI(fieldDef.type);
2953
- properties[fieldName] = {
2954
- ...baseType,
2955
- description: fieldDef.description || attributeDescriptions[fieldName] || void 0
2956
- };
2957
- if (fieldDef.required) {
2958
- required.push(fieldName);
2959
- }
2960
- if (fieldDef.type === "object" && fieldDef.props) {
2961
- properties[fieldName].properties = {};
2962
- for (const [propName, propDef] of Object.entries(fieldDef.props)) {
2963
- const propType = typeof propDef === "string" ? propDef : propDef.type;
2964
- properties[fieldName].properties[propName] = mapFieldTypeToOpenAPI(propType);
2965
- }
2966
- }
2967
- if (fieldDef.type === "array" && fieldDef.items) {
2968
- properties[fieldName].items = mapFieldTypeToOpenAPI(fieldDef.items);
2969
- }
2970
- } else if (typeof fieldDef === "string") {
2971
- const baseType = mapFieldTypeToOpenAPI(fieldDef);
2972
- const rules = extractValidationRules(fieldDef);
2973
- properties[fieldName] = {
2974
- ...baseType,
2975
- ...rules,
2976
- description: attributeDescriptions[fieldName] || void 0
2977
- };
2978
- if (rules.required) {
2979
- required.push(fieldName);
2980
- delete properties[fieldName].required;
2981
- }
2982
- }
2983
- }
2984
- return {
2985
- type: "object",
2986
- properties,
2987
- required: required.length > 0 ? required : void 0
2988
- };
2989
- }
2990
- function generateResourcePaths(resource, version, config = {}) {
2991
- const resourceName = resource.name;
2992
- const basePath = `/${version}/${resourceName}`;
2993
- const schema = generateResourceSchema(resource);
2994
- const methods = config.methods || ["GET", "POST", "PUT", "PATCH", "DELETE"];
2995
- const authMethods = config.auth || [];
2996
- const requiresAuth = authMethods && authMethods.length > 0;
2997
- const paths = {};
2998
- const security = [];
2999
- if (requiresAuth) {
3000
- if (authMethods.includes("jwt")) security.push({ bearerAuth: [] });
3001
- if (authMethods.includes("apiKey")) security.push({ apiKeyAuth: [] });
3002
- if (authMethods.includes("basic")) security.push({ basicAuth: [] });
3003
- }
3004
- const partitions = resource.config?.options?.partitions || resource.config?.partitions || resource.partitions || {};
3005
- const partitionNames = Object.keys(partitions);
3006
- const hasPartitions = partitionNames.length > 0;
3007
- let partitionDescription = "Partition name for filtering";
3008
- let partitionValuesDescription = "Partition values as JSON string";
3009
- let partitionExample = void 0;
3010
- let partitionValuesExample = void 0;
3011
- if (hasPartitions) {
3012
- const partitionDocs = partitionNames.map((name) => {
3013
- const partition = partitions[name];
3014
- const fields = Object.keys(partition.fields || {});
3015
- const fieldTypes = Object.entries(partition.fields || {}).map(([field, type]) => `${field}: ${type}`).join(", ");
3016
- return `- **${name}**: Filters by ${fields.join(", ")} (${fieldTypes})`;
3017
- }).join("\n");
3018
- partitionDescription = `Available partitions:
3019
- ${partitionDocs}`;
3020
- const examplePartition = partitionNames[0];
3021
- const exampleFields = partitions[examplePartition]?.fields || {};
3022
- Object.entries(exampleFields).map(([field, type]) => `"${field}": <${type} value>`).join(", ");
3023
- partitionValuesDescription = `Partition field values as JSON string. Must match the structure of the selected partition.
3024
-
3025
- Example for "${examplePartition}" partition: \`{"${Object.keys(exampleFields)[0]}": "value"}\``;
3026
- partitionExample = examplePartition;
3027
- const firstField = Object.keys(exampleFields)[0];
3028
- const firstFieldType = exampleFields[firstField];
3029
- let exampleValue = "example";
3030
- if (firstFieldType === "number" || firstFieldType === "integer") {
3031
- exampleValue = 123;
3032
- } else if (firstFieldType === "boolean") {
3033
- exampleValue = true;
3034
- }
3035
- partitionValuesExample = JSON.stringify({ [firstField]: exampleValue });
3036
- }
3037
- const attributeQueryParams = [];
3038
- if (hasPartitions) {
3039
- const partitionFieldsSet = /* @__PURE__ */ new Set();
3040
- for (const [partitionName, partition] of Object.entries(partitions)) {
3041
- const fields = partition.fields || {};
3042
- for (const fieldName of Object.keys(fields)) {
3043
- partitionFieldsSet.add(fieldName);
3044
- }
3045
- }
3046
- const attributes = resource.config?.attributes || resource.attributes || {};
3047
- for (const fieldName of partitionFieldsSet) {
3048
- const fieldDef = attributes[fieldName];
3049
- if (!fieldDef) continue;
3050
- let fieldType;
3051
- if (typeof fieldDef === "object" && fieldDef.type) {
3052
- fieldType = fieldDef.type;
3053
- } else if (typeof fieldDef === "string") {
3054
- fieldType = fieldDef.split("|")[0].trim();
3055
- } else {
3056
- fieldType = "string";
3057
- }
3058
- const openAPIType = mapFieldTypeToOpenAPI(fieldType);
3059
- attributeQueryParams.push({
3060
- name: fieldName,
3061
- in: "query",
3062
- description: `Filter by ${fieldName} field (indexed via partitions for efficient querying). Value will be parsed as JSON if possible, otherwise treated as string.`,
3063
- required: false,
3064
- schema: openAPIType
3065
- });
3066
- }
3067
- }
3068
- if (methods.includes("GET")) {
3069
- paths[basePath] = {
3070
- get: {
3071
- tags: [resourceName],
3072
- summary: `List ${resourceName}`,
3073
- 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.
3074
-
3075
- **Pagination**: Use \`limit\` and \`offset\` to paginate results. For example:
3076
- - First page (10 items): \`?limit=10&offset=0\`
3077
- - Second page: \`?limit=10&offset=10\`
3078
- - Third page: \`?limit=10&offset=20\`
3079
-
3080
- 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." : ""}`,
3081
- parameters: [
3082
- {
3083
- name: "limit",
3084
- in: "query",
3085
- description: "Maximum number of items to return per page (page size)",
3086
- schema: { type: "integer", default: 100, minimum: 1, maximum: 1e3 },
3087
- example: 10
3088
- },
3089
- {
3090
- name: "offset",
3091
- in: "query",
3092
- description: "Number of items to skip before starting to return results. Use for pagination: offset = (page - 1) * limit",
3093
- schema: { type: "integer", default: 0, minimum: 0 },
3094
- example: 0
3095
- },
3096
- ...hasPartitions ? [
3097
- {
3098
- name: "partition",
3099
- in: "query",
3100
- description: partitionDescription,
3101
- schema: {
3102
- type: "string",
3103
- enum: partitionNames
3104
- },
3105
- example: partitionExample
3106
- },
3107
- {
3108
- name: "partitionValues",
3109
- in: "query",
3110
- description: partitionValuesDescription,
3111
- schema: { type: "string" },
3112
- example: partitionValuesExample
3113
- }
3114
- ] : [],
3115
- ...attributeQueryParams
3116
- ],
3117
- responses: {
3118
- 200: {
3119
- description: "Successful response",
3120
- content: {
3121
- "application/json": {
3122
- schema: {
3123
- type: "object",
3124
- properties: {
3125
- success: { type: "boolean", example: true },
3126
- data: {
3127
- type: "array",
3128
- items: schema
3129
- },
3130
- pagination: {
3131
- type: "object",
3132
- description: "Pagination metadata for the current request",
3133
- properties: {
3134
- total: {
3135
- type: "integer",
3136
- description: "Total number of items available",
3137
- example: 150
3138
- },
3139
- page: {
3140
- type: "integer",
3141
- description: "Current page number (1-indexed)",
3142
- example: 1
3143
- },
3144
- pageSize: {
3145
- type: "integer",
3146
- description: "Number of items per page (same as limit parameter)",
3147
- example: 10
3148
- },
3149
- pageCount: {
3150
- type: "integer",
3151
- description: "Total number of pages available",
3152
- example: 15
3153
- }
3154
- }
3155
- }
3156
- }
3157
- }
3158
- }
3159
- },
3160
- headers: {
3161
- "X-Total-Count": {
3162
- description: "Total number of records",
3163
- schema: { type: "integer" }
3164
- },
3165
- "X-Page-Count": {
3166
- description: "Total number of pages",
3167
- schema: { type: "integer" }
3168
- }
3169
- }
3170
- }
3171
- },
3172
- security: security.length > 0 ? security : void 0
3173
- }
3174
- };
3175
- }
3176
- if (methods.includes("GET")) {
3177
- paths[`${basePath}/{id}`] = {
3178
- get: {
3179
- tags: [resourceName],
3180
- summary: `Get ${resourceName} by ID`,
3181
- description: `Retrieve a single ${resourceName} by its ID${hasPartitions ? ". Optionally specify a partition for more efficient retrieval." : ""}`,
3182
- parameters: [
3183
- {
3184
- name: "id",
3185
- in: "path",
3186
- required: true,
3187
- description: `${resourceName} ID`,
3188
- schema: { type: "string" }
3189
- },
3190
- ...hasPartitions ? [
3191
- {
3192
- name: "partition",
3193
- in: "query",
3194
- description: partitionDescription,
3195
- schema: {
3196
- type: "string",
3197
- enum: partitionNames
3198
- },
3199
- example: partitionExample
3200
- },
3201
- {
3202
- name: "partitionValues",
3203
- in: "query",
3204
- description: partitionValuesDescription,
3205
- schema: { type: "string" },
3206
- example: partitionValuesExample
3207
- }
3208
- ] : []
3209
- ],
3210
- responses: {
3211
- 200: {
3212
- description: "Successful response",
3213
- content: {
3214
- "application/json": {
3215
- schema: {
3216
- type: "object",
3217
- properties: {
3218
- success: { type: "boolean", example: true },
3219
- data: schema
3220
- }
3221
- }
3222
- }
3223
- }
3224
- },
3225
- 404: {
3226
- description: "Resource not found",
3227
- content: {
3228
- "application/json": {
3229
- schema: { $ref: "#/components/schemas/Error" }
3230
- }
3231
- }
3232
- }
3233
- },
3234
- security: security.length > 0 ? security : void 0
3235
- }
3236
- };
3237
- }
3238
- if (methods.includes("POST")) {
3239
- if (!paths[basePath]) paths[basePath] = {};
3240
- paths[basePath].post = {
3241
- tags: [resourceName],
3242
- summary: `Create ${resourceName}`,
3243
- description: `Create a new ${resourceName}`,
3244
- requestBody: {
3245
- required: true,
3246
- content: {
3247
- "application/json": {
3248
- schema
3249
- }
3250
- }
3251
- },
3252
- responses: {
3253
- 201: {
3254
- description: "Resource created successfully",
3255
- content: {
3256
- "application/json": {
3257
- schema: {
3258
- type: "object",
3259
- properties: {
3260
- success: { type: "boolean", example: true },
3261
- data: schema
3262
- }
3263
- }
3264
- }
3265
- },
3266
- headers: {
3267
- Location: {
3268
- description: "URL of the created resource",
3269
- schema: { type: "string" }
3270
- }
3271
- }
3272
- },
3273
- 400: {
3274
- description: "Validation error",
3275
- content: {
3276
- "application/json": {
3277
- schema: { $ref: "#/components/schemas/ValidationError" }
3278
- }
3279
- }
3280
- }
3281
- },
3282
- security: security.length > 0 ? security : void 0
3283
- };
3284
- }
3285
- if (methods.includes("PUT")) {
3286
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3287
- paths[`${basePath}/{id}`].put = {
3288
- tags: [resourceName],
3289
- summary: `Update ${resourceName} (full)`,
3290
- description: `Fully update a ${resourceName}`,
3291
- parameters: [
3292
- {
3293
- name: "id",
3294
- in: "path",
3295
- required: true,
3296
- schema: { type: "string" }
3297
- }
3298
- ],
3299
- requestBody: {
3300
- required: true,
3301
- content: {
3302
- "application/json": {
3303
- schema
3304
- }
3305
- }
3306
- },
3307
- responses: {
3308
- 200: {
3309
- description: "Resource updated successfully",
3310
- content: {
3311
- "application/json": {
3312
- schema: {
3313
- type: "object",
3314
- properties: {
3315
- success: { type: "boolean", example: true },
3316
- data: schema
3317
- }
3318
- }
3319
- }
3320
- }
3321
- },
3322
- 404: {
3323
- description: "Resource not found",
3324
- content: {
3325
- "application/json": {
3326
- schema: { $ref: "#/components/schemas/Error" }
3327
- }
3328
- }
3329
- }
3330
- },
3331
- security: security.length > 0 ? security : void 0
3332
- };
3333
- }
3334
- if (methods.includes("PATCH")) {
3335
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3336
- paths[`${basePath}/{id}`].patch = {
3337
- tags: [resourceName],
3338
- summary: `Update ${resourceName} (partial)`,
3339
- description: `Partially update a ${resourceName}`,
3340
- parameters: [
3341
- {
3342
- name: "id",
3343
- in: "path",
3344
- required: true,
3345
- schema: { type: "string" }
3346
- }
3347
- ],
3348
- requestBody: {
3349
- required: true,
3350
- content: {
3351
- "application/json": {
3352
- schema: {
3353
- ...schema,
3354
- required: void 0
3355
- // Partial updates don't require all fields
3356
- }
3357
- }
3358
- }
3359
- },
3360
- responses: {
3361
- 200: {
3362
- description: "Resource updated successfully",
3363
- content: {
3364
- "application/json": {
3365
- schema: {
3366
- type: "object",
3367
- properties: {
3368
- success: { type: "boolean", example: true },
3369
- data: schema
3370
- }
3371
- }
3372
- }
3373
- }
3374
- },
3375
- 404: {
3376
- description: "Resource not found",
3377
- content: {
3378
- "application/json": {
3379
- schema: { $ref: "#/components/schemas/Error" }
3380
- }
3381
- }
3382
- }
3383
- },
3384
- security: security.length > 0 ? security : void 0
3385
- };
3386
- }
3387
- if (methods.includes("DELETE")) {
3388
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3389
- paths[`${basePath}/{id}`].delete = {
3390
- tags: [resourceName],
3391
- summary: `Delete ${resourceName}`,
3392
- description: `Delete a ${resourceName} by ID`,
3393
- parameters: [
3394
- {
3395
- name: "id",
3396
- in: "path",
3397
- required: true,
3398
- schema: { type: "string" }
3399
- }
3400
- ],
3401
- responses: {
3402
- 204: {
3403
- description: "Resource deleted successfully"
3404
- },
3405
- 404: {
3406
- description: "Resource not found",
3407
- content: {
3408
- "application/json": {
3409
- schema: { $ref: "#/components/schemas/Error" }
3410
- }
3411
- }
3412
- }
3413
- },
3414
- security: security.length > 0 ? security : void 0
3415
- };
3416
- }
3417
- if (methods.includes("HEAD")) {
3418
- if (!paths[basePath]) paths[basePath] = {};
3419
- paths[basePath].head = {
3420
- tags: [resourceName],
3421
- summary: `Get ${resourceName} statistics`,
3422
- description: `Get statistics about ${resourceName} collection without retrieving data. Returns statistics in response headers.`,
3423
- responses: {
3424
- 200: {
3425
- description: "Statistics retrieved successfully",
3426
- headers: {
3427
- "X-Total-Count": {
3428
- description: "Total number of records",
3429
- schema: { type: "integer" }
3430
- },
3431
- "X-Resource-Version": {
3432
- description: "Current resource version",
3433
- schema: { type: "string" }
3434
- },
3435
- "X-Schema-Fields": {
3436
- description: "Number of schema fields",
3437
- schema: { type: "integer" }
3438
- }
3439
- }
3440
- }
3441
- },
3442
- security: security.length > 0 ? security : void 0
3443
- };
3444
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3445
- paths[`${basePath}/{id}`].head = {
3446
- tags: [resourceName],
3447
- summary: `Check if ${resourceName} exists`,
3448
- description: `Check if a ${resourceName} exists without retrieving its data`,
3449
- parameters: [
3450
- {
3451
- name: "id",
3452
- in: "path",
3453
- required: true,
3454
- schema: { type: "string" }
3455
- }
3456
- ],
3457
- responses: {
3458
- 200: {
3459
- description: "Resource exists",
3460
- headers: {
3461
- "Last-Modified": {
3462
- description: "Last modification date",
3463
- schema: { type: "string", format: "date-time" }
3464
- }
3465
- }
3466
- },
3467
- 404: {
3468
- description: "Resource not found"
3469
- }
3470
- },
3471
- security: security.length > 0 ? security : void 0
3472
- };
3473
- }
3474
- if (methods.includes("OPTIONS")) {
3475
- if (!paths[basePath]) paths[basePath] = {};
3476
- paths[basePath].options = {
3477
- tags: [resourceName],
3478
- summary: `Get ${resourceName} metadata`,
3479
- description: `Get complete metadata about ${resourceName} resource including schema, allowed methods, endpoints, and query parameters`,
3480
- responses: {
3481
- 200: {
3482
- description: "Metadata retrieved successfully",
3483
- headers: {
3484
- "Allow": {
3485
- description: "Allowed HTTP methods",
3486
- schema: { type: "string", example: "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS" }
3487
- }
3488
- },
3489
- content: {
3490
- "application/json": {
3491
- schema: {
3492
- type: "object",
3493
- properties: {
3494
- resource: { type: "string" },
3495
- version: { type: "string" },
3496
- totalRecords: { type: "integer" },
3497
- allowedMethods: {
3498
- type: "array",
3499
- items: { type: "string" }
3500
- },
3501
- schema: {
3502
- type: "array",
3503
- items: {
3504
- type: "object",
3505
- properties: {
3506
- name: { type: "string" },
3507
- type: { type: "string" },
3508
- rules: { type: "array", items: { type: "string" } }
3509
- }
3510
- }
3511
- },
3512
- endpoints: {
3513
- type: "object",
3514
- properties: {
3515
- list: { type: "string" },
3516
- get: { type: "string" },
3517
- create: { type: "string" },
3518
- update: { type: "string" },
3519
- delete: { type: "string" }
3520
- }
3521
- },
3522
- queryParameters: { type: "object" }
3523
- }
3524
- }
3525
- }
3526
- }
3527
- }
3528
- }
3529
- };
3530
- if (!paths[`${basePath}/{id}`]) paths[`${basePath}/{id}`] = {};
3531
- paths[`${basePath}/{id}`].options = {
3532
- tags: [resourceName],
3533
- summary: `Get allowed methods for ${resourceName} item`,
3534
- description: `Get allowed HTTP methods for individual ${resourceName} operations`,
3535
- parameters: [
3536
- {
3537
- name: "id",
3538
- in: "path",
3539
- required: true,
3540
- schema: { type: "string" }
3541
- }
3542
- ],
3543
- responses: {
3544
- 204: {
3545
- description: "Methods retrieved successfully",
3546
- headers: {
3547
- "Allow": {
3548
- description: "Allowed HTTP methods",
3549
- schema: { type: "string", example: "GET, PUT, PATCH, DELETE, HEAD, OPTIONS" }
3550
- }
3551
- }
3552
- }
3553
- }
3554
- };
3555
- }
3556
- return paths;
3557
- }
3558
- function generateRelationalPaths(resource, relationName, relationConfig, version, relatedSchema) {
3559
- const resourceName = resource.name;
3560
- const basePath = `/${version}/${resourceName}/{id}/${relationName}`;
3561
- relationConfig.resource;
3562
- const isToMany = relationConfig.type === "hasMany" || relationConfig.type === "belongsToMany";
3563
- const paths = {};
3564
- paths[basePath] = {
3565
- get: {
3566
- tags: [resourceName],
3567
- summary: `Get ${relationName} of ${resourceName}`,
3568
- description: `Retrieve ${relationName} (${relationConfig.type}) associated with this ${resourceName}. This endpoint uses the RelationPlugin to efficiently load related data` + (relationConfig.partitionHint ? ` via the '${relationConfig.partitionHint}' partition.` : "."),
3569
- parameters: [
3570
- {
3571
- name: "id",
3572
- in: "path",
3573
- required: true,
3574
- description: `${resourceName} ID`,
3575
- schema: { type: "string" }
3576
- },
3577
- ...isToMany ? [
3578
- {
3579
- name: "limit",
3580
- in: "query",
3581
- description: "Maximum number of items to return",
3582
- schema: { type: "integer", default: 100, minimum: 1, maximum: 1e3 }
3583
- },
3584
- {
3585
- name: "offset",
3586
- in: "query",
3587
- description: "Number of items to skip",
3588
- schema: { type: "integer", default: 0, minimum: 0 }
3589
- }
3590
- ] : []
3591
- ],
3592
- responses: {
3593
- 200: {
3594
- description: "Successful response",
3595
- content: {
3596
- "application/json": {
3597
- schema: isToMany ? {
3598
- type: "object",
3599
- properties: {
3600
- success: { type: "boolean", example: true },
3601
- data: {
3602
- type: "array",
3603
- items: relatedSchema
3604
- },
3605
- pagination: {
3606
- type: "object",
3607
- properties: {
3608
- total: { type: "integer" },
3609
- page: { type: "integer" },
3610
- pageSize: { type: "integer" },
3611
- pageCount: { type: "integer" }
3612
- }
3613
- }
3614
- }
3615
- } : {
3616
- type: "object",
3617
- properties: {
3618
- success: { type: "boolean", example: true },
3619
- data: relatedSchema
3620
- }
3621
- }
3622
- }
3623
- },
3624
- ...isToMany ? {
3625
- headers: {
3626
- "X-Total-Count": {
3627
- description: "Total number of related records",
3628
- schema: { type: "integer" }
3629
- },
3630
- "X-Page-Count": {
3631
- description: "Total number of pages",
3632
- schema: { type: "integer" }
3633
- }
3634
- }
3635
- } : {}
3636
- },
3637
- 404: {
3638
- description: `${resourceName} not found` + (isToMany ? "" : " or no related resource exists"),
3639
- content: {
3640
- "application/json": {
3641
- schema: { $ref: "#/components/schemas/Error" }
3642
- }
3643
- }
3644
- }
3645
- }
3646
- }
3647
- };
3648
- return paths;
3649
- }
3650
- function generateOpenAPISpec(database, config = {}) {
3651
- const {
3652
- title = "s3db.js API",
3653
- version = "1.0.0",
3654
- description = "Auto-generated REST API documentation for s3db.js resources",
3655
- serverUrl = "http://localhost:3000",
3656
- auth = {},
3657
- resources: resourceConfigs = {}
3658
- } = config;
3659
- const resourcesTableRows = [];
3660
- for (const [name, resource] of Object.entries(database.resources)) {
3661
- if (name.startsWith("plg_") && !resourceConfigs[name]) {
3662
- continue;
3663
- }
3664
- const version2 = resource.config?.currentVersion || resource.version || "v1";
3665
- const resourceDescription = resource.config?.description;
3666
- const descText = typeof resourceDescription === "object" ? resourceDescription.resource : resourceDescription || "No description";
3667
- resourcesTableRows.push(`| ${name} | ${descText} | \`/${version2}/${name}\` |`);
3668
- }
3669
- const enhancedDescription = `${description}
3670
-
3671
- ## Available Resources
3672
-
3673
- | Resource | Description | Base Path |
3674
- |----------|-------------|-----------|
3675
- ${resourcesTableRows.join("\n")}
3676
-
3677
- ---
3678
-
3679
- For detailed information about each endpoint, see the sections below.`;
3680
- const spec = {
3681
- openapi: "3.1.0",
3682
- info: {
3683
- title,
3684
- version,
3685
- description: enhancedDescription,
3686
- contact: {
3687
- name: "s3db.js",
3688
- url: "https://github.com/forattini-dev/s3db.js"
3689
- }
3690
- },
3691
- servers: [
3692
- {
3693
- url: serverUrl,
3694
- description: "API Server"
3695
- }
3696
- ],
3697
- paths: {},
3698
- components: {
3699
- schemas: {
3700
- Error: {
3701
- type: "object",
3702
- properties: {
3703
- success: { type: "boolean", example: false },
3704
- error: {
3705
- type: "object",
3706
- properties: {
3707
- message: { type: "string" },
3708
- code: { type: "string" },
3709
- details: { type: "object" }
3710
- }
3711
- }
3712
- }
3713
- },
3714
- ValidationError: {
3715
- type: "object",
3716
- properties: {
3717
- success: { type: "boolean", example: false },
3718
- error: {
3719
- type: "object",
3720
- properties: {
3721
- message: { type: "string", example: "Validation failed" },
3722
- code: { type: "string", example: "VALIDATION_ERROR" },
3723
- details: {
3724
- type: "object",
3725
- properties: {
3726
- errors: {
3727
- type: "array",
3728
- items: {
3729
- type: "object",
3730
- properties: {
3731
- field: { type: "string" },
3732
- message: { type: "string" },
3733
- expected: { type: "string" },
3734
- actual: {}
3735
- }
3736
- }
3737
- }
3738
- }
3739
- }
3740
- }
3741
- }
3742
- }
3743
- }
3744
- },
3745
- securitySchemes: {}
3746
- },
3747
- tags: []
3748
- };
3749
- if (auth.jwt?.enabled) {
3750
- spec.components.securitySchemes.bearerAuth = {
3751
- type: "http",
3752
- scheme: "bearer",
3753
- bearerFormat: "JWT",
3754
- description: "JWT authentication"
3755
- };
3756
- }
3757
- if (auth.apiKey?.enabled) {
3758
- spec.components.securitySchemes.apiKeyAuth = {
3759
- type: "apiKey",
3760
- in: "header",
3761
- name: auth.apiKey.headerName || "X-API-Key",
3762
- description: "API Key authentication"
3763
- };
3764
- }
3765
- if (auth.basic?.enabled) {
3766
- spec.components.securitySchemes.basicAuth = {
3767
- type: "http",
3768
- scheme: "basic",
3769
- description: "HTTP Basic authentication"
3770
- };
3771
- }
3772
- const resources = database.resources;
3773
- const relationsPlugin = database.plugins?.relation || database.plugins?.RelationPlugin || null;
3774
- for (const [name, resource] of Object.entries(resources)) {
3775
- if (name.startsWith("plg_") && !resourceConfigs[name]) {
3776
- continue;
3777
- }
3778
- const config2 = resourceConfigs[name] || {
3779
- methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
3780
- auth: false
3781
- };
3782
- const version2 = resource.config?.currentVersion || resource.version || "v1";
3783
- const paths = generateResourcePaths(resource, version2, config2);
3784
- Object.assign(spec.paths, paths);
3785
- const resourceDescription = resource.config?.description;
3786
- const tagDescription = typeof resourceDescription === "object" ? resourceDescription.resource : resourceDescription || `Operations for ${name} resource`;
3787
- spec.tags.push({
3788
- name,
3789
- description: tagDescription
3790
- });
3791
- spec.components.schemas[name] = generateResourceSchema(resource);
3792
- if (relationsPlugin && relationsPlugin.relations && relationsPlugin.relations[name]) {
3793
- const relationsDef = relationsPlugin.relations[name];
3794
- for (const [relationName, relationConfig] of Object.entries(relationsDef)) {
3795
- if (relationConfig.type === "belongsTo") {
3796
- continue;
3797
- }
3798
- const exposeRelation = config2?.relations?.[relationName]?.expose !== false;
3799
- if (!exposeRelation) {
3800
- continue;
3801
- }
3802
- const relatedResource = database.resources[relationConfig.resource];
3803
- if (!relatedResource) {
3804
- continue;
3805
- }
3806
- const relatedSchema = generateResourceSchema(relatedResource);
3807
- const relationalPaths = generateRelationalPaths(
3808
- resource,
3809
- relationName,
3810
- relationConfig,
3811
- version2,
3812
- relatedSchema
3813
- );
3814
- Object.assign(spec.paths, relationalPaths);
3815
- }
3816
- }
3817
- }
3818
- if (auth.jwt?.enabled || auth.apiKey?.enabled || auth.basic?.enabled) {
3819
- spec.paths["/auth/login"] = {
3820
- post: {
3821
- tags: ["Authentication"],
3822
- summary: "Login",
3823
- description: "Authenticate with username and password",
3824
- requestBody: {
3825
- required: true,
3826
- content: {
3827
- "application/json": {
3828
- schema: {
3829
- type: "object",
3830
- properties: {
3831
- username: { type: "string" },
3832
- password: { type: "string", format: "password" }
3833
- },
3834
- required: ["username", "password"]
3835
- }
3836
- }
3837
- }
3838
- },
3839
- responses: {
3840
- 200: {
3841
- description: "Login successful",
3842
- content: {
3843
- "application/json": {
3844
- schema: {
3845
- type: "object",
3846
- properties: {
3847
- success: { type: "boolean", example: true },
3848
- data: {
3849
- type: "object",
3850
- properties: {
3851
- token: { type: "string" },
3852
- user: { type: "object" }
3853
- }
3854
- }
3855
- }
3856
- }
3857
- }
3858
- }
3859
- },
3860
- 401: {
3861
- description: "Invalid credentials",
3862
- content: {
3863
- "application/json": {
3864
- schema: { $ref: "#/components/schemas/Error" }
3865
- }
3866
- }
3867
- }
3868
- }
3869
- }
3870
- };
3871
- spec.paths["/auth/register"] = {
3872
- post: {
3873
- tags: ["Authentication"],
3874
- summary: "Register",
3875
- description: "Register a new user",
3876
- requestBody: {
3877
- required: true,
3878
- content: {
3879
- "application/json": {
3880
- schema: {
3881
- type: "object",
3882
- properties: {
3883
- username: { type: "string", minLength: 3 },
3884
- password: { type: "string", format: "password", minLength: 8 },
3885
- email: { type: "string", format: "email" }
3886
- },
3887
- required: ["username", "password"]
3888
- }
3889
- }
3890
- }
3891
- },
3892
- responses: {
3893
- 201: {
3894
- description: "User registered successfully",
3895
- content: {
3896
- "application/json": {
3897
- schema: {
3898
- type: "object",
3899
- properties: {
3900
- success: { type: "boolean", example: true },
3901
- data: {
3902
- type: "object",
3903
- properties: {
3904
- token: { type: "string" },
3905
- user: { type: "object" }
3906
- }
3907
- }
3908
- }
3909
- }
3910
- }
3911
- }
3912
- }
3913
- }
3914
- }
3915
- };
3916
- spec.tags.push({
3917
- name: "Authentication",
3918
- description: "Authentication endpoints"
3919
- });
3920
- }
3921
- spec.paths["/health"] = {
3922
- get: {
3923
- tags: ["Health"],
3924
- summary: "Generic Health Check",
3925
- description: "Generic health check endpoint that includes references to liveness and readiness probes",
3926
- responses: {
3927
- 200: {
3928
- description: "API is healthy",
3929
- content: {
3930
- "application/json": {
3931
- schema: {
3932
- type: "object",
3933
- properties: {
3934
- success: { type: "boolean", example: true },
3935
- data: {
3936
- type: "object",
3937
- properties: {
3938
- status: { type: "string", example: "ok" },
3939
- uptime: { type: "number", description: "Process uptime in seconds" },
3940
- timestamp: { type: "string", format: "date-time" },
3941
- checks: {
3942
- type: "object",
3943
- properties: {
3944
- liveness: { type: "string", example: "/health/live" },
3945
- readiness: { type: "string", example: "/health/ready" }
3946
- }
3947
- }
3948
- }
3949
- }
3950
- }
3951
- }
3952
- }
3953
- }
3954
- }
3955
- }
3956
- }
3957
- };
3958
- spec.paths["/health/live"] = {
3959
- get: {
3960
- tags: ["Health"],
3961
- summary: "Liveness Probe",
3962
- description: "Kubernetes liveness probe - checks if the application is alive. If this fails, Kubernetes will restart the pod.",
3963
- responses: {
3964
- 200: {
3965
- description: "Application is alive",
3966
- content: {
3967
- "application/json": {
3968
- schema: {
3969
- type: "object",
3970
- properties: {
3971
- success: { type: "boolean", example: true },
3972
- data: {
3973
- type: "object",
3974
- properties: {
3975
- status: { type: "string", example: "alive" },
3976
- timestamp: { type: "string", format: "date-time" }
3977
- }
3978
- }
3979
- }
3980
- }
3981
- }
3982
- }
3983
- }
3984
- }
3985
- }
3986
- };
3987
- spec.paths["/health/ready"] = {
3988
- get: {
3989
- tags: ["Health"],
3990
- summary: "Readiness Probe",
3991
- description: "Kubernetes readiness probe - checks if the application is ready to receive traffic. If this fails, Kubernetes will remove the pod from service endpoints.",
3992
- responses: {
3993
- 200: {
3994
- description: "Application is ready to receive traffic",
3995
- content: {
3996
- "application/json": {
3997
- schema: {
3998
- type: "object",
3999
- properties: {
4000
- success: { type: "boolean", example: true },
4001
- data: {
4002
- type: "object",
4003
- properties: {
4004
- status: { type: "string", example: "ready" },
4005
- database: {
4006
- type: "object",
4007
- properties: {
4008
- connected: { type: "boolean", example: true },
4009
- resources: { type: "integer", example: 5 }
4010
- }
4011
- },
4012
- timestamp: { type: "string", format: "date-time" }
4013
- }
4014
- }
4015
- }
4016
- }
4017
- }
4018
- }
4019
- },
4020
- 503: {
4021
- description: "Application is not ready",
4022
- content: {
4023
- "application/json": {
4024
- schema: {
4025
- type: "object",
4026
- properties: {
4027
- success: { type: "boolean", example: false },
4028
- error: {
4029
- type: "object",
4030
- properties: {
4031
- message: { type: "string", example: "Service not ready" },
4032
- code: { type: "string", example: "NOT_READY" },
4033
- details: {
4034
- type: "object",
4035
- properties: {
4036
- database: {
4037
- type: "object",
4038
- properties: {
4039
- connected: { type: "boolean", example: false },
4040
- resources: { type: "integer", example: 0 }
4041
- }
4042
- }
4043
- }
4044
- }
4045
- }
4046
- }
4047
- }
4048
- }
4049
- }
4050
- }
4051
- }
4052
- }
4053
- }
4054
- };
4055
- spec.tags.push({
4056
- name: "Health",
4057
- description: "Health check endpoints for monitoring and Kubernetes probes"
4058
- });
4059
- const metricsPlugin = database.plugins?.metrics || database.plugins?.MetricsPlugin;
4060
- if (metricsPlugin && metricsPlugin.config?.prometheus?.enabled) {
4061
- const metricsPath = metricsPlugin.config.prometheus.path || "/metrics";
4062
- const isIntegrated = metricsPlugin.config.prometheus.mode !== "standalone";
4063
- if (isIntegrated) {
4064
- spec.paths[metricsPath] = {
4065
- get: {
4066
- tags: ["Monitoring"],
4067
- summary: "Prometheus Metrics",
4068
- description: "Exposes application metrics in Prometheus text-based exposition format for monitoring and observability. Metrics include operation counts, durations, errors, uptime, and resource statistics.",
4069
- responses: {
4070
- 200: {
4071
- description: "Metrics in Prometheus format",
4072
- content: {
4073
- "text/plain": {
4074
- schema: {
4075
- type: "string",
4076
- example: '# HELP s3db_operations_total Total number of operations by type and resource\n# TYPE s3db_operations_total counter\ns3db_operations_total{operation="insert",resource="cars"} 1523\ns3db_operations_total{operation="update",resource="cars"} 342\n\n# HELP s3db_operation_duration_seconds Average operation duration in seconds\n# TYPE s3db_operation_duration_seconds gauge\ns3db_operation_duration_seconds{operation="insert",resource="cars"} 0.045\n\n# HELP s3db_operation_errors_total Total number of operation errors\n# TYPE s3db_operation_errors_total counter\ns3db_operation_errors_total{operation="insert",resource="cars"} 12\n'
4077
- }
4078
- }
4079
- }
4080
- }
4081
- }
4082
- }
4083
- };
4084
- spec.tags.push({
4085
- name: "Monitoring",
4086
- description: "Monitoring and observability endpoints (Prometheus)"
4087
- });
4088
- }
4089
- }
4090
- return spec;
4091
- }
4092
-
4093
- class ApiServer {
4094
- /**
4095
- * Create API server
4096
- * @param {Object} options - Server options
4097
- * @param {number} options.port - Server port
4098
- * @param {string} options.host - Server host
4099
- * @param {Object} options.database - s3db.js database instance
4100
- * @param {Object} options.resources - Resource configuration
4101
- * @param {Array} options.middlewares - Global middlewares
4102
- */
4103
- constructor(options = {}) {
4104
- this.options = {
4105
- port: options.port || 3e3,
4106
- host: options.host || "0.0.0.0",
4107
- database: options.database,
4108
- resources: options.resources || {},
4109
- middlewares: options.middlewares || [],
4110
- verbose: options.verbose || false,
4111
- auth: options.auth || {},
4112
- docsEnabled: options.docsEnabled !== false,
4113
- // Enable /docs by default
4114
- docsUI: options.docsUI || "redoc",
4115
- // 'swagger' or 'redoc'
4116
- maxBodySize: options.maxBodySize || 10 * 1024 * 1024,
4117
- // 10MB default
4118
- rootHandler: options.rootHandler,
4119
- // Custom handler for root path, if not provided redirects to /docs
4120
- apiInfo: {
4121
- title: options.apiTitle || "s3db.js API",
4122
- version: options.apiVersion || "1.0.0",
4123
- description: options.apiDescription || "Auto-generated REST API for s3db.js resources"
4124
- }
4125
- };
4126
- this.app = new Hono();
4127
- this.server = null;
4128
- this.isRunning = false;
4129
- this.openAPISpec = null;
4130
- this.relationsPlugin = this.options.database?.plugins?.relation || this.options.database?.plugins?.RelationPlugin || null;
4131
- this._setupRoutes();
4132
- }
4133
- /**
4134
- * Setup all routes
4135
- * @private
4136
- */
4137
- _setupRoutes() {
4138
- this.options.middlewares.forEach((middleware) => {
4139
- this.app.use("*", middleware);
4140
- });
4141
- this.app.use("*", async (c, next) => {
4142
- const method = c.req.method;
4143
- if (["POST", "PUT", "PATCH"].includes(method)) {
4144
- const contentLength = c.req.header("content-length");
4145
- if (contentLength) {
4146
- const size = parseInt(contentLength);
4147
- if (size > this.options.maxBodySize) {
4148
- const response = payloadTooLarge(size, this.options.maxBodySize);
4149
- c.header("Connection", "close");
4150
- return c.json(response, response._status);
4151
- }
4152
- }
4153
- }
4154
- await next();
4155
- });
4156
- this.app.get("/health/live", (c) => {
4157
- const response = success({
4158
- status: "alive",
4159
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
4160
- });
4161
- return c.json(response);
4162
- });
4163
- this.app.get("/health/ready", (c) => {
4164
- const isReady = this.options.database && this.options.database.connected && Object.keys(this.options.database.resources).length > 0;
4165
- if (!isReady) {
4166
- const response2 = error("Service not ready", {
4167
- status: 503,
4168
- code: "NOT_READY",
4169
- details: {
4170
- database: {
4171
- connected: this.options.database?.connected || false,
4172
- resources: Object.keys(this.options.database?.resources || {}).length
4173
- }
4174
- }
4175
- });
4176
- return c.json(response2, 503);
4177
- }
4178
- const response = success({
4179
- status: "ready",
4180
- database: {
4181
- connected: true,
4182
- resources: Object.keys(this.options.database.resources).length
4183
- },
4184
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
4185
- });
4186
- return c.json(response);
4187
- });
4188
- this.app.get("/health", (c) => {
4189
- const response = success({
4190
- status: "ok",
4191
- uptime: process.uptime(),
4192
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4193
- checks: {
4194
- liveness: "/health/live",
4195
- readiness: "/health/ready"
4196
- }
4197
- });
4198
- return c.json(response);
4199
- });
4200
- this.app.get("/", (c) => {
4201
- if (this.options.rootHandler) {
4202
- return this.options.rootHandler(c);
4203
- }
4204
- return c.redirect("/docs", 302);
4205
- });
4206
- if (this.options.docsEnabled) {
4207
- this.app.get("/openapi.json", (c) => {
4208
- if (!this.openAPISpec) {
4209
- this.openAPISpec = this._generateOpenAPISpec();
4210
- }
4211
- return c.json(this.openAPISpec);
4212
- });
4213
- if (this.options.docsUI === "swagger") {
4214
- this.app.get("/docs", swaggerUI({
4215
- url: "/openapi.json"
4216
- }));
4217
- } else {
4218
- this.app.get("/docs", (c) => {
4219
- return c.html(`<!DOCTYPE html>
4220
- <html lang="en">
4221
- <head>
4222
- <meta charset="UTF-8">
4223
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
4224
- <title>${this.options.apiInfo.title} - API Documentation</title>
4225
- <style>
4226
- body {
4227
- margin: 0;
4228
- padding: 0;
4229
- }
4230
- </style>
4231
- </head>
4232
- <body>
4233
- <redoc spec-url="/openapi.json"></redoc>
4234
- <script src="https://cdn.redoc.ly/redoc/v2.5.1/bundles/redoc.standalone.js"><\/script>
4235
- </body>
4236
- </html>`);
4237
- });
4238
- }
4239
- }
4240
- this._setupResourceRoutes();
4241
- if (this.relationsPlugin) {
4242
- this._setupRelationalRoutes();
4243
- }
4244
- this.app.onError((err, c) => {
4245
- return errorHandler(err, c);
4246
- });
4247
- this.app.notFound((c) => {
4248
- const response = error("Route not found", {
4249
- status: 404,
4250
- code: "NOT_FOUND",
4251
- details: {
4252
- path: c.req.path,
4253
- method: c.req.method
4254
- }
4255
- });
4256
- return c.json(response, 404);
4257
- });
4258
- }
4259
- /**
4260
- * Setup routes for all resources
4261
- * @private
4262
- */
4263
- _setupResourceRoutes() {
4264
- const { database, resources: resourceConfigs } = this.options;
4265
- const resources = database.resources;
4266
- for (const [name, resource] of Object.entries(resources)) {
4267
- if (name.startsWith("plg_") && !resourceConfigs[name]) {
4268
- continue;
4269
- }
4270
- const config = resourceConfigs[name] || {
4271
- methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]};
4272
- const version = resource.config?.currentVersion || resource.version || "v1";
4273
- const resourceApp = createResourceRoutes(resource, version, {
4274
- methods: config.methods,
4275
- customMiddleware: config.customMiddleware || [],
4276
- enableValidation: config.validation !== false
4277
- });
4278
- this.app.route(`/${version}/${name}`, resourceApp);
4279
- if (this.options.verbose) {
4280
- console.log(`[API Plugin] Mounted routes for resource '${name}' at /${version}/${name}`);
4281
- }
4282
- }
4283
- }
4284
- /**
4285
- * Setup relational routes (when RelationPlugin is active)
4286
- * @private
4287
- */
4288
- _setupRelationalRoutes() {
4289
- if (!this.relationsPlugin || !this.relationsPlugin.relations) {
4290
- return;
4291
- }
4292
- const { database } = this.options;
4293
- const relations = this.relationsPlugin.relations;
4294
- if (this.options.verbose) {
4295
- console.log("[API Plugin] Setting up relational routes...");
4296
- }
4297
- for (const [resourceName, relationsDef] of Object.entries(relations)) {
4298
- const resource = database.resources[resourceName];
4299
- if (!resource) {
4300
- if (this.options.verbose) {
4301
- console.warn(`[API Plugin] Resource '${resourceName}' not found for relational routes`);
4302
- }
4303
- continue;
4304
- }
4305
- if (resourceName.startsWith("plg_") && !this.options.resources[resourceName]) {
4306
- continue;
4307
- }
4308
- const version = resource.config?.currentVersion || resource.version || "v1";
4309
- for (const [relationName, relationConfig] of Object.entries(relationsDef)) {
4310
- if (relationConfig.type === "belongsTo") {
4311
- continue;
4312
- }
4313
- const resourceConfig = this.options.resources[resourceName];
4314
- const exposeRelation = resourceConfig?.relations?.[relationName]?.expose !== false;
4315
- if (!exposeRelation) {
4316
- continue;
4317
- }
4318
- const relationalApp = createRelationalRoutes(
4319
- resource,
4320
- relationName,
4321
- relationConfig);
4322
- this.app.route(`/${version}/${resourceName}/:id/${relationName}`, relationalApp);
4323
- if (this.options.verbose) {
4324
- console.log(
4325
- `[API Plugin] Mounted relational route: /${version}/${resourceName}/:id/${relationName} (${relationConfig.type} -> ${relationConfig.resource})`
4326
- );
4327
- }
4328
- }
4329
- }
4330
- }
4331
- /**
4332
- * Start the server
4333
- * @returns {Promise<void>}
4334
- */
4335
- async start() {
4336
- if (this.isRunning) {
4337
- console.warn("[API Plugin] Server is already running");
4338
- return;
4339
- }
4340
- const { port, host } = this.options;
4341
- return new Promise((resolve, reject) => {
4342
- try {
4343
- this.server = serve({
4344
- fetch: this.app.fetch,
4345
- port,
4346
- hostname: host
4347
- }, (info) => {
4348
- this.isRunning = true;
4349
- console.log(`[API Plugin] Server listening on http://${info.address}:${info.port}`);
4350
- resolve();
4351
- });
4352
- } catch (err) {
4353
- reject(err);
4354
- }
4355
- });
4356
- }
4357
- /**
4358
- * Stop the server
4359
- * @returns {Promise<void>}
4360
- */
4361
- async stop() {
4362
- if (!this.isRunning) {
4363
- console.warn("[API Plugin] Server is not running");
4364
- return;
4365
- }
4366
- if (this.server && typeof this.server.close === "function") {
4367
- await new Promise((resolve) => {
4368
- this.server.close(() => {
4369
- this.isRunning = false;
4370
- console.log("[API Plugin] Server stopped");
4371
- resolve();
4372
- });
4373
- });
4374
- } else {
4375
- this.isRunning = false;
4376
- console.log("[API Plugin] Server stopped");
4377
- }
4378
- }
4379
- /**
4380
- * Get server info
4381
- * @returns {Object} Server information
4382
- */
4383
- getInfo() {
4384
- return {
4385
- isRunning: this.isRunning,
4386
- port: this.options.port,
4387
- host: this.options.host,
4388
- resources: Object.keys(this.options.database.resources).length
4389
- };
4390
- }
4391
- /**
4392
- * Get Hono app instance
4393
- * @returns {Hono} Hono app
4394
- */
4395
- getApp() {
4396
- return this.app;
4397
- }
4398
- /**
4399
- * Generate OpenAPI specification
4400
- * @private
4401
- * @returns {Object} OpenAPI spec
4402
- */
4403
- _generateOpenAPISpec() {
4404
- const { port, host, database, resources, auth, apiInfo } = this.options;
4405
- return generateOpenAPISpec(database, {
4406
- title: apiInfo.title,
4407
- version: apiInfo.version,
4408
- description: apiInfo.description,
4409
- serverUrl: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`,
4410
- auth,
4411
- resources
4412
- });
4413
- }
4414
- }
4415
-
4416
2451
  const PLUGIN_DEPENDENCIES = {
4417
2452
  "postgresql-replicator": {
4418
2453
  name: "PostgreSQL Replicator",
@@ -4973,6 +3008,11 @@ class ApiPlugin extends Plugin {
4973
3008
  if (this.config.verbose) {
4974
3009
  console.log("[API Plugin] Starting server...");
4975
3010
  }
3011
+ const serverPath = "./server.js";
3012
+ const { ApiServer } = await import(
3013
+ /* @vite-ignore */
3014
+ serverPath
3015
+ );
4976
3016
  this.server = new ApiServer({
4977
3017
  port: this.config.port,
4978
3018
  host: this.config.host,
@@ -22391,7 +20431,7 @@ class Database extends EventEmitter {
22391
20431
  this.id = idGenerator(7);
22392
20432
  this.version = "1";
22393
20433
  this.s3dbVersion = (() => {
22394
- const [ok, err, version] = tryFn(() => true ? "12.2.0" : "latest");
20434
+ const [ok, err, version] = tryFn(() => true ? "12.2.1" : "latest");
22395
20435
  return ok ? version : "latest";
22396
20436
  })();
22397
20437
  this._resourcesMap = {};