@questpie/openapi 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # @questpie/openapi
2
+
3
+ Auto-generate an OpenAPI 3.1 spec from a QUESTPIE CMS instance and serve interactive API docs via Scalar UI.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @questpie/openapi
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Wrap your fetch handler with `withOpenApi` to add `/openapi.json` and `/docs` routes:
14
+
15
+ ```ts
16
+ import { createFetchHandler } from "questpie";
17
+ import { withOpenApi } from "@questpie/openapi";
18
+ import { cms, appRpc } from "./cms";
19
+
20
+ const handler = withOpenApi(
21
+ createFetchHandler(cms, { basePath: "/api/cms", rpc: appRpc }),
22
+ {
23
+ cms,
24
+ rpc: appRpc,
25
+ basePath: "/api/cms",
26
+ info: { title: "My API", version: "1.0.0" },
27
+ scalar: { theme: "purple" },
28
+ },
29
+ );
30
+
31
+ // GET /api/cms/openapi.json → OpenAPI spec
32
+ // GET /api/cms/docs → Scalar UI
33
+ // Everything else → CMS routes
34
+ ```
35
+
36
+ ## What Gets Documented
37
+
38
+ | Category | Endpoints |
39
+ | --------------- | --------------------------------------------------------------------------------------- |
40
+ | **Collections** | List, create, findOne, update, delete, count, deleteMany, restore, upload, schema, meta |
41
+ | **Globals** | Get, update, schema |
42
+ | **RPC** | All procedures from the RPC router tree, with input/output from Zod schemas |
43
+ | **Auth** | Better Auth endpoints (sign-in, sign-up, session, sign-out) |
44
+ | **Search** | Full-text search and reindex |
45
+
46
+ RPC functions with an explicit `outputSchema` get full request/response documentation. Functions without it fall back to `{ type: "object" }`.
47
+
48
+ ## Standalone Spec Generation
49
+
50
+ Generate the spec without mounting routes:
51
+
52
+ ```ts
53
+ import { generateOpenApiSpec } from "@questpie/openapi";
54
+
55
+ const spec = generateOpenApiSpec(cms, appRpc, {
56
+ basePath: "/api/cms",
57
+ info: { title: "My API", version: "1.0.0" },
58
+ });
59
+ ```
60
+
61
+ ## Documentation
62
+
63
+ Full documentation: [https://questpie.com/docs/client/openapi](https://questpie.com/docs/client/openapi)
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,142 @@
1
+ import { Questpie, RpcRouterTree } from "questpie";
2
+
3
+ //#region src/types.d.ts
4
+
5
+ /**
6
+ * Configuration for OpenAPI spec generation.
7
+ */
8
+ interface OpenApiConfig {
9
+ /** OpenAPI info object */
10
+ info?: {
11
+ title?: string;
12
+ version?: string;
13
+ description?: string;
14
+ };
15
+ /** Server definitions */
16
+ servers?: Array<{
17
+ url: string;
18
+ description?: string;
19
+ }>;
20
+ /** Base path for CMS routes (must match your adapter basePath) */
21
+ basePath?: string;
22
+ /** Exclude specific collections or globals from the spec */
23
+ exclude?: {
24
+ collections?: string[];
25
+ globals?: string[];
26
+ };
27
+ /** Include auth endpoints in the spec */
28
+ auth?: boolean;
29
+ /** Include search endpoints in the spec */
30
+ search?: boolean;
31
+ }
32
+ /**
33
+ * Scalar UI configuration options.
34
+ */
35
+ interface ScalarConfig {
36
+ /** Theme for Scalar UI */
37
+ theme?: string;
38
+ /** Page title override */
39
+ title?: string;
40
+ /** Custom CSS */
41
+ customCss?: string;
42
+ /** Hide the "Download OpenAPI Spec" button */
43
+ hideDownloadButton?: boolean;
44
+ /** Default HTTP client for code samples */
45
+ defaultHttpClient?: {
46
+ targetKey: string;
47
+ clientKey: string;
48
+ };
49
+ }
50
+ /**
51
+ * Configuration for withOpenApi() handler wrapper.
52
+ */
53
+ interface WithOpenApiConfig extends OpenApiConfig {
54
+ /** CMS instance */
55
+ cms: Questpie<any>;
56
+ /** RPC router tree (same one passed to createFetchHandler) */
57
+ rpc?: RpcRouterTree<any>;
58
+ /** Scalar UI options */
59
+ scalar?: ScalarConfig;
60
+ /** Path for the JSON spec (relative to basePath, default: "openapi.json") */
61
+ specPath?: string;
62
+ /** Path for the Scalar UI docs (relative to basePath, default: "docs") */
63
+ docsPath?: string;
64
+ }
65
+ /**
66
+ * OpenAPI 3.1 spec (simplified type — full spec is a plain object).
67
+ */
68
+ type OpenApiSpec = {
69
+ openapi: "3.1.0";
70
+ info: {
71
+ title: string;
72
+ version: string;
73
+ description?: string;
74
+ };
75
+ servers?: Array<{
76
+ url: string;
77
+ description?: string;
78
+ }>;
79
+ paths: Record<string, Record<string, PathOperation>>;
80
+ components: {
81
+ schemas: Record<string, unknown>;
82
+ securitySchemes?: Record<string, unknown>;
83
+ };
84
+ tags?: Array<{
85
+ name: string;
86
+ description?: string;
87
+ }>;
88
+ security?: Array<Record<string, string[]>>;
89
+ };
90
+ interface PathOperation {
91
+ operationId?: string;
92
+ summary?: string;
93
+ description?: string;
94
+ tags?: string[];
95
+ parameters?: unknown[];
96
+ requestBody?: unknown;
97
+ responses: Record<string, unknown>;
98
+ security?: Array<Record<string, string[]>>;
99
+ }
100
+ //#endregion
101
+ //#region src/server.d.ts
102
+ /**
103
+ * Generate a complete OpenAPI 3.1 spec from a CMS instance and optional RPC router.
104
+ */
105
+ declare function generateOpenApiSpec(cms: Questpie<any>, rpc?: RpcRouterTree<any>, config?: OpenApiConfig): OpenApiSpec;
106
+ /**
107
+ * Create request handlers for serving the OpenAPI spec and Scalar UI.
108
+ */
109
+ declare function createOpenApiHandlers(spec: OpenApiSpec, options?: {
110
+ scalar?: ScalarConfig;
111
+ }): {
112
+ /** Returns the OpenAPI spec as JSON */
113
+ specHandler: () => Response;
114
+ /** Returns the Scalar UI HTML page */
115
+ scalarHandler: () => Response;
116
+ };
117
+ /**
118
+ * Wrap a CMS fetch handler to add OpenAPI spec and Scalar UI routes.
119
+ *
120
+ * Intercepts requests to `{basePath}/{specPath}` and `{basePath}/{docsPath}`
121
+ * before they reach the CMS handler. Everything else passes through unchanged.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const handler = withOpenApi(
126
+ * createFetchHandler(cms, { basePath: '/api/cms', rpc: appRpc }),
127
+ * {
128
+ * cms,
129
+ * rpc: appRpc,
130
+ * basePath: '/api/cms',
131
+ * info: { title: 'My API', version: '1.0.0' },
132
+ * scalar: { theme: 'purple' },
133
+ * }
134
+ * )
135
+ * // GET /api/cms/openapi.json → spec JSON
136
+ * // GET /api/cms/docs → Scalar UI
137
+ * // Everything else → CMS handler
138
+ * ```
139
+ */
140
+ declare function withOpenApi(handler: (request: Request, context?: any) => Promise<Response | null> | Response | null, config: WithOpenApiConfig): (request: Request, context?: any) => Promise<Response | null> | Response | null;
141
+ //#endregion
142
+ export { type OpenApiConfig, type OpenApiSpec, type ScalarConfig, type WithOpenApiConfig, createOpenApiHandlers, generateOpenApiSpec, withOpenApi };
@@ -0,0 +1,969 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/generator/schemas.ts
4
+ /**
5
+ * Shared schema helpers for OpenAPI spec generation.
6
+ * Provides $ref utilities, common schemas (pagination, errors), and Zod conversion helpers.
7
+ */
8
+ /**
9
+ * Create a $ref pointer to a component schema.
10
+ */
11
+ function ref(name) {
12
+ return { $ref: `#/components/schemas/${name}` };
13
+ }
14
+ /**
15
+ * Standard error response schema.
16
+ */
17
+ function errorResponseSchema() {
18
+ return {
19
+ type: "object",
20
+ properties: { error: {
21
+ type: "object",
22
+ properties: {
23
+ code: { type: "string" },
24
+ message: { type: "string" },
25
+ details: {}
26
+ },
27
+ required: ["code", "message"]
28
+ } },
29
+ required: ["error"]
30
+ };
31
+ }
32
+ /**
33
+ * Paginated response wrapper for a given item schema.
34
+ */
35
+ function paginatedResponseSchema(itemRef) {
36
+ return {
37
+ type: "object",
38
+ properties: {
39
+ docs: {
40
+ type: "array",
41
+ items: itemRef
42
+ },
43
+ totalDocs: { type: "integer" },
44
+ limit: { type: "integer" },
45
+ page: { type: "integer" },
46
+ totalPages: { type: "integer" },
47
+ hasNextPage: { type: "boolean" },
48
+ hasPrevPage: { type: "boolean" },
49
+ nextPage: { type: ["integer", "null"] },
50
+ prevPage: { type: ["integer", "null"] }
51
+ },
52
+ required: [
53
+ "docs",
54
+ "totalDocs",
55
+ "limit",
56
+ "page",
57
+ "totalPages"
58
+ ]
59
+ };
60
+ }
61
+ /**
62
+ * Common query parameters for collection list endpoints.
63
+ */
64
+ function listQueryParameters() {
65
+ return [
66
+ {
67
+ name: "limit",
68
+ in: "query",
69
+ schema: {
70
+ type: "integer",
71
+ default: 10
72
+ },
73
+ description: "Number of records to return"
74
+ },
75
+ {
76
+ name: "page",
77
+ in: "query",
78
+ schema: {
79
+ type: "integer",
80
+ default: 1
81
+ },
82
+ description: "Page number"
83
+ },
84
+ {
85
+ name: "offset",
86
+ in: "query",
87
+ schema: { type: "integer" },
88
+ description: "Number of records to skip"
89
+ },
90
+ {
91
+ name: "where",
92
+ in: "query",
93
+ schema: { type: "string" },
94
+ description: "Filter conditions (JSON encoded)"
95
+ },
96
+ {
97
+ name: "orderBy",
98
+ in: "query",
99
+ schema: { type: "string" },
100
+ description: "Sort configuration (JSON encoded)"
101
+ },
102
+ {
103
+ name: "locale",
104
+ in: "query",
105
+ schema: { type: "string" },
106
+ description: "Content locale"
107
+ }
108
+ ];
109
+ }
110
+ /**
111
+ * Common query parameters for single-record endpoints.
112
+ */
113
+ function singleQueryParameters() {
114
+ return [{
115
+ name: "locale",
116
+ in: "query",
117
+ schema: { type: "string" },
118
+ description: "Content locale"
119
+ }];
120
+ }
121
+ /**
122
+ * Standard JSON responses helper.
123
+ */
124
+ function jsonResponse(schema, description = "Successful response") {
125
+ return {
126
+ "200": {
127
+ description,
128
+ content: { "application/json": { schema } }
129
+ },
130
+ "400": {
131
+ description: "Bad request",
132
+ content: { "application/json": { schema: ref("ErrorResponse") } }
133
+ },
134
+ "401": {
135
+ description: "Unauthorized",
136
+ content: { "application/json": { schema: ref("ErrorResponse") } }
137
+ },
138
+ "404": {
139
+ description: "Not found",
140
+ content: { "application/json": { schema: ref("ErrorResponse") } }
141
+ }
142
+ };
143
+ }
144
+ /**
145
+ * JSON request body helper.
146
+ */
147
+ function jsonRequestBody(schema, description) {
148
+ return {
149
+ description,
150
+ required: true,
151
+ content: { "application/json": { schema } }
152
+ };
153
+ }
154
+ /**
155
+ * Safely convert a Zod schema to JSON Schema.
156
+ * Falls back to a permissive object schema on failure.
157
+ */
158
+ function zodToJsonSchema(schema) {
159
+ try {
160
+ if (schema && typeof schema === "object" && "_def" in schema) return z.toJSONSchema(schema);
161
+ } catch {}
162
+ return {
163
+ type: "object",
164
+ description: "Schema could not be generated"
165
+ };
166
+ }
167
+ /**
168
+ * Build the base component schemas shared across all endpoints.
169
+ */
170
+ function baseComponentSchemas() {
171
+ return {
172
+ ErrorResponse: errorResponseSchema(),
173
+ SuccessResponse: {
174
+ type: "object",
175
+ properties: { success: { type: "boolean" } },
176
+ required: ["success"]
177
+ },
178
+ CountResponse: {
179
+ type: "object",
180
+ properties: { count: { type: "integer" } },
181
+ required: ["count"]
182
+ },
183
+ DeleteManyResponse: {
184
+ type: "object",
185
+ properties: {
186
+ success: { type: "boolean" },
187
+ count: { type: "integer" }
188
+ },
189
+ required: ["success"]
190
+ }
191
+ };
192
+ }
193
+
194
+ //#endregion
195
+ //#region src/generator/auth.ts
196
+ /**
197
+ * Generate OpenAPI paths for Better Auth endpoints.
198
+ */
199
+ function generateAuthPaths(config) {
200
+ if (config.auth === false) return {
201
+ paths: {},
202
+ tags: []
203
+ };
204
+ const basePath = config.basePath ?? "/cms";
205
+ const tag = "Auth";
206
+ const paths = {};
207
+ paths[`${basePath}/auth/sign-in/email`] = { post: {
208
+ operationId: "auth_signInEmail",
209
+ summary: "Sign in with email and password",
210
+ tags: [tag],
211
+ requestBody: jsonRequestBody({
212
+ type: "object",
213
+ properties: {
214
+ email: {
215
+ type: "string",
216
+ format: "email"
217
+ },
218
+ password: { type: "string" }
219
+ },
220
+ required: ["email", "password"]
221
+ }),
222
+ responses: jsonResponse({
223
+ type: "object",
224
+ properties: {
225
+ user: { type: "object" },
226
+ session: { type: "object" }
227
+ }
228
+ }, "Authentication successful")
229
+ } };
230
+ paths[`${basePath}/auth/sign-up/email`] = { post: {
231
+ operationId: "auth_signUpEmail",
232
+ summary: "Sign up with email and password",
233
+ tags: [tag],
234
+ requestBody: jsonRequestBody({
235
+ type: "object",
236
+ properties: {
237
+ email: {
238
+ type: "string",
239
+ format: "email"
240
+ },
241
+ password: { type: "string" },
242
+ name: { type: "string" }
243
+ },
244
+ required: [
245
+ "email",
246
+ "password",
247
+ "name"
248
+ ]
249
+ }),
250
+ responses: jsonResponse({
251
+ type: "object",
252
+ properties: {
253
+ user: { type: "object" },
254
+ session: { type: "object" }
255
+ }
256
+ }, "Registration successful")
257
+ } };
258
+ paths[`${basePath}/auth/get-session`] = { get: {
259
+ operationId: "auth_getSession",
260
+ summary: "Get current session",
261
+ tags: [tag],
262
+ responses: jsonResponse({
263
+ type: "object",
264
+ properties: {
265
+ user: { type: "object" },
266
+ session: { type: "object" }
267
+ }
268
+ }, "Current session")
269
+ } };
270
+ paths[`${basePath}/auth/sign-out`] = { post: {
271
+ operationId: "auth_signOut",
272
+ summary: "Sign out",
273
+ tags: [tag],
274
+ responses: jsonResponse({
275
+ type: "object",
276
+ properties: { success: { type: "boolean" } }
277
+ }, "Signed out")
278
+ } };
279
+ return {
280
+ paths,
281
+ tags: [{
282
+ name: tag,
283
+ description: "Authentication endpoints (Better Auth)"
284
+ }]
285
+ };
286
+ }
287
+
288
+ //#endregion
289
+ //#region src/generator/collections.ts
290
+ /**
291
+ * Generate OpenAPI paths and component schemas for all collections.
292
+ */
293
+ function generateCollectionPaths(cms, config) {
294
+ const collections = cms.getCollections();
295
+ const basePath = config.basePath ?? "/cms";
296
+ const excluded = new Set(config.exclude?.collections ?? []);
297
+ const paths = {};
298
+ const schemas = {};
299
+ const tags = [];
300
+ for (const [name, collection] of Object.entries(collections)) {
301
+ if (excluded.has(name)) continue;
302
+ const state = collection.state;
303
+ if (!state) continue;
304
+ const tag = `Collections: ${name}`;
305
+ tags.push({
306
+ name: tag,
307
+ description: `CRUD operations for ${name}`
308
+ });
309
+ const pascalName = toPascalCase$1(name);
310
+ const documentSchemaName = `${pascalName}Document`;
311
+ const insertSchemaName = `${pascalName}Insert`;
312
+ const updateSchemaName = `${pascalName}Update`;
313
+ const fieldDefinitionSchema = buildSchemaFromFieldDefinitions(state.fieldDefinitions);
314
+ if (state.validation?.insertSchema) try {
315
+ schemas[insertSchemaName] = z.toJSONSchema(state.validation.insertSchema, { unrepresentable: "any" });
316
+ } catch {
317
+ schemas[insertSchemaName] = {
318
+ type: "object",
319
+ description: `Insert schema for ${name}`
320
+ };
321
+ }
322
+ else if (fieldDefinitionSchema != null) schemas[insertSchemaName] = fieldDefinitionSchema.insert;
323
+ else schemas[insertSchemaName] = {
324
+ type: "object",
325
+ description: `Insert schema for ${name}`
326
+ };
327
+ if (state.validation?.updateSchema) try {
328
+ schemas[updateSchemaName] = z.toJSONSchema(state.validation.updateSchema, { unrepresentable: "any" });
329
+ } catch {
330
+ schemas[updateSchemaName] = {
331
+ type: "object",
332
+ description: `Update schema for ${name}`
333
+ };
334
+ }
335
+ else if (fieldDefinitionSchema != null) schemas[updateSchemaName] = fieldDefinitionSchema.update;
336
+ else schemas[updateSchemaName] = {
337
+ type: "object",
338
+ description: `Update schema for ${name}`
339
+ };
340
+ schemas[documentSchemaName] = buildDocumentSchema(name, state, insertSchemaName);
341
+ const prefix = `${basePath}/${name}`;
342
+ paths[prefix] = {
343
+ get: {
344
+ operationId: `${name}_find`,
345
+ summary: `List ${name}`,
346
+ tags: [tag],
347
+ parameters: listQueryParameters(),
348
+ responses: jsonResponse(paginatedResponseSchema(ref(documentSchemaName)), `Paginated list of ${name}`)
349
+ },
350
+ post: {
351
+ operationId: `${name}_create`,
352
+ summary: `Create ${name}`,
353
+ tags: [tag],
354
+ requestBody: jsonRequestBody(ref(insertSchemaName)),
355
+ responses: jsonResponse(ref(documentSchemaName), `Created ${name} record`)
356
+ }
357
+ };
358
+ paths[`${prefix}/count`] = { get: {
359
+ operationId: `${name}_count`,
360
+ summary: `Count ${name}`,
361
+ tags: [tag],
362
+ parameters: [{
363
+ name: "where",
364
+ in: "query",
365
+ schema: { type: "string" },
366
+ description: "Filter conditions (JSON encoded)"
367
+ }],
368
+ responses: jsonResponse(ref("CountResponse"), `Count of ${name}`)
369
+ } };
370
+ paths[`${prefix}/delete-many`] = { post: {
371
+ operationId: `${name}_deleteMany`,
372
+ summary: `Delete many ${name}`,
373
+ tags: [tag],
374
+ requestBody: jsonRequestBody({
375
+ type: "object",
376
+ properties: { where: {
377
+ type: "object",
378
+ description: "Filter conditions for records to delete"
379
+ } }
380
+ }),
381
+ responses: jsonResponse(ref("DeleteManyResponse"), `Delete multiple ${name} records`)
382
+ } };
383
+ if (state.upload) paths[`${prefix}/upload`] = { post: {
384
+ operationId: `${name}_upload`,
385
+ summary: `Upload file to ${name}`,
386
+ tags: [tag],
387
+ requestBody: {
388
+ required: true,
389
+ content: { "multipart/form-data": { schema: {
390
+ type: "object",
391
+ properties: { file: {
392
+ type: "string",
393
+ format: "binary"
394
+ } },
395
+ required: ["file"]
396
+ } } }
397
+ },
398
+ responses: jsonResponse(ref(documentSchemaName), `Uploaded file record`)
399
+ } };
400
+ paths[`${prefix}/schema`] = { get: {
401
+ operationId: `${name}_schema`,
402
+ summary: `Get ${name} introspection schema`,
403
+ tags: [tag],
404
+ responses: jsonResponse({
405
+ type: "object",
406
+ description: "Introspected collection schema"
407
+ }, `Introspection schema for ${name}`)
408
+ } };
409
+ paths[`${prefix}/meta`] = { get: {
410
+ operationId: `${name}_meta`,
411
+ summary: `Get ${name} metadata`,
412
+ tags: [tag],
413
+ responses: jsonResponse({
414
+ type: "object",
415
+ description: "Collection metadata"
416
+ }, `Metadata for ${name}`)
417
+ } };
418
+ const idParam = {
419
+ name: "id",
420
+ in: "path",
421
+ required: true,
422
+ schema: { type: "string" },
423
+ description: "Record ID"
424
+ };
425
+ paths[`${prefix}/{id}`] = {
426
+ get: {
427
+ operationId: `${name}_findOne`,
428
+ summary: `Get ${name} by ID`,
429
+ tags: [tag],
430
+ parameters: [idParam, ...singleQueryParameters()],
431
+ responses: jsonResponse(ref(documentSchemaName), `Single ${name} record`)
432
+ },
433
+ patch: {
434
+ operationId: `${name}_update`,
435
+ summary: `Update ${name}`,
436
+ tags: [tag],
437
+ parameters: [idParam],
438
+ requestBody: jsonRequestBody(ref(updateSchemaName)),
439
+ responses: jsonResponse(ref(documentSchemaName), `Updated ${name} record`)
440
+ },
441
+ delete: {
442
+ operationId: `${name}_delete`,
443
+ summary: `Delete ${name}`,
444
+ tags: [tag],
445
+ parameters: [idParam],
446
+ responses: jsonResponse(ref("SuccessResponse"), `Deleted ${name} record`)
447
+ }
448
+ };
449
+ if (state.options?.softDelete) paths[`${prefix}/{id}/restore`] = { post: {
450
+ operationId: `${name}_restore`,
451
+ summary: `Restore deleted ${name}`,
452
+ tags: [tag],
453
+ parameters: [idParam],
454
+ responses: jsonResponse(ref(documentSchemaName), `Restored ${name} record`)
455
+ } };
456
+ }
457
+ return {
458
+ paths,
459
+ schemas,
460
+ tags
461
+ };
462
+ }
463
+ /**
464
+ * Build a document response schema that extends the insert schema with
465
+ * standard fields (id, timestamps).
466
+ */
467
+ function buildDocumentSchema(name, state, insertSchemaName) {
468
+ const properties = { id: { type: "string" } };
469
+ if (state.options?.timestamps !== false) {
470
+ properties.createdAt = {
471
+ type: "string",
472
+ format: "date-time"
473
+ };
474
+ properties.updatedAt = {
475
+ type: "string",
476
+ format: "date-time"
477
+ };
478
+ }
479
+ if (state.options?.softDelete) properties.deletedAt = {
480
+ type: ["string", "null"],
481
+ format: "date-time"
482
+ };
483
+ return {
484
+ allOf: [{
485
+ type: "object",
486
+ properties,
487
+ required: ["id"]
488
+ }, ref(insertSchemaName)],
489
+ description: `${name} document`
490
+ };
491
+ }
492
+ function toPascalCase$1(str) {
493
+ return str.replace(/[-_](.)/g, (_, c) => c.toUpperCase()).replace(/^(.)/, (_, c) => c.toUpperCase());
494
+ }
495
+ function buildSchemaFromFieldDefinitions(fieldDefinitions) {
496
+ if (!fieldDefinitions || typeof fieldDefinitions !== "object") return null;
497
+ const shape = {};
498
+ for (const [fieldName, fieldDefinition] of Object.entries(fieldDefinitions)) {
499
+ const toZodSchema = fieldDefinition.toZodSchema;
500
+ if (typeof toZodSchema !== "function") continue;
501
+ try {
502
+ const schema = toZodSchema();
503
+ if (schema && typeof schema === "object" && "_def" in schema) shape[fieldName] = schema;
504
+ } catch {}
505
+ }
506
+ if (Object.keys(shape).length === 0) return null;
507
+ const insertSchema = z.object(shape);
508
+ const updateSchema = insertSchema.partial();
509
+ return {
510
+ insert: z.toJSONSchema(insertSchema, { unrepresentable: "any" }),
511
+ update: z.toJSONSchema(updateSchema, { unrepresentable: "any" })
512
+ };
513
+ }
514
+
515
+ //#endregion
516
+ //#region src/generator/globals.ts
517
+ /**
518
+ * Generate OpenAPI paths and component schemas for all globals.
519
+ */
520
+ function generateGlobalPaths(cms, config) {
521
+ const globals = cms.getGlobals();
522
+ const basePath = config.basePath ?? "/cms";
523
+ const excluded = new Set(config.exclude?.globals ?? []);
524
+ const paths = {};
525
+ const schemas = {};
526
+ const tags = [];
527
+ for (const [name, global] of Object.entries(globals)) {
528
+ if (excluded.has(name)) continue;
529
+ const state = global.state;
530
+ if (!state) continue;
531
+ const tag = `Globals: ${name}`;
532
+ tags.push({
533
+ name: tag,
534
+ description: `Operations for ${name} global`
535
+ });
536
+ const pascalName = toPascalCase(name);
537
+ const valueSchemaName = `${pascalName}Global`;
538
+ const updateSchemaName = `${pascalName}GlobalUpdate`;
539
+ const fieldDefinitionSchema = buildGlobalSchemaFromFieldDefinitions(state.fieldDefinitions);
540
+ if (state.validation?.updateSchema) try {
541
+ schemas[updateSchemaName] = z.toJSONSchema(state.validation.updateSchema, { unrepresentable: "any" });
542
+ } catch {
543
+ schemas[updateSchemaName] = {
544
+ type: "object",
545
+ description: `Update schema for ${name} global`
546
+ };
547
+ }
548
+ else if (fieldDefinitionSchema != null) schemas[updateSchemaName] = fieldDefinitionSchema;
549
+ else schemas[updateSchemaName] = {
550
+ type: "object",
551
+ description: `Update schema for ${name} global`
552
+ };
553
+ const properties = { id: { type: "string" } };
554
+ if (state.options?.timestamps !== false) {
555
+ properties.createdAt = {
556
+ type: "string",
557
+ format: "date-time"
558
+ };
559
+ properties.updatedAt = {
560
+ type: "string",
561
+ format: "date-time"
562
+ };
563
+ }
564
+ schemas[valueSchemaName] = {
565
+ allOf: [{
566
+ type: "object",
567
+ properties,
568
+ required: ["id"]
569
+ }, ref(updateSchemaName)],
570
+ description: `${name} global value`
571
+ };
572
+ const prefix = `${basePath}/globals/${name}`;
573
+ paths[prefix] = {
574
+ get: {
575
+ operationId: `global_${name}_get`,
576
+ summary: `Get ${name} global`,
577
+ tags: [tag],
578
+ parameters: [{
579
+ name: "locale",
580
+ in: "query",
581
+ schema: { type: "string" },
582
+ description: "Content locale"
583
+ }],
584
+ responses: jsonResponse(ref(valueSchemaName), `Current value of ${name} global`)
585
+ },
586
+ patch: {
587
+ operationId: `global_${name}_update`,
588
+ summary: `Update ${name} global`,
589
+ tags: [tag],
590
+ requestBody: jsonRequestBody(ref(updateSchemaName)),
591
+ responses: jsonResponse(ref(valueSchemaName), `Updated ${name} global`)
592
+ }
593
+ };
594
+ paths[`${prefix}/schema`] = { get: {
595
+ operationId: `global_${name}_schema`,
596
+ summary: `Get ${name} global introspection schema`,
597
+ tags: [tag],
598
+ responses: jsonResponse({
599
+ type: "object",
600
+ description: "Introspected global schema"
601
+ }, `Introspection schema for ${name} global`)
602
+ } };
603
+ }
604
+ return {
605
+ paths,
606
+ schemas,
607
+ tags
608
+ };
609
+ }
610
+ function toPascalCase(str) {
611
+ return str.replace(/[-_](.)/g, (_, c) => c.toUpperCase()).replace(/^(.)/, (_, c) => c.toUpperCase());
612
+ }
613
+ function buildGlobalSchemaFromFieldDefinitions(fieldDefinitions) {
614
+ if (!fieldDefinitions || typeof fieldDefinitions !== "object") return null;
615
+ const shape = {};
616
+ for (const [fieldName, fieldDefinition] of Object.entries(fieldDefinitions)) {
617
+ const toZodSchema = fieldDefinition.toZodSchema;
618
+ if (typeof toZodSchema !== "function") continue;
619
+ try {
620
+ const schema = toZodSchema();
621
+ if (schema && typeof schema === "object" && "_def" in schema) shape[fieldName] = schema;
622
+ } catch {}
623
+ }
624
+ if (Object.keys(shape).length === 0) return null;
625
+ return z.toJSONSchema(z.object(shape).partial(), { unrepresentable: "any" });
626
+ }
627
+
628
+ //#endregion
629
+ //#region src/generator/rpc.ts
630
+ /**
631
+ * Flatten an RPC router tree into a list of { path, definition }.
632
+ */
633
+ function flattenRpcTree(tree, prefix = []) {
634
+ const entries = [];
635
+ for (const [key, value] of Object.entries(tree)) {
636
+ const segments = [...prefix, key];
637
+ if (value && typeof value === "object" && "handler" in value && typeof value.handler === "function") entries.push({
638
+ path: segments.join("/"),
639
+ segments,
640
+ definition: value
641
+ });
642
+ else if (value && typeof value === "object") entries.push(...flattenRpcTree(value, segments));
643
+ }
644
+ return entries;
645
+ }
646
+ /**
647
+ * Generate OpenAPI paths for all RPC functions in the router tree.
648
+ */
649
+ function generateRpcPaths(rpc, config) {
650
+ const paths = {};
651
+ const schemas = {};
652
+ const tags = [];
653
+ if (!rpc) return {
654
+ paths,
655
+ schemas,
656
+ tags
657
+ };
658
+ const basePath = config.basePath ?? "/cms";
659
+ const entries = flattenRpcTree(rpc);
660
+ const tagSet = /* @__PURE__ */ new Set();
661
+ for (const entry of entries) {
662
+ const def = entry.definition;
663
+ const isRaw = def.mode === "raw";
664
+ const topLevel = entry.segments[0] ?? "rpc";
665
+ if (!tagSet.has(topLevel)) {
666
+ tagSet.add(topLevel);
667
+ tags.push({
668
+ name: `RPC: ${topLevel}`,
669
+ description: `RPC functions under ${topLevel}`
670
+ });
671
+ }
672
+ const operationId = `rpc_${entry.segments.join("_")}`;
673
+ const routePath = `${basePath}/rpc/${entry.path}`;
674
+ const operation = {
675
+ operationId,
676
+ summary: entry.path,
677
+ tags: [`RPC: ${topLevel}`],
678
+ responses: {}
679
+ };
680
+ if (isRaw) {
681
+ operation.description = "Raw RPC function — accepts any request body and returns a raw response.";
682
+ operation.requestBody = { content: {
683
+ "application/json": { schema: {} },
684
+ "application/octet-stream": { schema: {
685
+ type: "string",
686
+ format: "binary"
687
+ } }
688
+ } };
689
+ operation.responses = {
690
+ "200": { description: "Raw response" },
691
+ "401": {
692
+ description: "Unauthorized",
693
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
694
+ }
695
+ };
696
+ } else {
697
+ let inputSchema = {};
698
+ let outputSchema = { type: "object" };
699
+ if (def.schema) {
700
+ const schemaName = `${operationId}_Input`;
701
+ schemas[schemaName] = zodToJsonSchema(def.schema);
702
+ inputSchema = { $ref: `#/components/schemas/${schemaName}` };
703
+ }
704
+ if (def.outputSchema) {
705
+ const schemaName = `${operationId}_Output`;
706
+ schemas[schemaName] = zodToJsonSchema(def.outputSchema);
707
+ outputSchema = { $ref: `#/components/schemas/${schemaName}` };
708
+ }
709
+ operation.requestBody = jsonRequestBody(inputSchema, "RPC function input");
710
+ operation.responses = jsonResponse(outputSchema, "RPC function output");
711
+ }
712
+ paths[routePath] = { post: operation };
713
+ }
714
+ return {
715
+ paths,
716
+ schemas,
717
+ tags
718
+ };
719
+ }
720
+
721
+ //#endregion
722
+ //#region src/generator/search.ts
723
+ /**
724
+ * Generate OpenAPI paths for search endpoints.
725
+ */
726
+ function generateSearchPaths(config) {
727
+ if (config.search === false) return {
728
+ paths: {},
729
+ tags: []
730
+ };
731
+ const basePath = config.basePath ?? "/cms";
732
+ const tag = "Search";
733
+ const paths = {};
734
+ paths[`${basePath}/search`] = { post: {
735
+ operationId: "search",
736
+ summary: "Search across collections",
737
+ tags: [tag],
738
+ requestBody: jsonRequestBody({
739
+ type: "object",
740
+ properties: {
741
+ query: {
742
+ type: "string",
743
+ description: "Search query"
744
+ },
745
+ collections: {
746
+ type: "array",
747
+ items: { type: "string" },
748
+ description: "Collections to search (omit for all)"
749
+ },
750
+ limit: {
751
+ type: "integer",
752
+ default: 10
753
+ },
754
+ offset: {
755
+ type: "integer",
756
+ default: 0
757
+ }
758
+ },
759
+ required: ["query"]
760
+ }),
761
+ responses: jsonResponse({
762
+ type: "object",
763
+ properties: {
764
+ results: {
765
+ type: "array",
766
+ items: {
767
+ type: "object",
768
+ properties: {
769
+ collection: { type: "string" },
770
+ doc: { type: "object" },
771
+ _search: {
772
+ type: "object",
773
+ properties: {
774
+ score: { type: "number" },
775
+ highlights: { type: "object" },
776
+ indexedTitle: { type: "string" }
777
+ }
778
+ }
779
+ }
780
+ }
781
+ },
782
+ totalResults: { type: "integer" }
783
+ }
784
+ }, "Search results")
785
+ } };
786
+ paths[`${basePath}/search/reindex/{collection}`] = { post: {
787
+ operationId: "search_reindex",
788
+ summary: "Reindex a collection",
789
+ description: "Requires admin authentication.",
790
+ tags: [tag],
791
+ parameters: [{
792
+ name: "collection",
793
+ in: "path",
794
+ required: true,
795
+ schema: { type: "string" },
796
+ description: "Collection name to reindex"
797
+ }],
798
+ responses: jsonResponse({
799
+ type: "object",
800
+ properties: {
801
+ success: { type: "boolean" },
802
+ collection: { type: "string" }
803
+ }
804
+ }, "Reindex started")
805
+ } };
806
+ return {
807
+ paths,
808
+ tags: [{
809
+ name: tag,
810
+ description: "Full-text search endpoints"
811
+ }]
812
+ };
813
+ }
814
+
815
+ //#endregion
816
+ //#region src/generator/index.ts
817
+ /**
818
+ * Generate a complete OpenAPI 3.1 spec from a Questpie CMS instance and optional RPC router.
819
+ */
820
+ function generateOpenApiSpec$1(cms, rpc, config = {}) {
821
+ const allPaths = {};
822
+ const allSchemas = { ...baseComponentSchemas() };
823
+ const allTags = [];
824
+ const collections = generateCollectionPaths(cms, config);
825
+ Object.assign(allPaths, collections.paths);
826
+ Object.assign(allSchemas, collections.schemas);
827
+ allTags.push(...collections.tags);
828
+ const globals = generateGlobalPaths(cms, config);
829
+ Object.assign(allPaths, globals.paths);
830
+ Object.assign(allSchemas, globals.schemas);
831
+ allTags.push(...globals.tags);
832
+ const rpcResult = generateRpcPaths(rpc, config);
833
+ Object.assign(allPaths, rpcResult.paths);
834
+ Object.assign(allSchemas, rpcResult.schemas);
835
+ allTags.push(...rpcResult.tags);
836
+ const auth = generateAuthPaths(config);
837
+ Object.assign(allPaths, auth.paths);
838
+ allTags.push(...auth.tags);
839
+ const search = generateSearchPaths(config);
840
+ Object.assign(allPaths, search.paths);
841
+ allTags.push(...search.tags);
842
+ return {
843
+ openapi: "3.1.0",
844
+ info: {
845
+ title: config.info?.title ?? "QUESTPIE CMS API",
846
+ version: config.info?.version ?? "1.0.0",
847
+ description: config.info?.description
848
+ },
849
+ servers: config.servers,
850
+ paths: allPaths,
851
+ components: {
852
+ schemas: allSchemas,
853
+ securitySchemes: {
854
+ bearerAuth: {
855
+ type: "http",
856
+ scheme: "bearer"
857
+ },
858
+ cookieAuth: {
859
+ type: "apiKey",
860
+ in: "cookie",
861
+ name: "better-auth.session_token"
862
+ }
863
+ }
864
+ },
865
+ tags: allTags,
866
+ security: [{ bearerAuth: [] }, { cookieAuth: [] }]
867
+ };
868
+ }
869
+
870
+ //#endregion
871
+ //#region src/scalar.ts
872
+ /**
873
+ * Generate an HTML page that renders Scalar API reference UI.
874
+ * The spec is inlined as JSON and Scalar is loaded from CDN.
875
+ */
876
+ function serveScalarUI(spec, config) {
877
+ const title = config?.title ?? spec.info.title ?? "API Reference";
878
+ const scalarConfig = JSON.stringify({
879
+ theme: config?.theme ?? "purple",
880
+ hideDownloadButton: config?.hideDownloadButton,
881
+ defaultHttpClient: config?.defaultHttpClient,
882
+ customCss: config?.customCss,
883
+ content: spec
884
+ });
885
+ const html = `<!DOCTYPE html>
886
+ <html>
887
+ <head>
888
+ <title>${escapeHtml(title)}</title>
889
+ <meta charset="utf-8" />
890
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
891
+ </head>
892
+ <body>
893
+ <script id="api-reference" data-configuration='${escapeAttr(scalarConfig)}'><\/script>
894
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"><\/script>
895
+ </body>
896
+ </html>`;
897
+ return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
898
+ }
899
+ function escapeHtml(str) {
900
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
901
+ }
902
+ function escapeAttr(str) {
903
+ return str.replace(/&/g, "&amp;").replace(/'/g, "&#39;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
904
+ }
905
+
906
+ //#endregion
907
+ //#region src/server.ts
908
+ /**
909
+ * Generate a complete OpenAPI 3.1 spec from a CMS instance and optional RPC router.
910
+ */
911
+ function generateOpenApiSpec(cms, rpc, config) {
912
+ return generateOpenApiSpec$1(cms, rpc, config);
913
+ }
914
+ /**
915
+ * Create request handlers for serving the OpenAPI spec and Scalar UI.
916
+ */
917
+ function createOpenApiHandlers(spec, options) {
918
+ return {
919
+ specHandler: () => new Response(JSON.stringify(spec), { headers: {
920
+ "Content-Type": "application/json",
921
+ "Access-Control-Allow-Origin": "*"
922
+ } }),
923
+ scalarHandler: () => serveScalarUI(spec, options?.scalar)
924
+ };
925
+ }
926
+ /**
927
+ * Wrap a CMS fetch handler to add OpenAPI spec and Scalar UI routes.
928
+ *
929
+ * Intercepts requests to `{basePath}/{specPath}` and `{basePath}/{docsPath}`
930
+ * before they reach the CMS handler. Everything else passes through unchanged.
931
+ *
932
+ * @example
933
+ * ```ts
934
+ * const handler = withOpenApi(
935
+ * createFetchHandler(cms, { basePath: '/api/cms', rpc: appRpc }),
936
+ * {
937
+ * cms,
938
+ * rpc: appRpc,
939
+ * basePath: '/api/cms',
940
+ * info: { title: 'My API', version: '1.0.0' },
941
+ * scalar: { theme: 'purple' },
942
+ * }
943
+ * )
944
+ * // GET /api/cms/openapi.json → spec JSON
945
+ * // GET /api/cms/docs → Scalar UI
946
+ * // Everything else → CMS handler
947
+ * ```
948
+ */
949
+ function withOpenApi(handler, config) {
950
+ const { cms, rpc, scalar, specPath = "openapi.json", docsPath = "docs", ...openApiConfig } = config;
951
+ const { specHandler, scalarHandler } = createOpenApiHandlers(generateOpenApiSpec$1(cms, rpc, openApiConfig), { scalar });
952
+ const basePath = normalizeBasePath(openApiConfig.basePath ?? "/cms");
953
+ const specRoute = `${basePath}/${specPath}`;
954
+ const docsRoute = `${basePath}/${docsPath}`;
955
+ return (request, context) => {
956
+ const pathname = new URL(request.url).pathname;
957
+ if (request.method === "GET") {
958
+ if (pathname === specRoute) return specHandler();
959
+ if (pathname === docsRoute) return scalarHandler();
960
+ }
961
+ return handler(request, context);
962
+ };
963
+ }
964
+ function normalizeBasePath(path) {
965
+ return path.endsWith("/") ? path.slice(0, -1) : path;
966
+ }
967
+
968
+ //#endregion
969
+ export { createOpenApiHandlers, generateOpenApiSpec, withOpenApi };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@questpie/openapi",
3
+ "version": "2.0.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/questpie/questpie-cms.git",
8
+ "directory": "packages/openapi"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsdown",
15
+ "check-types": "tsc --noEmit"
16
+ },
17
+ "dependencies": {
18
+ "questpie": "workspace:*",
19
+ "zod": "^4.2.1"
20
+ },
21
+ "devDependencies": {
22
+ "bun-types": "latest",
23
+ "tsdown": "^0.18.3"
24
+ },
25
+ "peerDependencies": {
26
+ "questpie": "^2.0.0"
27
+ },
28
+ "exports": {
29
+ ".": "./src/server.ts",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "exports": {
35
+ ".": "./dist/server.mjs",
36
+ "./package.json": "./package.json"
37
+ }
38
+ },
39
+ "main": "./dist/server.mjs",
40
+ "module": "./dist/server.mjs",
41
+ "types": "./dist/server.d.mts"
42
+ }