@btst/stack 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/api/index.cjs +7 -1
  2. package/dist/api/index.d.cts +2 -2
  3. package/dist/api/index.d.mts +2 -2
  4. package/dist/api/index.d.ts +2 -2
  5. package/dist/api/index.mjs +7 -1
  6. package/dist/client/index.d.cts +1 -1
  7. package/dist/client/index.d.mts +1 -1
  8. package/dist/client/index.d.ts +1 -1
  9. package/dist/index.d.cts +1 -1
  10. package/dist/index.d.mts +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/packages/better-stack/src/plugins/open-api/api/generator.cjs +300 -0
  13. package/dist/packages/better-stack/src/plugins/open-api/api/generator.mjs +284 -0
  14. package/dist/packages/better-stack/src/plugins/open-api/api/plugin.cjs +115 -0
  15. package/dist/packages/better-stack/src/plugins/open-api/api/plugin.mjs +113 -0
  16. package/dist/packages/better-stack/src/plugins/open-api/db.cjs +7 -0
  17. package/dist/packages/better-stack/src/plugins/open-api/db.mjs +5 -0
  18. package/dist/packages/better-stack/src/plugins/open-api/logo.cjs +8 -0
  19. package/dist/packages/better-stack/src/plugins/open-api/logo.mjs +6 -0
  20. package/dist/plugins/api/index.d.cts +2 -2
  21. package/dist/plugins/api/index.d.mts +2 -2
  22. package/dist/plugins/api/index.d.ts +2 -2
  23. package/dist/plugins/client/index.d.cts +2 -2
  24. package/dist/plugins/client/index.d.mts +2 -2
  25. package/dist/plugins/client/index.d.ts +2 -2
  26. package/dist/plugins/open-api/api/index.cjs +9 -0
  27. package/dist/plugins/open-api/api/index.d.cts +95 -0
  28. package/dist/plugins/open-api/api/index.d.mts +95 -0
  29. package/dist/plugins/open-api/api/index.d.ts +95 -0
  30. package/dist/plugins/open-api/api/index.mjs +2 -0
  31. package/dist/shared/{stack.ByOugz9d.d.cts → stack.CSce37mX.d.cts} +15 -2
  32. package/dist/shared/{stack.ByOugz9d.d.mts → stack.CSce37mX.d.mts} +15 -2
  33. package/dist/shared/{stack.ByOugz9d.d.ts → stack.CSce37mX.d.ts} +15 -2
  34. package/package.json +14 -1
  35. package/src/api/index.ts +14 -2
  36. package/src/plugins/open-api/api/generator.ts +433 -0
  37. package/src/plugins/open-api/api/index.ts +8 -0
  38. package/src/plugins/open-api/api/plugin.ts +243 -0
  39. package/src/plugins/open-api/db.ts +7 -0
  40. package/src/plugins/open-api/logo.ts +7 -0
  41. package/src/types.ts +15 -1
@@ -2,6 +2,18 @@ import { Route, createRouter } from '@btst/yar';
2
2
  import { Adapter, DbPlugin, DatabaseDefinition } from '@btst/db';
3
3
  import { Endpoint, Router } from 'better-call';
4
4
 
5
+ /**
6
+ * Context passed to backend plugins during route creation
7
+ * Provides access to all registered plugins for introspection (used by openAPI plugin)
8
+ */
9
+ interface BetterStackContext {
10
+ /** All registered backend plugins */
11
+ plugins: Record<string, BackendPlugin<any>>;
12
+ /** The API base path (e.g., "/api/data") */
13
+ basePath: string;
14
+ /** The database adapter */
15
+ adapter: Adapter;
16
+ }
5
17
  /**
6
18
  * Backend plugin definition
7
19
  * Defines API routes and data access for a feature
@@ -20,8 +32,9 @@ interface BackendPlugin<TRoutes extends Record<string, Endpoint> = Record<string
20
32
  *
21
33
  * @param adapter - Better DB adapter instance with methods:
22
34
  * create, update, updateMany, delete, deleteMany, findOne, findMany, count
35
+ * @param context - Optional context with access to all plugins (for introspection)
23
36
  */
24
- routes: (adapter: Adapter) => TRoutes;
37
+ routes: (adapter: Adapter, context?: BetterStackContext) => TRoutes;
25
38
  dbPlugin: DbPlugin;
26
39
  }
27
40
  /**
@@ -129,4 +142,4 @@ type SitemapEntry = {
129
142
  };
130
143
  type Sitemap = Array<SitemapEntry>;
131
144
 
132
- export type { BackendPlugin as B, ClientPlugin as C, PluginOverrides as P, Sitemap as S, PluginRoutes as a, ClientLibConfig as b, ClientLib as c, PrefixedPluginRoutes as d, BackendLibConfig as e, BackendLib as f };
145
+ export type { BackendPlugin as B, ClientPlugin as C, PluginOverrides as P, Sitemap as S, BetterStackContext as a, PluginRoutes as b, ClientLibConfig as c, ClientLib as d, PrefixedPluginRoutes as e, BackendLibConfig as f, BackendLib as g };
@@ -2,6 +2,18 @@ import { Route, createRouter } from '@btst/yar';
2
2
  import { Adapter, DbPlugin, DatabaseDefinition } from '@btst/db';
3
3
  import { Endpoint, Router } from 'better-call';
4
4
 
5
+ /**
6
+ * Context passed to backend plugins during route creation
7
+ * Provides access to all registered plugins for introspection (used by openAPI plugin)
8
+ */
9
+ interface BetterStackContext {
10
+ /** All registered backend plugins */
11
+ plugins: Record<string, BackendPlugin<any>>;
12
+ /** The API base path (e.g., "/api/data") */
13
+ basePath: string;
14
+ /** The database adapter */
15
+ adapter: Adapter;
16
+ }
5
17
  /**
6
18
  * Backend plugin definition
7
19
  * Defines API routes and data access for a feature
@@ -20,8 +32,9 @@ interface BackendPlugin<TRoutes extends Record<string, Endpoint> = Record<string
20
32
  *
21
33
  * @param adapter - Better DB adapter instance with methods:
22
34
  * create, update, updateMany, delete, deleteMany, findOne, findMany, count
35
+ * @param context - Optional context with access to all plugins (for introspection)
23
36
  */
24
- routes: (adapter: Adapter) => TRoutes;
37
+ routes: (adapter: Adapter, context?: BetterStackContext) => TRoutes;
25
38
  dbPlugin: DbPlugin;
26
39
  }
27
40
  /**
@@ -129,4 +142,4 @@ type SitemapEntry = {
129
142
  };
130
143
  type Sitemap = Array<SitemapEntry>;
131
144
 
132
- export type { BackendPlugin as B, ClientPlugin as C, PluginOverrides as P, Sitemap as S, PluginRoutes as a, ClientLibConfig as b, ClientLib as c, PrefixedPluginRoutes as d, BackendLibConfig as e, BackendLib as f };
145
+ export type { BackendPlugin as B, ClientPlugin as C, PluginOverrides as P, Sitemap as S, BetterStackContext as a, PluginRoutes as b, ClientLibConfig as c, ClientLib as d, PrefixedPluginRoutes as e, BackendLibConfig as f, BackendLib as g };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btst/stack",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "A composable, plugin-based library for building full-stack applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -247,6 +247,16 @@
247
247
  }
248
248
  },
249
249
  "./plugins/form-builder/css": "./dist/plugins/form-builder/style.css",
250
+ "./plugins/open-api/api": {
251
+ "import": {
252
+ "types": "./dist/plugins/open-api/api/index.d.ts",
253
+ "default": "./dist/plugins/open-api/api/index.mjs"
254
+ },
255
+ "require": {
256
+ "types": "./dist/plugins/open-api/api/index.d.cts",
257
+ "default": "./dist/plugins/open-api/api/index.cjs"
258
+ }
259
+ },
250
260
  "./dist/*": "./dist/*",
251
261
  "./ui/css": "./dist/ui/components.css",
252
262
  "./package.json": "./package.json"
@@ -312,6 +322,9 @@
312
322
  ],
313
323
  "plugins/form-builder/client/hooks": [
314
324
  "./dist/plugins/form-builder/client/hooks/index.d.ts"
325
+ ],
326
+ "plugins/open-api/api": [
327
+ "./dist/plugins/open-api/api/index.d.ts"
315
328
  ]
316
329
  }
317
330
  },
package/src/api/index.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  BackendLibConfig,
4
4
  BackendLib,
5
5
  PrefixedPluginRoutes,
6
+ BetterStackContext,
6
7
  } from "../types";
7
8
  import { defineDb } from "@btst/db";
8
9
 
@@ -45,9 +46,19 @@ export function betterStack<
45
46
  betterDbSchema = betterDbSchema.use(plugin.dbPlugin);
46
47
  }
47
48
 
49
+ // Create the adapter instance once
50
+ const adapterInstance = adapter(betterDbSchema);
51
+
52
+ // Create context for plugins that need access to all plugins (e.g., openAPI)
53
+ const context: BetterStackContext = {
54
+ plugins,
55
+ basePath,
56
+ adapter: adapterInstance,
57
+ };
58
+
48
59
  for (const [pluginKey, plugin] of Object.entries(plugins)) {
49
- // Pass the adapter directly to plugin routes
50
- const pluginRoutes = plugin.routes(adapter(betterDbSchema));
60
+ // Pass both adapter and context to plugin routes
61
+ const pluginRoutes = plugin.routes(adapterInstance, context);
51
62
 
52
63
  // Prefix route keys with plugin name to avoid collisions
53
64
  for (const [routeKey, endpoint] of Object.entries(pluginRoutes)) {
@@ -72,4 +83,5 @@ export type {
72
83
  BackendPlugin,
73
84
  BackendLibConfig,
74
85
  BackendLib,
86
+ BetterStackContext,
75
87
  } from "../types";
@@ -0,0 +1,433 @@
1
+ import type { Endpoint } from "better-call";
2
+ import type { BetterStackContext } from "../../../types";
3
+ import * as z from "zod";
4
+
5
+ /**
6
+ * OpenAPI path operation object
7
+ */
8
+ export interface PathOperation {
9
+ tags?: string[];
10
+ operationId?: string;
11
+ description?: string;
12
+ summary?: string;
13
+ parameters?: OpenAPIParameter[];
14
+ requestBody?: {
15
+ required?: boolean;
16
+ content: {
17
+ "application/json": {
18
+ schema: Record<string, any>;
19
+ };
20
+ };
21
+ };
22
+ responses: Record<string, any>;
23
+ }
24
+
25
+ export interface OpenAPIParameter {
26
+ name: string;
27
+ in: "query" | "path" | "header";
28
+ required?: boolean;
29
+ schema: Record<string, any>;
30
+ description?: string;
31
+ }
32
+
33
+ /**
34
+ * Convert :param to {param} for OpenAPI path format
35
+ */
36
+ function toOpenApiPath(path: string): string {
37
+ return path
38
+ .split("/")
39
+ .map((part) => (part.startsWith(":") ? `{${part.slice(1)}}` : part))
40
+ .join("/");
41
+ }
42
+
43
+ /**
44
+ * Get the primitive type from a Zod type
45
+ */
46
+ function getTypeFromZodType(
47
+ zodType: z.ZodType<any>,
48
+ ): "string" | "number" | "boolean" | "array" | "object" | "integer" {
49
+ // const typeName = zodType._zpiSkeleton?.type || zodType.constructor.name;
50
+
51
+ if (zodType instanceof z.ZodString) return "string";
52
+ if (zodType instanceof z.ZodNumber) return "number";
53
+ if (zodType instanceof z.ZodBoolean) return "boolean";
54
+ if (zodType instanceof z.ZodArray) return "array";
55
+ if (zodType instanceof z.ZodObject) return "object";
56
+
57
+ // Fallback based on type property if available
58
+ const type = (zodType as any).type;
59
+ if (type === "string") return "string";
60
+ if (type === "number") return "number";
61
+ if (type === "boolean") return "boolean";
62
+ if (type === "array") return "array";
63
+ if (type === "object") return "object";
64
+
65
+ return "string";
66
+ }
67
+
68
+ /**
69
+ * Process a Zod type into an OpenAPI schema
70
+ */
71
+ function processZodType(zodType: z.ZodType<any>): Record<string, any> {
72
+ // Handle optional - unwrap and process inner type
73
+ // Optionality is handled by the `required` array in parent object schemas,
74
+ // NOT by adding `nullable: true` (which would incorrectly allow null values)
75
+ if (zodType instanceof z.ZodOptional) {
76
+ const innerType =
77
+ (zodType as any)._def?.innerType || (zodType as any).unwrap?.();
78
+ if (innerType) {
79
+ return processZodType(innerType);
80
+ }
81
+ }
82
+
83
+ // Handle nullable
84
+ if (zodType instanceof z.ZodNullable) {
85
+ const innerType =
86
+ (zodType as any)._def?.innerType || (zodType as any).unwrap?.();
87
+ if (innerType) {
88
+ const innerSchema = processZodType(innerType);
89
+ return {
90
+ ...innerSchema,
91
+ nullable: true,
92
+ };
93
+ }
94
+ }
95
+
96
+ // Handle default - unwrap and process inner type, including default value
97
+ if (zodType instanceof z.ZodDefault) {
98
+ const innerType = (zodType as any)._def?.innerType;
99
+ const defaultValue = (zodType as any)._def?.defaultValue?.();
100
+ if (innerType) {
101
+ const innerSchema = processZodType(innerType);
102
+ // Include the default value in the OpenAPI schema if it's JSON-serializable
103
+ if (defaultValue !== undefined) {
104
+ return {
105
+ ...innerSchema,
106
+ default: defaultValue,
107
+ };
108
+ }
109
+ return innerSchema;
110
+ }
111
+ }
112
+
113
+ // Handle object
114
+ if (zodType instanceof z.ZodObject) {
115
+ const shape = (zodType as any).shape || (zodType as any)._def?.shape?.();
116
+ if (shape) {
117
+ const properties: Record<string, any> = {};
118
+ const required: string[] = [];
119
+
120
+ for (const [key, value] of Object.entries(shape)) {
121
+ if (value instanceof z.ZodType) {
122
+ properties[key] = processZodType(value);
123
+ if (!(value instanceof z.ZodOptional)) {
124
+ required.push(key);
125
+ }
126
+ }
127
+ }
128
+
129
+ return {
130
+ type: "object",
131
+ properties,
132
+ ...(required.length > 0 ? { required } : {}),
133
+ };
134
+ }
135
+ }
136
+
137
+ // Handle array
138
+ if (zodType instanceof z.ZodArray) {
139
+ const elementType = (zodType as any)._def?.type || (zodType as any).element;
140
+ return {
141
+ type: "array",
142
+ items: elementType ? processZodType(elementType) : { type: "string" },
143
+ };
144
+ }
145
+
146
+ // Handle enum
147
+ if (zodType instanceof z.ZodEnum) {
148
+ const values = (zodType as any)._def?.values || (zodType as any).options;
149
+ return {
150
+ type: "string",
151
+ enum: values,
152
+ };
153
+ }
154
+
155
+ // Handle literal
156
+ if (zodType instanceof z.ZodLiteral) {
157
+ const value = (zodType as any)._def?.value || (zodType as any).value;
158
+ // Map JavaScript typeof to OpenAPI 3.1 types correctly
159
+ // Note: typeof null === "object" in JS, but OpenAPI 3.1 has "null" type
160
+ let type: string;
161
+ if (value === null) {
162
+ type = "null";
163
+ } else if (value === undefined) {
164
+ // undefined is not a valid JSON/OpenAPI value, treat as nullable
165
+ return { nullable: true };
166
+ } else {
167
+ type = typeof value;
168
+ }
169
+ return {
170
+ type,
171
+ const: value,
172
+ };
173
+ }
174
+
175
+ // Handle union
176
+ if (zodType instanceof z.ZodUnion) {
177
+ const options = (zodType as any)._def?.options || (zodType as any).options;
178
+ if (options && Array.isArray(options)) {
179
+ return {
180
+ oneOf: options.map((opt: z.ZodType<any>) => processZodType(opt)),
181
+ };
182
+ }
183
+ }
184
+
185
+ // Handle coerce types
186
+ if ((zodType as any)._def?.coerce) {
187
+ const innerType = (zodType as any)._def?.innerType;
188
+ if (innerType) {
189
+ return processZodType(innerType);
190
+ }
191
+ }
192
+
193
+ // Default to primitive type
194
+ return {
195
+ type: getTypeFromZodType(zodType),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Extract query parameters from endpoint options
201
+ */
202
+ function getParameters(options: any): OpenAPIParameter[] {
203
+ const parameters: OpenAPIParameter[] = [];
204
+
205
+ // Handle query parameters
206
+ if (options.query instanceof z.ZodObject) {
207
+ const shape =
208
+ (options.query as any).shape || (options.query as any)._def?.shape?.();
209
+ if (shape) {
210
+ for (const [key, value] of Object.entries(shape)) {
211
+ if (value instanceof z.ZodType) {
212
+ parameters.push({
213
+ name: key,
214
+ in: "query",
215
+ required: !(value instanceof z.ZodOptional),
216
+ schema: processZodType(value),
217
+ });
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ // Handle path parameters from params schema
224
+ if (options.params instanceof z.ZodObject) {
225
+ const shape =
226
+ (options.params as any).shape || (options.params as any)._def?.shape?.();
227
+ if (shape) {
228
+ for (const [key, value] of Object.entries(shape)) {
229
+ if (value instanceof z.ZodType) {
230
+ parameters.push({
231
+ name: key,
232
+ in: "path",
233
+ required: true,
234
+ schema: processZodType(value),
235
+ });
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ return parameters;
242
+ }
243
+
244
+ /**
245
+ * Extract request body schema from endpoint options
246
+ *
247
+ * Handles any Zod type as request body including:
248
+ * - ZodObject (most common)
249
+ * - ZodArray (batch/bulk operations)
250
+ * - ZodUnion (multiple accepted formats)
251
+ * - ZodOptional (optional body)
252
+ * - ZodNullable, ZodEnum, ZodLiteral, etc.
253
+ */
254
+ function getRequestBody(
255
+ options: any,
256
+ ): PathOperation["requestBody"] | undefined {
257
+ if (!options.body) return undefined;
258
+
259
+ // Handle any Zod type - processZodType already handles all Zod types
260
+ if (options.body instanceof z.ZodType) {
261
+ const schema = processZodType(options.body);
262
+
263
+ // Determine if body is required:
264
+ // - ZodOptional: not required
265
+ // - ZodNullable: required but can be null (nullable is set in schema)
266
+ // - Everything else: required
267
+ const isOptional = options.body instanceof z.ZodOptional;
268
+
269
+ return {
270
+ required: !isOptional,
271
+ content: {
272
+ "application/json": {
273
+ schema,
274
+ },
275
+ },
276
+ };
277
+ }
278
+
279
+ return undefined;
280
+ }
281
+
282
+ /**
283
+ * Create a fresh error schema object to avoid circular references in JSON serialization
284
+ */
285
+ function createErrorSchema(): Record<string, any> {
286
+ return {
287
+ type: "object",
288
+ properties: {
289
+ message: { type: "string" },
290
+ },
291
+ required: ["message"],
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Generate standard error responses (creates fresh objects to avoid circular refs)
297
+ */
298
+ function getErrorResponses(): Record<string, any> {
299
+ return {
300
+ "400": {
301
+ description: "Bad Request",
302
+ content: { "application/json": { schema: createErrorSchema() } },
303
+ },
304
+ "401": {
305
+ description: "Unauthorized",
306
+ content: { "application/json": { schema: createErrorSchema() } },
307
+ },
308
+ "403": {
309
+ description: "Forbidden",
310
+ content: { "application/json": { schema: createErrorSchema() } },
311
+ },
312
+ "404": {
313
+ description: "Not Found",
314
+ content: { "application/json": { schema: createErrorSchema() } },
315
+ },
316
+ "500": {
317
+ description: "Internal Server Error",
318
+ content: { "application/json": { schema: createErrorSchema() } },
319
+ },
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Generate OpenAPI 3.1 schema from Better Stack context
325
+ */
326
+ export function generateOpenAPISchema(
327
+ context: BetterStackContext,
328
+ options?: { title?: string; description?: string; version?: string },
329
+ ): Record<string, any> {
330
+ const paths: Record<string, Record<string, PathOperation>> = {};
331
+ const tags: Array<{ name: string; description: string }> = [];
332
+
333
+ // Iterate over all plugins
334
+ for (const [pluginKey, plugin] of Object.entries(context.plugins)) {
335
+ // Skip the open-api plugin itself
336
+ if (pluginKey === "openApi" || plugin.name === "open-api") {
337
+ continue;
338
+ }
339
+
340
+ // Get plugin routes
341
+ const pluginRoutes = plugin.routes(context.adapter, context);
342
+
343
+ // Create tag for this plugin
344
+ const tagName = pluginKey.charAt(0).toUpperCase() + pluginKey.slice(1);
345
+ tags.push({
346
+ name: tagName,
347
+ description: `${tagName} plugin endpoints`,
348
+ });
349
+
350
+ // Process each endpoint
351
+ for (const [routeKey, endpoint] of Object.entries(pluginRoutes)) {
352
+ const ep = endpoint as Endpoint;
353
+
354
+ // Access endpoint properties
355
+ const path = (ep as any).path;
356
+ const endpointOptions = (ep as any).options || {};
357
+ const method = (endpointOptions.method || "GET").toLowerCase();
358
+
359
+ if (!path) continue;
360
+
361
+ const openApiPath = toOpenApiPath(path);
362
+
363
+ // Initialize path object if needed
364
+ if (!paths[openApiPath]) {
365
+ paths[openApiPath] = {};
366
+ }
367
+
368
+ // Build operation object
369
+ const operation: PathOperation = {
370
+ tags: [tagName],
371
+ operationId: `${pluginKey}_${routeKey}`,
372
+ summary: endpointOptions.metadata?.openapi?.summary,
373
+ description: endpointOptions.metadata?.openapi?.description,
374
+ parameters: getParameters(endpointOptions),
375
+ responses: {
376
+ "200": {
377
+ description: "Successful response",
378
+ content: {
379
+ "application/json": {
380
+ schema: { type: "object" },
381
+ },
382
+ },
383
+ },
384
+ ...getErrorResponses(),
385
+ },
386
+ };
387
+
388
+ // Add request body for POST/PUT/PATCH
389
+ if (["post", "put", "patch"].includes(method)) {
390
+ const requestBody = getRequestBody(endpointOptions);
391
+ if (requestBody) {
392
+ operation.requestBody = requestBody;
393
+ }
394
+ }
395
+
396
+ paths[openApiPath][method] = operation;
397
+ }
398
+ }
399
+
400
+ return {
401
+ openapi: "3.1.0",
402
+ info: {
403
+ title: options?.title || "Better Stack API",
404
+ description:
405
+ options?.description ||
406
+ "API Reference for your Better Stack application",
407
+ version: options?.version || "1.0.0",
408
+ },
409
+ servers: [
410
+ {
411
+ url: context.basePath,
412
+ description: "API Server",
413
+ },
414
+ ],
415
+ tags,
416
+ paths,
417
+ components: {
418
+ securitySchemes: {
419
+ bearerAuth: {
420
+ type: "http",
421
+ scheme: "bearer",
422
+ description: "Bearer token authentication",
423
+ },
424
+ cookieAuth: {
425
+ type: "apiKey",
426
+ in: "cookie",
427
+ name: "session",
428
+ description: "Session cookie authentication",
429
+ },
430
+ },
431
+ },
432
+ };
433
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ openApiBackendPlugin,
3
+ type OpenAPIOptions,
4
+ type ScalarTheme,
5
+ type OpenApiRouter,
6
+ } from "./plugin";
7
+
8
+ export { generateOpenAPISchema } from "./generator";