@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 +67 -0
- package/dist/server.d.mts +142 -0
- package/dist/server.mjs +969 -0
- package/package.json +42 -0
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 };
|
package/dist/server.mjs
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
901
|
+
}
|
|
902
|
+
function escapeAttr(str) {
|
|
903
|
+
return str.replace(/&/g, "&").replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|