@btst/stack 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/index.cjs +7 -1
- package/dist/api/index.d.cts +2 -2
- package/dist/api/index.d.mts +2 -2
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.mjs +7 -1
- package/dist/client/index.cjs +6 -2
- package/dist/client/index.d.cts +2 -1
- package/dist/client/index.d.mts +2 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.mjs +6 -2
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/packages/better-stack/src/plugins/open-api/api/generator.cjs +300 -0
- package/dist/packages/better-stack/src/plugins/open-api/api/generator.mjs +284 -0
- package/dist/packages/better-stack/src/plugins/open-api/api/plugin.cjs +115 -0
- package/dist/packages/better-stack/src/plugins/open-api/api/plugin.mjs +113 -0
- package/dist/packages/better-stack/src/plugins/open-api/db.cjs +7 -0
- package/dist/packages/better-stack/src/plugins/open-api/db.mjs +5 -0
- package/dist/packages/better-stack/src/plugins/open-api/logo.cjs +8 -0
- package/dist/packages/better-stack/src/plugins/open-api/logo.mjs +6 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.cjs +43 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.mjs +41 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.cjs +794 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.mjs +788 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.cjs +111 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.mjs +106 -0
- package/dist/packages/better-stack/src/plugins/route-docs/generator.cjs +244 -0
- package/dist/packages/better-stack/src/plugins/route-docs/generator.mjs +227 -0
- package/dist/packages/ui/src/components/sheet.cjs +25 -0
- package/dist/packages/ui/src/components/sheet.mjs +24 -1
- package/dist/plugins/api/index.d.cts +2 -2
- package/dist/plugins/api/index.d.mts +2 -2
- package/dist/plugins/api/index.d.ts +2 -2
- package/dist/plugins/blog/api/index.d.cts +1 -1
- package/dist/plugins/blog/api/index.d.mts +1 -1
- package/dist/plugins/blog/api/index.d.ts +1 -1
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +1 -1
- package/dist/plugins/blog/client/index.d.mts +1 -1
- package/dist/plugins/blog/client/index.d.ts +1 -1
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/client/index.d.cts +2 -2
- package/dist/plugins/client/index.d.mts +2 -2
- package/dist/plugins/client/index.d.ts +2 -2
- package/dist/plugins/open-api/api/index.cjs +9 -0
- package/dist/plugins/open-api/api/index.d.cts +95 -0
- package/dist/plugins/open-api/api/index.d.mts +95 -0
- package/dist/plugins/open-api/api/index.d.ts +95 -0
- package/dist/plugins/open-api/api/index.mjs +2 -0
- package/dist/plugins/route-docs/client/index.cjs +10 -0
- package/dist/plugins/route-docs/client/index.d.cts +126 -0
- package/dist/plugins/route-docs/client/index.d.mts +126 -0
- package/dist/plugins/route-docs/client/index.d.ts +126 -0
- package/dist/plugins/route-docs/client/index.mjs +1 -0
- package/dist/plugins/route-docs/client.css +3 -0
- package/dist/plugins/route-docs/style.css +19 -0
- package/dist/shared/{stack.ByOugz9d.d.cts → stack.u9iYV6vt.d.cts} +28 -3
- package/dist/shared/{stack.ByOugz9d.d.mts → stack.u9iYV6vt.d.mts} +28 -3
- package/dist/shared/{stack.ByOugz9d.d.ts → stack.u9iYV6vt.d.ts} +28 -3
- package/package.json +28 -1
- package/src/api/index.ts +14 -2
- package/src/client/index.ts +11 -4
- package/src/plugins/open-api/api/generator.ts +433 -0
- package/src/plugins/open-api/api/index.ts +8 -0
- package/src/plugins/open-api/api/plugin.ts +243 -0
- package/src/plugins/open-api/db.ts +7 -0
- package/src/plugins/open-api/logo.ts +7 -0
- package/src/plugins/route-docs/client/components/loading/docs-skeleton.tsx +82 -0
- package/src/plugins/route-docs/client/components/loading/index.tsx +1 -0
- package/src/plugins/route-docs/client/components/pages/docs-page.tsx +1240 -0
- package/src/plugins/route-docs/client/index.ts +7 -0
- package/src/plugins/route-docs/client/plugin.tsx +187 -0
- package/src/plugins/route-docs/client.css +3 -0
- package/src/plugins/route-docs/generator.ts +385 -0
- package/src/plugins/route-docs/index.ts +12 -0
- package/src/plugins/route-docs/style.css +19 -0
- package/src/types.ts +34 -2
- package/dist/shared/{stack.CcI4sYJP.d.mts → stack.DLhzx1-D.d.cts} +1 -1
- package/dist/shared/{stack.CcI4sYJP.d.ts → stack.DLhzx1-D.d.mts} +1 -1
- package/dist/shared/{stack.CcI4sYJP.d.cts → stack.DLhzx1-D.d.ts} +1 -1
package/src/client/index.ts
CHANGED
|
@@ -4,10 +4,11 @@ import type {
|
|
|
4
4
|
ClientLibConfig,
|
|
5
5
|
ClientLib,
|
|
6
6
|
ClientPlugin,
|
|
7
|
+
ClientStackContext,
|
|
7
8
|
PluginRoutes,
|
|
8
9
|
Sitemap,
|
|
9
10
|
} from "../types";
|
|
10
|
-
export type { ClientPlugin } from "../types";
|
|
11
|
+
export type { ClientPlugin, ClientStackContext } from "../types";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Creates the client library with plugin support
|
|
@@ -60,15 +61,21 @@ export function createStackClient<
|
|
|
60
61
|
TPlugins extends Record<string, ClientPlugin<any, any>>,
|
|
61
62
|
TRoutes extends PluginRoutes<TPlugins> = PluginRoutes<TPlugins>,
|
|
62
63
|
>(config: ClientLibConfig<TPlugins>): ClientLib<TRoutes> {
|
|
63
|
-
const { plugins } = config;
|
|
64
|
+
const { plugins, basePath } = config;
|
|
64
65
|
|
|
65
66
|
// Collect all routes from all plugins
|
|
66
67
|
// We build this with type assertions to preserve literal keys
|
|
67
68
|
const allRoutes = {} as TRoutes;
|
|
68
69
|
|
|
70
|
+
// Create the context object to pass to plugin routes
|
|
71
|
+
const clientStackContext: ClientStackContext<TPlugins> = {
|
|
72
|
+
plugins,
|
|
73
|
+
basePath,
|
|
74
|
+
};
|
|
75
|
+
|
|
69
76
|
for (const [pluginKey, plugin] of Object.entries(plugins)) {
|
|
70
|
-
// Add routes
|
|
71
|
-
const pluginRoutes = plugin.routes();
|
|
77
|
+
// Add routes - pass the context for plugins that need introspection (e.g., routeDocs)
|
|
78
|
+
const pluginRoutes = plugin.routes(clientStackContext);
|
|
72
79
|
Object.assign(allRoutes, pluginRoutes);
|
|
73
80
|
}
|
|
74
81
|
|
|
@@ -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,243 @@
|
|
|
1
|
+
import { defineBackendPlugin } from "@btst/stack/plugins/api";
|
|
2
|
+
import { createEndpoint } from "@btst/stack/plugins/api";
|
|
3
|
+
import { openApiSchema } from "../db";
|
|
4
|
+
import { generateOpenAPISchema } from "./generator";
|
|
5
|
+
import { logo } from "../logo";
|
|
6
|
+
import type { BetterStackContext } from "../../../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Scalar API Reference themes
|
|
10
|
+
*/
|
|
11
|
+
export type ScalarTheme =
|
|
12
|
+
| "alternate"
|
|
13
|
+
| "default"
|
|
14
|
+
| "moon"
|
|
15
|
+
| "purple"
|
|
16
|
+
| "solarized"
|
|
17
|
+
| "bluePlanet"
|
|
18
|
+
| "saturn"
|
|
19
|
+
| "kepler"
|
|
20
|
+
| "mars"
|
|
21
|
+
| "deepSpace"
|
|
22
|
+
| "laserwave"
|
|
23
|
+
| "none";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* OpenAPI plugin configuration options
|
|
27
|
+
*/
|
|
28
|
+
export interface OpenAPIOptions {
|
|
29
|
+
/**
|
|
30
|
+
* The path to the OpenAPI reference page
|
|
31
|
+
* This path is relative to the API base path
|
|
32
|
+
* @default "/reference"
|
|
33
|
+
*/
|
|
34
|
+
path?: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Disable the default HTML reference page
|
|
38
|
+
* Only the JSON schema endpoint will be available
|
|
39
|
+
* @default false
|
|
40
|
+
*/
|
|
41
|
+
disableDefaultReference?: boolean;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Theme for the Scalar API Reference page
|
|
45
|
+
* @default "default"
|
|
46
|
+
*/
|
|
47
|
+
theme?: ScalarTheme;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* CSP nonce for inline scripts
|
|
51
|
+
* Required for strict Content Security Policy
|
|
52
|
+
*/
|
|
53
|
+
nonce?: string;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Custom title for the API documentation
|
|
57
|
+
* @default "Better Stack API"
|
|
58
|
+
*/
|
|
59
|
+
title?: string;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Custom description for the API documentation
|
|
63
|
+
*/
|
|
64
|
+
description?: string;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* API version string
|
|
68
|
+
* @default "1.0.0"
|
|
69
|
+
*/
|
|
70
|
+
version?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Escape HTML entities to prevent XSS and ensure proper rendering
|
|
75
|
+
*/
|
|
76
|
+
function escapeHtml(str: string): string {
|
|
77
|
+
return str
|
|
78
|
+
.replace(/&/g, "&")
|
|
79
|
+
.replace(/</g, "<")
|
|
80
|
+
.replace(/>/g, ">")
|
|
81
|
+
.replace(/"/g, """)
|
|
82
|
+
.replace(/'/g, "'");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Escape JSON for safe embedding in HTML script tags.
|
|
87
|
+
* Replaces < with \u003c to prevent </script> from closing the tag prematurely.
|
|
88
|
+
* This is valid JSON and will be parsed correctly.
|
|
89
|
+
*/
|
|
90
|
+
function escapeJsonForHtml(json: string): string {
|
|
91
|
+
return json.replace(/</g, "\\u003c");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Generate the HTML page for Scalar API Reference
|
|
96
|
+
*/
|
|
97
|
+
function getScalarHTML(
|
|
98
|
+
schema: Record<string, any>,
|
|
99
|
+
theme: ScalarTheme = "default",
|
|
100
|
+
nonce?: string,
|
|
101
|
+
): string {
|
|
102
|
+
const nonceAttr = nonce ? ` nonce="${escapeHtml(nonce)}"` : "";
|
|
103
|
+
const encodedLogo = encodeURIComponent(logo);
|
|
104
|
+
|
|
105
|
+
const title = schema.info?.title || "API Reference";
|
|
106
|
+
const description = schema.info?.description || "API Reference";
|
|
107
|
+
|
|
108
|
+
return `<!doctype html>
|
|
109
|
+
<html>
|
|
110
|
+
<head>
|
|
111
|
+
<title>${escapeHtml(title)}</title>
|
|
112
|
+
<meta charset="utf-8" />
|
|
113
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
114
|
+
</head>
|
|
115
|
+
<body>
|
|
116
|
+
<script
|
|
117
|
+
id="api-reference"
|
|
118
|
+
type="application/json"${nonceAttr}>
|
|
119
|
+
${escapeJsonForHtml(JSON.stringify(schema))}
|
|
120
|
+
</script>
|
|
121
|
+
<script${nonceAttr}>
|
|
122
|
+
var configuration = {
|
|
123
|
+
favicon: "data:image/svg+xml;utf8,${encodedLogo}",
|
|
124
|
+
theme: "${theme}",
|
|
125
|
+
metaData: {
|
|
126
|
+
title: ${JSON.stringify(title)},
|
|
127
|
+
description: ${JSON.stringify(description)},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
document.getElementById('api-reference').dataset.configuration =
|
|
132
|
+
JSON.stringify(configuration)
|
|
133
|
+
</script>
|
|
134
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"${nonceAttr}></script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* OpenAPI plugin for Better Stack
|
|
141
|
+
*
|
|
142
|
+
* Automatically generates OpenAPI 3.1 documentation for all registered plugins.
|
|
143
|
+
* Provides both a JSON schema endpoint and an interactive Scalar UI reference page.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* const { handler } = betterStack({
|
|
148
|
+
* basePath: "/api/data",
|
|
149
|
+
* plugins: {
|
|
150
|
+
* blog: blogBackendPlugin(),
|
|
151
|
+
* cms: cmsBackendPlugin({ ... }),
|
|
152
|
+
* openApi: openApiBackendPlugin({ theme: "moon" }),
|
|
153
|
+
* },
|
|
154
|
+
* adapter: (db) => createMemoryAdapter(db)({}),
|
|
155
|
+
* });
|
|
156
|
+
*
|
|
157
|
+
* // Access:
|
|
158
|
+
* // - GET /api/data/open-api/schema - JSON schema
|
|
159
|
+
* // - GET /api/data/reference - Interactive Scalar UI
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export const openApiBackendPlugin = (options?: OpenAPIOptions) => {
|
|
163
|
+
const referencePath = options?.path ?? "/reference";
|
|
164
|
+
|
|
165
|
+
// Store context for use in endpoint handlers
|
|
166
|
+
let storedContext: BetterStackContext | null = null;
|
|
167
|
+
|
|
168
|
+
return defineBackendPlugin({
|
|
169
|
+
name: "open-api",
|
|
170
|
+
dbPlugin: openApiSchema,
|
|
171
|
+
|
|
172
|
+
routes: (_adapter, context) => {
|
|
173
|
+
// Store context for endpoint handlers
|
|
174
|
+
storedContext = context ?? null;
|
|
175
|
+
|
|
176
|
+
const generateSchema = createEndpoint(
|
|
177
|
+
"/open-api/schema",
|
|
178
|
+
{
|
|
179
|
+
method: "GET",
|
|
180
|
+
},
|
|
181
|
+
async (ctx) => {
|
|
182
|
+
if (!storedContext) {
|
|
183
|
+
throw ctx.error(500, {
|
|
184
|
+
message: "OpenAPI context not available",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const schema = generateOpenAPISchema(storedContext, {
|
|
189
|
+
title: options?.title,
|
|
190
|
+
description: options?.description,
|
|
191
|
+
version: options?.version,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return schema;
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const reference = createEndpoint(
|
|
199
|
+
referencePath,
|
|
200
|
+
{
|
|
201
|
+
method: "GET",
|
|
202
|
+
},
|
|
203
|
+
async (ctx) => {
|
|
204
|
+
if (options?.disableDefaultReference) {
|
|
205
|
+
throw ctx.error(404, {
|
|
206
|
+
message: "Reference page is disabled",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!storedContext) {
|
|
211
|
+
throw ctx.error(500, {
|
|
212
|
+
message: "OpenAPI context not available",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const schema = generateOpenAPISchema(storedContext, {
|
|
217
|
+
title: options?.title,
|
|
218
|
+
description: options?.description,
|
|
219
|
+
version: options?.version,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return new Response(
|
|
223
|
+
getScalarHTML(schema, options?.theme, options?.nonce),
|
|
224
|
+
{
|
|
225
|
+
headers: {
|
|
226
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
generateSchema,
|
|
235
|
+
reference,
|
|
236
|
+
} as const;
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
export type OpenApiRouter = ReturnType<
|
|
242
|
+
ReturnType<typeof openApiBackendPlugin>["routes"]
|
|
243
|
+
>;
|