@questpie/openapi 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @questpie/openapi
2
2
 
3
- Auto-generate an OpenAPI 3.1 spec from a QUESTPIE CMS instance and serve interactive API docs via Scalar UI.
3
+ Auto-generate an OpenAPI 3.1 spec from a QUESTPIE app instance and serve interactive API docs via Scalar UI.
4
4
 
5
5
  ## Installation
6
6
 
@@ -10,40 +10,56 @@ bun add @questpie/openapi
10
10
 
11
11
  ## Usage
12
12
 
13
- Wrap your fetch handler with `withOpenApi` to add `/openapi.json` and `/docs` routes:
13
+ Register `openApiModule` in your `modules.ts` file to add `/openapi.json` and `/docs` routes:
14
14
 
15
15
  ```ts
16
+ // src/questpie/server/modules.ts
17
+ import { adminModule } from "@questpie/admin/server";
18
+ import { openApiModule } from "@questpie/openapi";
19
+
20
+ export default [adminModule, openApiModule] as const;
21
+ ```
22
+
23
+ Configure the OpenAPI module via `config/openapi.ts`:
24
+
25
+ ```ts
26
+ // src/questpie/server/config/openapi.ts
27
+ import { openApiConfig } from "@questpie/openapi";
28
+
29
+ export default openApiConfig({
30
+ info: { title: "My API", version: "1.0.0" },
31
+ scalar: { theme: "purple" },
32
+ });
33
+ ```
34
+
35
+ Your route handler stays clean — no wrapper needed:
36
+
37
+ ```ts
38
+ // routes/api/$.ts
16
39
  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
40
+ import { app } from "#questpie";
41
+
42
+ const handler = createFetchHandler(app, { basePath: "/api" });
43
+ ```
44
+
45
+ Once registered, the following endpoints are available:
46
+
47
+ ```
48
+ GET /api/openapi.json → OpenAPI spec
49
+ GET /api/docs → Scalar UI
34
50
  ```
35
51
 
36
52
  ## What Gets Documented
37
53
 
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 |
54
+ | Category | Endpoints |
55
+ | --------------- | --------------------------------------------------------------------------------------------------------- |
56
+ | **Collections** | List, create, findOne, update, delete, count, deleteMany, restore, versions, revert, upload, schema, meta |
57
+ | **Globals** | Get, update, versions, revert, schema |
58
+ | **Routes** | All standalone routes, with input/output from Zod schemas |
59
+ | **Auth** | Better Auth endpoints (sign-in, sign-up, session, sign-out) |
60
+ | **Search** | Full-text search and reindex |
45
61
 
46
- RPC functions with an explicit `outputSchema` get full request/response documentation. Functions without it fall back to `{ type: "object" }`.
62
+ Routes with an explicit `outputSchema` get full request/response documentation. Routes without it fall back to `{ type: "object" }`.
47
63
 
48
64
  ## Standalone Spec Generation
49
65
 
@@ -52,9 +68,9 @@ Generate the spec without mounting routes:
52
68
  ```ts
53
69
  import { generateOpenApiSpec } from "@questpie/openapi";
54
70
 
55
- const spec = generateOpenApiSpec(cms, appRpc, {
56
- basePath: "/api/cms",
57
- info: { title: "My API", version: "1.0.0" },
71
+ const spec = generateOpenApiSpec(app, {
72
+ basePath: "/api",
73
+ info: { title: "My API", version: "1.0.0" },
58
74
  });
59
75
  ```
60
76
 
@@ -0,0 +1,7 @@
1
+ import { CodegenPlugin } from "questpie";
2
+
3
+ //#region src/plugin.d.ts
4
+
5
+ declare function openApiPlugin(): CodegenPlugin;
6
+ //#endregion
7
+ export { openApiPlugin };
@@ -0,0 +1,30 @@
1
+ //#region src/plugin.ts
2
+ function openApiPlugin() {
3
+ return {
4
+ name: "questpie-openapi",
5
+ targets: { server: {
6
+ root: ".",
7
+ outputFile: "index.ts",
8
+ discover: { openapi: {
9
+ pattern: "config/openapi.ts",
10
+ configKey: "openapi"
11
+ } },
12
+ registries: { singletonFactories: { openapi: {
13
+ configType: "OpenApiModuleConfig",
14
+ imports: [{
15
+ name: "OpenApiModuleConfig",
16
+ from: "@questpie/openapi"
17
+ }]
18
+ } } },
19
+ transform: (ctx) => {
20
+ const routes = ctx.categories.get("routes");
21
+ if (!routes?.size) return;
22
+ const union = [...routes.keys()].map((k) => `"${k}"`).join(" | ");
23
+ ctx.addTypeDeclaration(`export type AppRouteKeys = ${union};`);
24
+ }
25
+ } }
26
+ };
27
+ }
28
+
29
+ //#endregion
30
+ export { openApiPlugin };
package/dist/server.d.mts CHANGED
@@ -1,7 +1,6 @@
1
- import { Questpie, RpcRouterTree } from "questpie";
1
+ import * as questpie0 from "questpie";
2
2
 
3
3
  //#region src/types.d.ts
4
-
5
4
  /**
6
5
  * Configuration for OpenAPI spec generation.
7
6
  */
@@ -17,7 +16,7 @@ interface OpenApiConfig {
17
16
  url: string;
18
17
  description?: string;
19
18
  }>;
20
- /** Base path for CMS routes (must match your adapter basePath) */
19
+ /** Base path for routes (must match your adapter basePath) */
21
20
  basePath?: string;
22
21
  /** Exclude specific collections or globals from the spec */
23
22
  exclude?: {
@@ -48,18 +47,15 @@ interface ScalarConfig {
48
47
  };
49
48
  }
50
49
  /**
51
- * Configuration for withOpenApi() handler wrapper.
50
+ * Configuration for the OpenAPI module.
51
+ * Pass to `openApiModule()` for zero-config setup via `modules.ts`.
52
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>;
53
+ interface OpenApiModuleConfig extends OpenApiConfig {
58
54
  /** Scalar UI options */
59
55
  scalar?: ScalarConfig;
60
- /** Path for the JSON spec (relative to basePath, default: "openapi.json") */
56
+ /** Path for the JSON spec route (default: "openapi.json") */
61
57
  specPath?: string;
62
- /** Path for the Scalar UI docs (relative to basePath, default: "docs") */
58
+ /** Path for the Scalar UI docs route (default: "docs") */
63
59
  docsPath?: string;
64
60
  }
65
61
  /**
@@ -99,44 +95,97 @@ interface PathOperation {
99
95
  }
100
96
  //#endregion
101
97
  //#region src/server.d.ts
98
+
102
99
  /**
103
- * Generate a complete OpenAPI 3.1 spec from a CMS instance and optional RPC router.
100
+ * Identity factory for `config/openapi.ts` provides type inference.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * // config/openapi.ts
105
+ * import { openApiConfig } from "@questpie/openapi";
106
+ *
107
+ * export default openApiConfig({
108
+ * info: { title: "My API", version: "1.0.0" },
109
+ * scalar: { theme: "purple" },
110
+ * });
111
+ * ```
104
112
  */
105
- declare function generateOpenApiSpec(cms: Questpie<any>, rpc?: RpcRouterTree<any>, config?: OpenApiConfig): OpenApiSpec;
113
+ declare function openApiConfig(config: OpenApiModuleConfig): OpenApiModuleConfig;
106
114
  /**
107
- * Create request handlers for serving the OpenAPI spec and Scalar UI.
115
+ * Generate a complete OpenAPI 3.1 spec from a QUESTPIE app instance.
116
+ * Routes are read from `app.config.routes` automatically.
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * import { generateOpenApiSpec } from "@questpie/openapi";
121
+ *
122
+ * const spec = generateOpenApiSpec(app, {
123
+ * info: { title: "My API", version: "1.0.0" },
124
+ * });
125
+ * ```
108
126
  */
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
- };
127
+ declare function generateOpenApiSpec(app: unknown, config?: OpenApiConfig): OpenApiSpec;
117
128
  /**
118
- * Wrap a CMS fetch handler to add OpenAPI spec and Scalar UI routes.
129
+ * Create a route that serves the OpenAPI 3.1 JSON spec.
130
+ * Place in your `routes/` directory for automatic discovery.
131
+ *
132
+ * Spec is lazy-generated on first request and cached with ETag.
133
+ * Config is read from `config/openapi.ts` at request time, or from
134
+ * explicit parameter if provided.
135
+ *
136
+ * @example
137
+ * ```ts title="routes/openapi-spec.ts"
138
+ * import { openApiRoute } from "@questpie/openapi";
119
139
  *
120
- * Intercepts requests to `{basePath}/{specPath}` and `{basePath}/{docsPath}`
121
- * before they reach the CMS handler. Everything else passes through unchanged.
140
+ * export default openApiRoute();
141
+ * ```
142
+ */
143
+ declare function openApiRoute(config?: OpenApiConfig): questpie0.RawRouteDefinition;
144
+ /**
145
+ * Create a route that serves the Scalar interactive API docs.
146
+ * Place in your `routes/` directory for automatic discovery.
122
147
  *
123
148
  * @example
149
+ * ```ts title="routes/docs.ts"
150
+ * import { docsRoute } from "@questpie/openapi";
151
+ *
152
+ * export default docsRoute();
153
+ * ```
154
+ */
155
+ declare function docsRoute(config?: OpenApiConfig & {
156
+ scalar?: ScalarConfig;
157
+ }): questpie0.RawRouteDefinition;
158
+ /**
159
+ * OpenAPI module — registers spec + docs routes.
160
+ *
161
+ * Routes are served as:
162
+ * - `GET /api/openapi.json` — OpenAPI 3.1 JSON spec
163
+ * - `GET /api/docs` — Scalar interactive API reference
164
+ *
165
+ * Configure via `config/openapi.ts`:
166
+ * ```ts
167
+ * import { openApiConfig } from "@questpie/openapi";
168
+ * export default openApiConfig({ info: { title: "My API", version: "1.0.0" } });
169
+ * ```
170
+ *
171
+ * Or pass config directly for backward compatibility:
124
172
  * ```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
173
+ * openApiModule({ info: { title: "My API" } })
174
+ * ```
175
+ *
176
+ * @example Static (reads config from config/openapi.ts):
177
+ * ```ts title="questpie/server/modules.ts"
178
+ * import { openApiModule } from "@questpie/openapi";
179
+ * export default [openApiModule] as const;
138
180
  * ```
139
181
  */
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;
182
+ declare const openApiModule: {
183
+ name: "questpie-openapi";
184
+ plugin: questpie0.CodegenPlugin;
185
+ routes: {
186
+ "openapi.json": questpie0.RawRouteDefinition;
187
+ docs: questpie0.RawRouteDefinition;
188
+ };
189
+ };
141
190
  //#endregion
142
- export { type OpenApiConfig, type OpenApiSpec, type ScalarConfig, type WithOpenApiConfig, createOpenApiHandlers, generateOpenApiSpec, withOpenApi };
191
+ export { type OpenApiConfig, type OpenApiModuleConfig, type OpenApiSpec, type ScalarConfig, openApiModule as default, openApiModule, docsRoute, generateOpenApiSpec, openApiConfig, openApiRoute };
package/dist/server.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { openApiPlugin } from "./plugin.mjs";
2
+ import { module, route } from "questpie";
1
3
  import { z } from "zod";
2
4
 
3
5
  //#region src/generator/schemas.ts
@@ -104,7 +106,8 @@ function listQueryParameters() {
104
106
  in: "query",
105
107
  schema: { type: "string" },
106
108
  description: "Content locale"
107
- }
109
+ },
110
+ stageQueryParameter()
108
111
  ];
109
112
  }
110
113
  /**
@@ -116,7 +119,15 @@ function singleQueryParameters() {
116
119
  in: "query",
117
120
  schema: { type: "string" },
118
121
  description: "Content locale"
119
- }];
122
+ }, stageQueryParameter()];
123
+ }
124
+ function stageQueryParameter() {
125
+ return {
126
+ name: "stage",
127
+ in: "query",
128
+ schema: { type: "string" },
129
+ description: "Workflow stage"
130
+ };
120
131
  }
121
132
  /**
122
133
  * Standard JSON responses helper.
@@ -201,7 +212,7 @@ function generateAuthPaths(config) {
201
212
  paths: {},
202
213
  tags: []
203
214
  };
204
- const basePath = config.basePath ?? "/cms";
215
+ const basePath = config.basePath ?? "/";
205
216
  const tag = "Auth";
206
217
  const paths = {};
207
218
  paths[`${basePath}/auth/sign-in/email`] = { post: {
@@ -290,9 +301,9 @@ function generateAuthPaths(config) {
290
301
  /**
291
302
  * Generate OpenAPI paths and component schemas for all collections.
292
303
  */
293
- function generateCollectionPaths(cms, config) {
294
- const collections = cms.getCollections();
295
- const basePath = config.basePath ?? "/cms";
304
+ function generateCollectionPaths(app, config) {
305
+ const collections = app.getCollections();
306
+ const basePath = config.basePath ?? "/";
296
307
  const excluded = new Set(config.exclude?.collections ?? []);
297
308
  const paths = {};
298
309
  const schemas = {};
@@ -351,6 +362,7 @@ function generateCollectionPaths(cms, config) {
351
362
  operationId: `${name}_create`,
352
363
  summary: `Create ${name}`,
353
364
  tags: [tag],
365
+ parameters: [stageQueryParameter()],
354
366
  requestBody: jsonRequestBody(ref(insertSchemaName)),
355
367
  responses: jsonResponse(ref(documentSchemaName), `Created ${name} record`)
356
368
  }
@@ -434,7 +446,7 @@ function generateCollectionPaths(cms, config) {
434
446
  operationId: `${name}_update`,
435
447
  summary: `Update ${name}`,
436
448
  tags: [tag],
437
- parameters: [idParam],
449
+ parameters: [idParam, stageQueryParameter()],
438
450
  requestBody: jsonRequestBody(ref(updateSchemaName)),
439
451
  responses: jsonResponse(ref(documentSchemaName), `Updated ${name} record`)
440
452
  },
@@ -442,7 +454,7 @@ function generateCollectionPaths(cms, config) {
442
454
  operationId: `${name}_delete`,
443
455
  summary: `Delete ${name}`,
444
456
  tags: [tag],
445
- parameters: [idParam],
457
+ parameters: [idParam, stageQueryParameter()],
446
458
  responses: jsonResponse(ref("SuccessResponse"), `Deleted ${name} record`)
447
459
  }
448
460
  };
@@ -450,9 +462,76 @@ function generateCollectionPaths(cms, config) {
450
462
  operationId: `${name}_restore`,
451
463
  summary: `Restore deleted ${name}`,
452
464
  tags: [tag],
453
- parameters: [idParam],
465
+ parameters: [idParam, stageQueryParameter()],
454
466
  responses: jsonResponse(ref(documentSchemaName), `Restored ${name} record`)
455
467
  } };
468
+ paths[`${prefix}/{id}/versions`] = { get: {
469
+ operationId: `${name}_findVersions`,
470
+ summary: `List ${name} versions`,
471
+ tags: [tag],
472
+ parameters: [
473
+ idParam,
474
+ {
475
+ name: "limit",
476
+ in: "query",
477
+ schema: { type: "number" },
478
+ description: "Maximum number of versions to return"
479
+ },
480
+ {
481
+ name: "offset",
482
+ in: "query",
483
+ schema: { type: "number" },
484
+ description: "Number of versions to skip"
485
+ }
486
+ ],
487
+ responses: jsonResponse({
488
+ type: "array",
489
+ items: {
490
+ type: "object",
491
+ properties: {
492
+ id: { type: "string" },
493
+ versionId: { type: "string" },
494
+ versionNumber: { type: "number" },
495
+ versionOperation: { type: "string" },
496
+ versionUserId: { type: ["string", "null"] },
497
+ versionCreatedAt: {
498
+ type: "string",
499
+ format: "date-time"
500
+ }
501
+ }
502
+ }
503
+ }, `Version history for ${name}`)
504
+ } };
505
+ paths[`${prefix}/{id}/revert`] = { post: {
506
+ operationId: `${name}_revertToVersion`,
507
+ summary: `Revert ${name} to a version`,
508
+ tags: [tag],
509
+ parameters: [idParam, stageQueryParameter()],
510
+ requestBody: jsonRequestBody({
511
+ type: "object",
512
+ properties: {
513
+ version: { type: "number" },
514
+ versionId: { type: "string" }
515
+ }
516
+ }),
517
+ responses: jsonResponse(ref(documentSchemaName), `Reverted ${name} record`)
518
+ } };
519
+ const versioningOpts = state.options?.versioning;
520
+ if (versioningOpts && typeof versioningOpts === "object" && versioningOpts.workflow) paths[`${prefix}/{id}/transition`] = { post: {
521
+ operationId: `${name}_transition`,
522
+ summary: `Transition ${name} workflow stage`,
523
+ tags: [tag],
524
+ parameters: [idParam],
525
+ requestBody: jsonRequestBody({
526
+ type: "object",
527
+ required: ["stage"],
528
+ properties: { stage: {
529
+ type: "string",
530
+ description: "Target workflow stage"
531
+ } }
532
+ }),
533
+ responses: jsonResponse(ref(documentSchemaName), `Transitioned ${name} record`)
534
+ } };
456
535
  }
457
536
  return {
458
537
  paths,
@@ -496,10 +575,10 @@ function buildSchemaFromFieldDefinitions(fieldDefinitions) {
496
575
  if (!fieldDefinitions || typeof fieldDefinitions !== "object") return null;
497
576
  const shape = {};
498
577
  for (const [fieldName, fieldDefinition] of Object.entries(fieldDefinitions)) {
499
- const toZodSchema = fieldDefinition.toZodSchema;
500
- if (typeof toZodSchema !== "function") continue;
578
+ const fd = fieldDefinition;
579
+ if (typeof fd.toZodSchema !== "function") continue;
501
580
  try {
502
- const schema = toZodSchema();
581
+ const schema = fd.toZodSchema();
503
582
  if (schema && typeof schema === "object" && "_def" in schema) shape[fieldName] = schema;
504
583
  } catch {}
505
584
  }
@@ -517,9 +596,9 @@ function buildSchemaFromFieldDefinitions(fieldDefinitions) {
517
596
  /**
518
597
  * Generate OpenAPI paths and component schemas for all globals.
519
598
  */
520
- function generateGlobalPaths(cms, config) {
521
- const globals = cms.getGlobals();
522
- const basePath = config.basePath ?? "/cms";
599
+ function generateGlobalPaths(app, config) {
600
+ const globals = app.getGlobals();
601
+ const basePath = config.basePath ?? "/";
523
602
  const excluded = new Set(config.exclude?.globals ?? []);
524
603
  const paths = {};
525
604
  const schemas = {};
@@ -580,13 +659,14 @@ function generateGlobalPaths(cms, config) {
580
659
  in: "query",
581
660
  schema: { type: "string" },
582
661
  description: "Content locale"
583
- }],
662
+ }, stageQueryParameter()],
584
663
  responses: jsonResponse(ref(valueSchemaName), `Current value of ${name} global`)
585
664
  },
586
665
  patch: {
587
666
  operationId: `global_${name}_update`,
588
667
  summary: `Update ${name} global`,
589
668
  tags: [tag],
669
+ parameters: [stageQueryParameter()],
590
670
  requestBody: jsonRequestBody(ref(updateSchemaName)),
591
671
  responses: jsonResponse(ref(valueSchemaName), `Updated ${name} global`)
592
672
  }
@@ -600,6 +680,78 @@ function generateGlobalPaths(cms, config) {
600
680
  description: "Introspected global schema"
601
681
  }, `Introspection schema for ${name} global`)
602
682
  } };
683
+ paths[`${prefix}/versions`] = { get: {
684
+ operationId: `global_${name}_findVersions`,
685
+ summary: `List ${name} global versions`,
686
+ tags: [tag],
687
+ parameters: [
688
+ {
689
+ name: "id",
690
+ in: "query",
691
+ schema: { type: "string" },
692
+ description: "Global record ID"
693
+ },
694
+ {
695
+ name: "limit",
696
+ in: "query",
697
+ schema: { type: "number" },
698
+ description: "Maximum number of versions to return"
699
+ },
700
+ {
701
+ name: "offset",
702
+ in: "query",
703
+ schema: { type: "number" },
704
+ description: "Number of versions to skip"
705
+ }
706
+ ],
707
+ responses: jsonResponse({
708
+ type: "array",
709
+ items: {
710
+ type: "object",
711
+ properties: {
712
+ id: { type: "string" },
713
+ versionId: { type: "string" },
714
+ versionNumber: { type: "number" },
715
+ versionOperation: { type: "string" },
716
+ versionUserId: { type: ["string", "null"] },
717
+ versionCreatedAt: {
718
+ type: "string",
719
+ format: "date-time"
720
+ }
721
+ }
722
+ }
723
+ }, `Version history for ${name} global`)
724
+ } };
725
+ paths[`${prefix}/revert`] = { post: {
726
+ operationId: `global_${name}_revertToVersion`,
727
+ summary: `Revert ${name} global to a version`,
728
+ tags: [tag],
729
+ parameters: [stageQueryParameter()],
730
+ requestBody: jsonRequestBody({
731
+ type: "object",
732
+ properties: {
733
+ id: { type: "string" },
734
+ version: { type: "number" },
735
+ versionId: { type: "string" }
736
+ }
737
+ }),
738
+ responses: jsonResponse(ref(valueSchemaName), `Reverted ${name} global value`)
739
+ } };
740
+ const versioningOpts = state.options?.versioning;
741
+ if (versioningOpts && typeof versioningOpts === "object" && versioningOpts.workflow) paths[`${prefix}/transition`] = { post: {
742
+ operationId: `global_${name}_transition`,
743
+ summary: `Transition ${name} global workflow stage`,
744
+ tags: [tag],
745
+ requestBody: jsonRequestBody({
746
+ type: "object",
747
+ required: ["stage"],
748
+ properties: { stage: {
749
+ type: "string",
750
+ description: "Target workflow stage"
751
+ } }
752
+ }),
753
+ responses: jsonResponse(ref(valueSchemaName), `Transitioned ${name} global value`)
754
+ } };
603
755
  }
604
756
  return {
605
757
  paths,
@@ -614,10 +766,10 @@ function buildGlobalSchemaFromFieldDefinitions(fieldDefinitions) {
614
766
  if (!fieldDefinitions || typeof fieldDefinitions !== "object") return null;
615
767
  const shape = {};
616
768
  for (const [fieldName, fieldDefinition] of Object.entries(fieldDefinitions)) {
617
- const toZodSchema = fieldDefinition.toZodSchema;
618
- if (typeof toZodSchema !== "function") continue;
769
+ const fd = fieldDefinition;
770
+ if (typeof fd.toZodSchema !== "function") continue;
619
771
  try {
620
- const schema = toZodSchema();
772
+ const schema = fd.toZodSchema();
621
773
  if (schema && typeof schema === "object" && "_def" in schema) shape[fieldName] = schema;
622
774
  } catch {}
623
775
  }
@@ -626,11 +778,18 @@ function buildGlobalSchemaFromFieldDefinitions(fieldDefinitions) {
626
778
  }
627
779
 
628
780
  //#endregion
629
- //#region src/generator/rpc.ts
781
+ //#region src/generator/routes.ts
782
+ /**
783
+ * Convert camelCase to kebab-case, matching the HTTP adapter's URL generation.
784
+ * e.g. "createBooking" → "create-booking"
785
+ */
786
+ function camelToKebab(str) {
787
+ return str.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
788
+ }
630
789
  /**
631
- * Flatten an RPC router tree into a list of { path, definition }.
790
+ * Flatten a routes tree into a list of { path, definition }.
632
791
  */
633
- function flattenRpcTree(tree, prefix = []) {
792
+ function flattenRoutesTree(tree, prefix = []) {
634
793
  const entries = [];
635
794
  for (const [key, value] of Object.entries(tree)) {
636
795
  const segments = [...prefix, key];
@@ -639,46 +798,47 @@ function flattenRpcTree(tree, prefix = []) {
639
798
  segments,
640
799
  definition: value
641
800
  });
642
- else if (value && typeof value === "object") entries.push(...flattenRpcTree(value, segments));
801
+ else if (value && typeof value === "object") entries.push(...flattenRoutesTree(value, segments));
643
802
  }
644
803
  return entries;
645
804
  }
646
805
  /**
647
- * Generate OpenAPI paths for all RPC functions in the router tree.
806
+ * Generate OpenAPI paths for all routes in the routes tree.
648
807
  */
649
- function generateRpcPaths(rpc, config) {
808
+ function generateRoutePaths(routes, config) {
650
809
  const paths = {};
651
810
  const schemas = {};
652
811
  const tags = [];
653
- if (!rpc) return {
812
+ if (!routes) return {
654
813
  paths,
655
814
  schemas,
656
815
  tags
657
816
  };
658
- const basePath = config.basePath ?? "/cms";
659
- const entries = flattenRpcTree(rpc);
817
+ const basePath = config.basePath ?? "/";
818
+ const entries = flattenRoutesTree(routes);
660
819
  const tagSet = /* @__PURE__ */ new Set();
661
820
  for (const entry of entries) {
662
821
  const def = entry.definition;
663
822
  const isRaw = def.mode === "raw";
664
- const topLevel = entry.segments[0] ?? "rpc";
823
+ const method = (def.method ?? "post").toLowerCase();
824
+ const topLevel = entry.segments[0] ?? "routes";
665
825
  if (!tagSet.has(topLevel)) {
666
826
  tagSet.add(topLevel);
667
827
  tags.push({
668
- name: `RPC: ${topLevel}`,
669
- description: `RPC functions under ${topLevel}`
828
+ name: `Routes: ${topLevel}`,
829
+ description: `Routes under ${topLevel}`
670
830
  });
671
831
  }
672
- const operationId = `rpc_${entry.segments.join("_")}`;
673
- const routePath = `${basePath}/rpc/${entry.path}`;
832
+ const operationId = `route_${entry.segments.join("_")}`;
833
+ const routePath = `${basePath}/${entry.segments.map(camelToKebab).join("/")}`;
674
834
  const operation = {
675
835
  operationId,
676
836
  summary: entry.path,
677
- tags: [`RPC: ${topLevel}`],
837
+ tags: [`Routes: ${topLevel}`],
678
838
  responses: {}
679
839
  };
680
840
  if (isRaw) {
681
- operation.description = "Raw RPC function accepts any request body and returns a raw response.";
841
+ operation.description = "Raw route - accepts any request body and returns a raw response.";
682
842
  operation.requestBody = { content: {
683
843
  "application/json": { schema: {} },
684
844
  "application/octet-stream": { schema: {
@@ -706,10 +866,10 @@ function generateRpcPaths(rpc, config) {
706
866
  schemas[schemaName] = zodToJsonSchema(def.outputSchema);
707
867
  outputSchema = { $ref: `#/components/schemas/${schemaName}` };
708
868
  }
709
- operation.requestBody = jsonRequestBody(inputSchema, "RPC function input");
710
- operation.responses = jsonResponse(outputSchema, "RPC function output");
869
+ operation.requestBody = jsonRequestBody(inputSchema, "Route input");
870
+ operation.responses = jsonResponse(outputSchema, "Route output");
711
871
  }
712
- paths[routePath] = { post: operation };
872
+ paths[routePath] = { [method]: operation };
713
873
  }
714
874
  return {
715
875
  paths,
@@ -728,7 +888,7 @@ function generateSearchPaths(config) {
728
888
  paths: {},
729
889
  tags: []
730
890
  };
731
- const basePath = config.basePath ?? "/cms";
891
+ const basePath = config.basePath ?? "/";
732
892
  const tag = "Search";
733
893
  const paths = {};
734
894
  paths[`${basePath}/search`] = { post: {
@@ -815,24 +975,24 @@ function generateSearchPaths(config) {
815
975
  //#endregion
816
976
  //#region src/generator/index.ts
817
977
  /**
818
- * Generate a complete OpenAPI 3.1 spec from a Questpie CMS instance and optional RPC router.
978
+ * Generate a complete OpenAPI 3.1 spec from a Questpie app instance and optional routes tree.
819
979
  */
820
- function generateOpenApiSpec$1(cms, rpc, config = {}) {
980
+ function generateOpenApiSpec$1(app, routes, config = {}) {
821
981
  const allPaths = {};
822
982
  const allSchemas = { ...baseComponentSchemas() };
823
983
  const allTags = [];
824
- const collections = generateCollectionPaths(cms, config);
984
+ const collections = generateCollectionPaths(app, config);
825
985
  Object.assign(allPaths, collections.paths);
826
986
  Object.assign(allSchemas, collections.schemas);
827
987
  allTags.push(...collections.tags);
828
- const globals = generateGlobalPaths(cms, config);
988
+ const globals = generateGlobalPaths(app, config);
829
989
  Object.assign(allPaths, globals.paths);
830
990
  Object.assign(allSchemas, globals.schemas);
831
991
  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);
992
+ const routeResult = generateRoutePaths(routes, config);
993
+ Object.assign(allPaths, routeResult.paths);
994
+ Object.assign(allSchemas, routeResult.schemas);
995
+ allTags.push(...routeResult.tags);
836
996
  const auth = generateAuthPaths(config);
837
997
  Object.assign(allPaths, auth.paths);
838
998
  allTags.push(...auth.tags);
@@ -842,7 +1002,7 @@ function generateOpenApiSpec$1(cms, rpc, config = {}) {
842
1002
  return {
843
1003
  openapi: "3.1.0",
844
1004
  info: {
845
- title: config.info?.title ?? "QUESTPIE CMS API",
1005
+ title: config.info?.title ?? "QUESTPIE API",
846
1006
  version: config.info?.version ?? "1.0.0",
847
1007
  description: config.info?.description
848
1008
  },
@@ -906,64 +1066,145 @@ function escapeAttr(str) {
906
1066
  //#endregion
907
1067
  //#region src/server.ts
908
1068
  /**
909
- * Generate a complete OpenAPI 3.1 spec from a CMS instance and optional RPC router.
1069
+ * Identity factory for `config/openapi.ts` provides type inference.
1070
+ *
1071
+ * @example
1072
+ * ```ts
1073
+ * // config/openapi.ts
1074
+ * import { openApiConfig } from "@questpie/openapi";
1075
+ *
1076
+ * export default openApiConfig({
1077
+ * info: { title: "My API", version: "1.0.0" },
1078
+ * scalar: { theme: "purple" },
1079
+ * });
1080
+ * ```
910
1081
  */
911
- function generateOpenApiSpec(cms, rpc, config) {
912
- return generateOpenApiSpec$1(cms, rpc, config);
1082
+ function openApiConfig(config) {
1083
+ return config;
913
1084
  }
914
1085
  /**
915
- * Create request handlers for serving the OpenAPI spec and Scalar UI.
1086
+ * Generate a complete OpenAPI 3.1 spec from a QUESTPIE app instance.
1087
+ * Routes are read from `app.config.routes` automatically.
1088
+ *
1089
+ * @example
1090
+ * ```ts
1091
+ * import { generateOpenApiSpec } from "@questpie/openapi";
1092
+ *
1093
+ * const spec = generateOpenApiSpec(app, {
1094
+ * info: { title: "My API", version: "1.0.0" },
1095
+ * });
1096
+ * ```
916
1097
  */
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
- };
1098
+ function generateOpenApiSpec(app, config) {
1099
+ const routes = app.config?.routes;
1100
+ return generateOpenApiSpec$1(app, routes, config);
1101
+ }
1102
+ const specCache = /* @__PURE__ */ new WeakMap();
1103
+ function getCachedSpec(app, config) {
1104
+ const appObj = app;
1105
+ let cached = specCache.get(appObj);
1106
+ if (!cached) {
1107
+ const spec = generateOpenApiSpec(app, config);
1108
+ const json = JSON.stringify(spec);
1109
+ let hash = 0;
1110
+ for (let i = 0; i < json.length; i++) hash = (hash << 5) - hash + json.charCodeAt(i) | 0;
1111
+ cached = {
1112
+ json,
1113
+ etag: `"oapi-${(hash >>> 0).toString(36)}"`
1114
+ };
1115
+ specCache.set(appObj, cached);
1116
+ }
1117
+ return cached;
925
1118
  }
926
1119
  /**
927
- * Wrap a CMS fetch handler to add OpenAPI spec and Scalar UI routes.
1120
+ * Read OpenAPI config from `app.state.config.openapi` (set via config/openapi.ts).
1121
+ * Falls back to explicit config parameter if provided.
1122
+ */
1123
+ function resolveOpenApiConfig(app, explicitConfig) {
1124
+ if (explicitConfig) return explicitConfig;
1125
+ return (app.state?.config)?.openapi;
1126
+ }
1127
+ /**
1128
+ * Create a route that serves the OpenAPI 3.1 JSON spec.
1129
+ * Place in your `routes/` directory for automatic discovery.
928
1130
  *
929
- * Intercepts requests to `{basePath}/{specPath}` and `{basePath}/{docsPath}`
930
- * before they reach the CMS handler. Everything else passes through unchanged.
1131
+ * Spec is lazy-generated on first request and cached with ETag.
1132
+ * Config is read from `config/openapi.ts` at request time, or from
1133
+ * explicit parameter if provided.
931
1134
  *
932
1135
  * @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
1136
+ * ```ts title="routes/openapi-spec.ts"
1137
+ * import { openApiRoute } from "@questpie/openapi";
1138
+ *
1139
+ * export default openApiRoute();
947
1140
  * ```
948
1141
  */
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
- };
1142
+ function openApiRoute(config) {
1143
+ return route().get().raw().handler(async (ctx) => {
1144
+ const app = ctx.app;
1145
+ const { json, etag } = getCachedSpec(app, resolveOpenApiConfig(app, config));
1146
+ if (ctx.request.headers.get("if-none-match") === etag) return new Response(null, { status: 304 });
1147
+ return new Response(json, { headers: {
1148
+ "Content-Type": "application/json",
1149
+ "Cache-Control": "public, max-age=3600, stale-while-revalidate=43200",
1150
+ ETag: etag,
1151
+ "Access-Control-Allow-Origin": "*"
1152
+ } });
1153
+ });
963
1154
  }
964
- function normalizeBasePath(path) {
965
- return path.endsWith("/") ? path.slice(0, -1) : path;
1155
+ /**
1156
+ * Create a route that serves the Scalar interactive API docs.
1157
+ * Place in your `routes/` directory for automatic discovery.
1158
+ *
1159
+ * @example
1160
+ * ```ts title="routes/docs.ts"
1161
+ * import { docsRoute } from "@questpie/openapi";
1162
+ *
1163
+ * export default docsRoute();
1164
+ * ```
1165
+ */
1166
+ function docsRoute(config) {
1167
+ const { scalar: scalarConfig, ...openApiConfig$1 } = config ?? {};
1168
+ return route().get().raw().handler(async (ctx) => {
1169
+ const app = ctx.app;
1170
+ const resolved = resolveOpenApiConfig(app, openApiConfig$1);
1171
+ const scalarOpts = scalarConfig ?? resolved?.scalar;
1172
+ return serveScalarUI(generateOpenApiSpec(app, resolved), scalarOpts);
1173
+ });
966
1174
  }
1175
+ /**
1176
+ * OpenAPI module — registers spec + docs routes.
1177
+ *
1178
+ * Routes are served as:
1179
+ * - `GET /api/openapi.json` — OpenAPI 3.1 JSON spec
1180
+ * - `GET /api/docs` — Scalar interactive API reference
1181
+ *
1182
+ * Configure via `config/openapi.ts`:
1183
+ * ```ts
1184
+ * import { openApiConfig } from "@questpie/openapi";
1185
+ * export default openApiConfig({ info: { title: "My API", version: "1.0.0" } });
1186
+ * ```
1187
+ *
1188
+ * Or pass config directly for backward compatibility:
1189
+ * ```ts
1190
+ * openApiModule({ info: { title: "My API" } })
1191
+ * ```
1192
+ *
1193
+ * @example Static (reads config from config/openapi.ts):
1194
+ * ```ts title="questpie/server/modules.ts"
1195
+ * import { openApiModule } from "@questpie/openapi";
1196
+ * export default [openApiModule] as const;
1197
+ * ```
1198
+ */
1199
+ const openApiModule = module({
1200
+ name: "questpie-openapi",
1201
+ plugin: openApiPlugin(),
1202
+ routes: {
1203
+ "openapi.json": openApiRoute(),
1204
+ docs: docsRoute()
1205
+ }
1206
+ });
1207
+ var server_default = openApiModule;
967
1208
 
968
1209
  //#endregion
969
- export { createOpenApiHandlers, generateOpenApiSpec, withOpenApi };
1210
+ export { server_default as default, docsRoute, generateOpenApiSpec, openApiConfig, openApiModule, openApiRoute };
package/package.json CHANGED
@@ -1,21 +1,49 @@
1
1
  {
2
2
  "name": "@questpie/openapi",
3
- "version": "2.0.0",
4
- "type": "module",
3
+ "version": "3.0.1",
5
4
  "repository": {
6
5
  "type": "git",
7
- "url": "https://github.com/questpie/questpie-cms.git",
6
+ "url": "https://github.com/questpie/questpie.git",
8
7
  "directory": "packages/openapi"
9
8
  },
10
9
  "files": [
11
10
  "dist"
12
11
  ],
12
+ "type": "module",
13
+ "main": "./dist/server.mjs",
14
+ "module": "./dist/server.mjs",
15
+ "types": "./dist/server.d.mts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/server.d.mts",
19
+ "default": "./dist/server.mjs"
20
+ },
21
+ "./plugin": {
22
+ "types": "./dist/plugin.d.mts",
23
+ "default": "./dist/plugin.mjs"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/server.d.mts",
32
+ "default": "./dist/server.mjs"
33
+ },
34
+ "./plugin": {
35
+ "types": "./dist/plugin.d.mts",
36
+ "default": "./dist/plugin.mjs"
37
+ },
38
+ "./package.json": "./package.json"
39
+ }
40
+ },
13
41
  "scripts": {
14
42
  "build": "tsdown",
15
43
  "check-types": "tsc --noEmit"
16
44
  },
17
45
  "dependencies": {
18
- "questpie": "workspace:*",
46
+ "questpie": "^3.0.1",
19
47
  "zod": "^4.2.1"
20
48
  },
21
49
  "devDependencies": {
@@ -23,20 +51,6 @@
23
51
  "tsdown": "^0.18.3"
24
52
  },
25
53
  "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"
54
+ "questpie": "^3.0.0"
55
+ }
42
56
  }