yaxa-svelte 2.1.0 → 2.3.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 +73 -14
- package/dist/server/endpoint/endpoint.test.d.ts +1 -0
- package/dist/server/endpoint/endpoint.test.js +364 -0
- package/dist/server/endpoint/guards.d.ts +57 -0
- package/dist/server/endpoint/guards.js +153 -0
- package/dist/server/endpoint/index.d.ts +14 -0
- package/dist/server/endpoint/index.js +242 -0
- package/dist/server/endpoint/problem.d.ts +26 -0
- package/dist/server/endpoint/problem.js +62 -0
- package/dist/server/endpoint/types.d.ts +151 -0
- package/dist/server/endpoint/types.js +1 -0
- package/dist/server/endpoint/validate.d.ts +21 -0
- package/dist/server/endpoint/validate.js +128 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/openapi/generator.d.ts +5 -0
- package/dist/server/openapi/generator.js +317 -0
- package/dist/server/openapi/handler.d.ts +7 -0
- package/dist/server/openapi/handler.js +117 -0
- package/dist/server/openapi/index.d.ts +5 -0
- package/dist/server/openapi/index.js +4 -0
- package/dist/server/openapi/openapi.test.d.ts +1 -0
- package/dist/server/openapi/openapi.test.js +219 -0
- package/dist/server/openapi/registry.d.ts +35 -0
- package/dist/server/openapi/registry.js +51 -0
- package/dist/server/openapi/schema.d.ts +4 -0
- package/dist/server/openapi/schema.js +59 -0
- package/dist/server/openapi/types.d.ts +127 -0
- package/dist/server/openapi/types.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { convertToJsonSchema } from './schema';
|
|
2
|
+
import { globalApiRegistry } from './registry';
|
|
3
|
+
/**
|
|
4
|
+
* Standard RFC 7807 Problem Details Schema for OpenAPI Components.
|
|
5
|
+
*/
|
|
6
|
+
const PROBLEM_DETAILS_SCHEMA = {
|
|
7
|
+
type: 'object',
|
|
8
|
+
properties: {
|
|
9
|
+
type: { type: 'string', format: 'uri', example: 'https://yaxa.dev/errors/validation-failed' },
|
|
10
|
+
title: { type: 'string', example: 'Unprocessable Entity' },
|
|
11
|
+
status: { type: 'integer', example: 422 },
|
|
12
|
+
detail: { type: 'string', example: 'Validation failed for 1 parameter(s).' },
|
|
13
|
+
instance: { type: 'string' },
|
|
14
|
+
invalidParams: {
|
|
15
|
+
type: 'array',
|
|
16
|
+
items: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
name: { type: 'string', example: 'body.email' },
|
|
20
|
+
reason: { type: 'string', example: 'Invalid email address' }
|
|
21
|
+
},
|
|
22
|
+
required: ['name', 'reason']
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
required: ['type', 'title', 'status']
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Extracts parameter list from a converted JSON schema.
|
|
30
|
+
*/
|
|
31
|
+
function extractParametersFromSchema(jsonSchema, location) {
|
|
32
|
+
if (!jsonSchema || typeof jsonSchema !== 'object' || !jsonSchema.properties) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const requiredList = Array.isArray(jsonSchema.required) ? jsonSchema.required : [];
|
|
36
|
+
const parameters = [];
|
|
37
|
+
for (const [key, propSchema] of Object.entries(jsonSchema.properties)) {
|
|
38
|
+
parameters.push({
|
|
39
|
+
name: key,
|
|
40
|
+
in: location,
|
|
41
|
+
required: location === 'path' ? true : requiredList.includes(key),
|
|
42
|
+
description: propSchema?.description,
|
|
43
|
+
deprecated: propSchema?.deprecated,
|
|
44
|
+
schema: propSchema
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return parameters;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolves YaxaEndpointConfig from an endpoint function or config object.
|
|
51
|
+
*/
|
|
52
|
+
function resolveConfig(endpoint) {
|
|
53
|
+
if (!endpoint)
|
|
54
|
+
return null;
|
|
55
|
+
if (typeof endpoint === 'function' && '_yaxaConfig' in endpoint) {
|
|
56
|
+
return endpoint._yaxaConfig;
|
|
57
|
+
}
|
|
58
|
+
if (typeof endpoint === 'object' && 'handle' in endpoint) {
|
|
59
|
+
return endpoint;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Generates an OpenAPI 3.1.0 document from registered yaxaEndpoints.
|
|
65
|
+
*/
|
|
66
|
+
export function generateOpenApiSpec(options = {}) {
|
|
67
|
+
const title = options.title || 'Yaxa SvelteKit API';
|
|
68
|
+
const version = options.version || '1.0.0';
|
|
69
|
+
const description = options.description || 'Auto-generated OpenAPI 3.1 contract via yaxa-svelte.';
|
|
70
|
+
const registry = options.registry || globalApiRegistry;
|
|
71
|
+
const registeredEndpoints = registry.getEndpoints();
|
|
72
|
+
// Merge manual options.endpoints if provided
|
|
73
|
+
const mergedEndpoints = {
|
|
74
|
+
...registeredEndpoints
|
|
75
|
+
};
|
|
76
|
+
if (options.endpoints) {
|
|
77
|
+
for (const [rawPath, methods] of Object.entries(options.endpoints)) {
|
|
78
|
+
const normalizedPath = registry.normalizePath(rawPath);
|
|
79
|
+
if (!mergedEndpoints[normalizedPath]) {
|
|
80
|
+
mergedEndpoints[normalizedPath] = {};
|
|
81
|
+
}
|
|
82
|
+
for (const [method, handler] of Object.entries(methods)) {
|
|
83
|
+
mergedEndpoints[normalizedPath][method.toUpperCase()] = handler;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const paths = {};
|
|
88
|
+
const securitySchemes = {};
|
|
89
|
+
let usesSecurity = false;
|
|
90
|
+
for (const [path, methods] of Object.entries(mergedEndpoints)) {
|
|
91
|
+
paths[path] = {};
|
|
92
|
+
for (const [method, endpoint] of Object.entries(methods)) {
|
|
93
|
+
const config = resolveConfig(endpoint);
|
|
94
|
+
const methodLower = method.toLowerCase();
|
|
95
|
+
if (!config) {
|
|
96
|
+
// Fallback for raw RequestHandler
|
|
97
|
+
paths[path][methodLower] = {
|
|
98
|
+
summary: `${method.toUpperCase()} ${path}`,
|
|
99
|
+
responses: {
|
|
100
|
+
'200': { description: 'Successful response' }
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const parameters = [];
|
|
106
|
+
// 1. Path Params
|
|
107
|
+
if (config.schema?.params) {
|
|
108
|
+
const paramsSchema = convertToJsonSchema(config.schema.params);
|
|
109
|
+
parameters.push(...extractParametersFromSchema(paramsSchema, 'path'));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// Detect {param} placeholders in path and add basic string parameters
|
|
113
|
+
const pathParamMatches = path.match(/\{(\w+)\}/g);
|
|
114
|
+
if (pathParamMatches) {
|
|
115
|
+
for (const match of pathParamMatches) {
|
|
116
|
+
const paramName = match.slice(1, -1);
|
|
117
|
+
parameters.push({
|
|
118
|
+
name: paramName,
|
|
119
|
+
in: 'path',
|
|
120
|
+
required: true,
|
|
121
|
+
schema: { type: 'string' }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// 2. Query Params
|
|
127
|
+
if (config.schema?.query) {
|
|
128
|
+
const querySchema = convertToJsonSchema(config.schema.query);
|
|
129
|
+
parameters.push(...extractParametersFromSchema(querySchema, 'query'));
|
|
130
|
+
}
|
|
131
|
+
// 3. Header Params
|
|
132
|
+
if (config.schema?.headers) {
|
|
133
|
+
const headersSchema = convertToJsonSchema(config.schema.headers);
|
|
134
|
+
parameters.push(...extractParametersFromSchema(headersSchema, 'header'));
|
|
135
|
+
}
|
|
136
|
+
// 4. Request Body
|
|
137
|
+
let requestBody = undefined;
|
|
138
|
+
if (config.schema?.body && method !== 'GET' && method !== 'HEAD') {
|
|
139
|
+
const bodySchema = convertToJsonSchema(config.schema.body);
|
|
140
|
+
requestBody = {
|
|
141
|
+
required: true,
|
|
142
|
+
content: {
|
|
143
|
+
'application/json': {
|
|
144
|
+
schema: bodySchema
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// 5. Responses
|
|
150
|
+
const responses = {};
|
|
151
|
+
const successStatus = config.status
|
|
152
|
+
? String(config.status)
|
|
153
|
+
: method === 'POST'
|
|
154
|
+
? '201'
|
|
155
|
+
: '200';
|
|
156
|
+
if (config.responses) {
|
|
157
|
+
for (const [statusCode, resp] of Object.entries(config.responses)) {
|
|
158
|
+
responses[String(statusCode)] = {
|
|
159
|
+
description: resp.description,
|
|
160
|
+
content: resp.schema
|
|
161
|
+
? {
|
|
162
|
+
'application/json': {
|
|
163
|
+
schema: convertToJsonSchema(resp.schema)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
: undefined
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!responses[successStatus]) {
|
|
171
|
+
responses[successStatus] = {
|
|
172
|
+
description: successStatus === '201'
|
|
173
|
+
? 'Created'
|
|
174
|
+
: successStatus === '204'
|
|
175
|
+
? 'No Content'
|
|
176
|
+
: 'Successful Operation',
|
|
177
|
+
content: successStatus !== '204'
|
|
178
|
+
? {
|
|
179
|
+
'application/json': {
|
|
180
|
+
schema: { type: 'object' }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
: undefined
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// 6. Guard Reflection (Security & Error Status Codes)
|
|
187
|
+
let hasAuthGuard = false;
|
|
188
|
+
let hasRoleGuard = false;
|
|
189
|
+
let roleGuardRoles = [];
|
|
190
|
+
let hasRateLimitGuard = false;
|
|
191
|
+
let rateLimitRetryAfter = 60;
|
|
192
|
+
if (config.guards && config.guards.length > 0) {
|
|
193
|
+
for (const guard of config.guards) {
|
|
194
|
+
const meta = guard._yaxaGuard;
|
|
195
|
+
if (meta) {
|
|
196
|
+
if (meta.type === 'auth') {
|
|
197
|
+
hasAuthGuard = true;
|
|
198
|
+
}
|
|
199
|
+
else if (meta.type === 'role') {
|
|
200
|
+
hasRoleGuard = true;
|
|
201
|
+
if (meta.roles)
|
|
202
|
+
roleGuardRoles = meta.roles;
|
|
203
|
+
}
|
|
204
|
+
else if (meta.type === 'rateLimit') {
|
|
205
|
+
hasRateLimitGuard = true;
|
|
206
|
+
if (meta.options?.windowSeconds) {
|
|
207
|
+
rateLimitRetryAfter = meta.options.windowSeconds;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const operationSecurity = [];
|
|
214
|
+
if (hasAuthGuard || hasRoleGuard) {
|
|
215
|
+
usesSecurity = true;
|
|
216
|
+
securitySchemes['BearerAuth'] = {
|
|
217
|
+
type: 'http',
|
|
218
|
+
scheme: 'bearer',
|
|
219
|
+
bearerFormat: 'JWT',
|
|
220
|
+
description: 'Standard Bearer Token Header (`Authorization: Bearer <token>`)'
|
|
221
|
+
};
|
|
222
|
+
securitySchemes['CookieAuth'] = {
|
|
223
|
+
type: 'apiKey',
|
|
224
|
+
in: 'cookie',
|
|
225
|
+
name: 'better-auth.session_token',
|
|
226
|
+
description: 'Session cookie via Better-Auth'
|
|
227
|
+
};
|
|
228
|
+
operationSecurity.push({ BearerAuth: [] }, { CookieAuth: [] });
|
|
229
|
+
responses['401'] = {
|
|
230
|
+
description: 'Unauthorized (RFC 7807 Problem Details)',
|
|
231
|
+
content: {
|
|
232
|
+
'application/problem+json': {
|
|
233
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (hasRoleGuard) {
|
|
239
|
+
responses['403'] = {
|
|
240
|
+
description: `Forbidden — Insufficient role permissions. Requires: [${roleGuardRoles.join(', ')}]`,
|
|
241
|
+
content: {
|
|
242
|
+
'application/problem+json': {
|
|
243
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// 7. Schema Validation Error (422)
|
|
249
|
+
if (config.schema &&
|
|
250
|
+
(config.schema.body || config.schema.query || config.schema.params || config.schema.headers)) {
|
|
251
|
+
responses['422'] = {
|
|
252
|
+
description: 'Unprocessable Entity — Schema validation failed (RFC 7807)',
|
|
253
|
+
content: {
|
|
254
|
+
'application/problem+json': {
|
|
255
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// 8. Rate Limit Error (429)
|
|
261
|
+
if (hasRateLimitGuard) {
|
|
262
|
+
responses['429'] = {
|
|
263
|
+
description: 'Too Many Requests — Rate limit exceeded (RFC 7807)',
|
|
264
|
+
headers: {
|
|
265
|
+
'Retry-After': {
|
|
266
|
+
description: 'Seconds to wait before retrying',
|
|
267
|
+
schema: { type: 'integer', example: rateLimitRetryAfter }
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
content: {
|
|
271
|
+
'application/problem+json': {
|
|
272
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// 9. Standard 500 Sanitized Error
|
|
278
|
+
responses['500'] = {
|
|
279
|
+
description: 'Internal Server Error — Sanitized exception (RFC 7807)',
|
|
280
|
+
content: {
|
|
281
|
+
'application/problem+json': {
|
|
282
|
+
schema: { $ref: '#/components/schemas/ProblemDetails' }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const operation = {
|
|
287
|
+
summary: config.summary || `${method.toUpperCase()} ${path}`,
|
|
288
|
+
description: config.description,
|
|
289
|
+
operationId: config.operationId ||
|
|
290
|
+
`${methodLower}_${path.replace(/[/{}]+/g, '_').replace(/^_+|_+$/g, '')}`,
|
|
291
|
+
tags: config.tags || (path.split('/')[2] ? [path.split('/')[2]] : ['Default']),
|
|
292
|
+
deprecated: config.deprecated,
|
|
293
|
+
parameters: parameters.length > 0 ? parameters : undefined,
|
|
294
|
+
requestBody,
|
|
295
|
+
responses,
|
|
296
|
+
security: operationSecurity.length > 0 ? operationSecurity : undefined
|
|
297
|
+
};
|
|
298
|
+
paths[path][methodLower] = operation;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
openapi: '3.1.0',
|
|
303
|
+
info: {
|
|
304
|
+
title,
|
|
305
|
+
version,
|
|
306
|
+
description
|
|
307
|
+
},
|
|
308
|
+
servers: options.servers || [{ url: '/', description: 'Current Environment' }],
|
|
309
|
+
paths,
|
|
310
|
+
components: {
|
|
311
|
+
schemas: {
|
|
312
|
+
ProblemDetails: PROBLEM_DETAILS_SCHEMA
|
|
313
|
+
},
|
|
314
|
+
securitySchemes: usesSecurity ? securitySchemes : undefined
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit';
|
|
2
|
+
import type { ApiDocsOptions } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a turnkey SvelteKit RequestHandler that generates an interactive OpenAPI 3.1 documentation
|
|
5
|
+
* page (Scalar, Swagger UI, or Redoc) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createApiDocsHandler(options?: ApiDocsOptions): RequestHandler;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { generateOpenApiSpec } from './generator';
|
|
2
|
+
function renderScalarHtml(specJson, title) {
|
|
3
|
+
return `<!doctype html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<title>${title}</title>
|
|
9
|
+
<style>
|
|
10
|
+
body { margin: 0; background-color: #0f172a; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<script id="api-reference" data-configuration='{"darkMode":true,"layout":"modern"}' type="application/json">
|
|
15
|
+
${specJson}
|
|
16
|
+
</script>
|
|
17
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>`;
|
|
20
|
+
}
|
|
21
|
+
function renderSwaggerHtml(specJson, title) {
|
|
22
|
+
return `<!DOCTYPE html>
|
|
23
|
+
<html lang="en">
|
|
24
|
+
<head>
|
|
25
|
+
<meta charset="utf-8" />
|
|
26
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
27
|
+
<title>${title}</title>
|
|
28
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
29
|
+
<style>
|
|
30
|
+
body { margin: 0; background: #fafafa; }
|
|
31
|
+
</style>
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<div id="swagger-ui"></div>
|
|
35
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
36
|
+
<script>
|
|
37
|
+
window.ui = SwaggerUIBundle({
|
|
38
|
+
spec: ${specJson},
|
|
39
|
+
dom_id: '#swagger-ui',
|
|
40
|
+
deepLinking: true,
|
|
41
|
+
presets: [
|
|
42
|
+
SwaggerUIBundle.presets.apis,
|
|
43
|
+
SwaggerUIBundle.SwaggerUIStandalonePreset
|
|
44
|
+
],
|
|
45
|
+
layout: "BaseLayout"
|
|
46
|
+
});
|
|
47
|
+
</script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>`;
|
|
50
|
+
}
|
|
51
|
+
function renderRedocHtml(specJson, title) {
|
|
52
|
+
return `<!DOCTYPE html>
|
|
53
|
+
<html lang="en">
|
|
54
|
+
<head>
|
|
55
|
+
<meta charset="utf-8" />
|
|
56
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
57
|
+
<title>${title}</title>
|
|
58
|
+
<style>
|
|
59
|
+
body { margin: 0; padding: 0; }
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<div id="redoc-container"></div>
|
|
64
|
+
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
|
65
|
+
<script>
|
|
66
|
+
Redoc.init(${specJson}, {
|
|
67
|
+
scrollYOffset: 50,
|
|
68
|
+
theme: {
|
|
69
|
+
colors: { primary: { main: '#3b82f6' } }
|
|
70
|
+
}
|
|
71
|
+
}, document.getElementById('redoc-container'));
|
|
72
|
+
</script>
|
|
73
|
+
</body>
|
|
74
|
+
</html>`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Creates a turnkey SvelteKit RequestHandler that generates an interactive OpenAPI 3.1 documentation
|
|
78
|
+
* page (Scalar, Swagger UI, or Redoc) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
|
|
79
|
+
*/
|
|
80
|
+
export function createApiDocsHandler(options = {}) {
|
|
81
|
+
const theme = options.theme || 'scalar';
|
|
82
|
+
return async (event) => {
|
|
83
|
+
const { url, request } = event;
|
|
84
|
+
const format = url.searchParams.get('format');
|
|
85
|
+
const acceptHeader = request.headers.get('accept') || '';
|
|
86
|
+
const spec = generateOpenApiSpec(options);
|
|
87
|
+
// If caller requests JSON via query param, accept header, or path ending in .json
|
|
88
|
+
if (format === 'json' ||
|
|
89
|
+
acceptHeader.includes('application/json') ||
|
|
90
|
+
url.pathname.endsWith('/openapi.json')) {
|
|
91
|
+
return Response.json(spec, {
|
|
92
|
+
headers: {
|
|
93
|
+
'content-type': 'application/json; charset=utf-8',
|
|
94
|
+
'cache-control': 'public, max-age=60'
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// Render Interactive HTML Docs
|
|
99
|
+
const specJson = JSON.stringify(spec);
|
|
100
|
+
let html;
|
|
101
|
+
if (theme === 'swagger') {
|
|
102
|
+
html = renderSwaggerHtml(specJson, spec.info.title);
|
|
103
|
+
}
|
|
104
|
+
else if (theme === 'redoc') {
|
|
105
|
+
html = renderRedocHtml(specJson, spec.info.title);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
html = renderScalarHtml(specJson, spec.info.title);
|
|
109
|
+
}
|
|
110
|
+
return new Response(html, {
|
|
111
|
+
headers: {
|
|
112
|
+
'content-type': 'text/html; charset=utf-8',
|
|
113
|
+
'cache-control': 'public, max-age=300'
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { generateOpenApiSpec } from './generator';
|
|
2
|
+
export { createApiDocsHandler } from './handler';
|
|
3
|
+
export { ApiRegistry, createApiRegistry, globalApiRegistry } from './registry';
|
|
4
|
+
export { convertToJsonSchema } from './schema';
|
|
5
|
+
export type { OpenApiSpec31, OpenApiOperation, OpenApiParameter, OpenApiRequestBody, OpenApiResponse, GenerateOpenApiOptions, ApiDocsOptions, ApiDocsTheme, EndpointLike, EndpointMap } from './types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createMockRequestEvent } from '../../testing/index.js';
|
|
4
|
+
import { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApiRegistry, convertToJsonSchema } from './index.js';
|
|
5
|
+
import { yaxaEndpoint } from '../endpoint/index.js';
|
|
6
|
+
import { requireAuth, requireRole, rateLimit } from '../endpoint/guards.js';
|
|
7
|
+
describe('OpenAPI 3.1 Schema Engine', () => {
|
|
8
|
+
it('converts Zod schema into compliant JSON Schema', () => {
|
|
9
|
+
const schema = z.object({
|
|
10
|
+
title: z.string().min(3),
|
|
11
|
+
count: z.number().optional(),
|
|
12
|
+
role: z.enum(['admin', 'member'])
|
|
13
|
+
});
|
|
14
|
+
const jsonSchema = convertToJsonSchema(schema);
|
|
15
|
+
expect(jsonSchema.type).toBe('object');
|
|
16
|
+
expect(jsonSchema.properties.title.type).toBe('string');
|
|
17
|
+
expect(jsonSchema.properties.count.type).toBe('number');
|
|
18
|
+
expect(jsonSchema.required).toContain('title');
|
|
19
|
+
expect(jsonSchema.required).toContain('role');
|
|
20
|
+
expect(jsonSchema.required).not.toContain('count');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe('API Route Registry', () => {
|
|
24
|
+
let registry;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
registry = createApiRegistry();
|
|
27
|
+
globalApiRegistry.clear();
|
|
28
|
+
});
|
|
29
|
+
it('normalizes SvelteKit bracket path params to OpenAPI curly braces', () => {
|
|
30
|
+
expect(registry.normalizePath('/api/items/[id]')).toBe('/api/items/{id}');
|
|
31
|
+
expect(registry.normalizePath('/api/orgs/[orgSlug]/users/[userId]')).toBe('/api/orgs/{orgSlug}/users/{userId}');
|
|
32
|
+
expect(registry.normalizePath('/api/files/[...rest]')).toBe('/api/files/{rest}');
|
|
33
|
+
});
|
|
34
|
+
it('registers endpoints and retrieves them in normalized form', () => {
|
|
35
|
+
const getHandler = yaxaEndpoint({ handle: () => [] });
|
|
36
|
+
registry.register('/api/users/[id]', { GET: getHandler });
|
|
37
|
+
const endpoints = registry.getEndpoints();
|
|
38
|
+
expect(endpoints['/api/users/{id}']).toBeDefined();
|
|
39
|
+
expect(endpoints['/api/users/{id}']['GET']).toBe(getHandler);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('generateOpenApiSpec Engine', () => {
|
|
43
|
+
const ItemParamsSchema = z.object({
|
|
44
|
+
id: z.string()
|
|
45
|
+
});
|
|
46
|
+
const ItemQuerySchema = z.object({
|
|
47
|
+
filter: z.string().optional(),
|
|
48
|
+
limit: z.number().optional()
|
|
49
|
+
});
|
|
50
|
+
const CreateItemBodySchema = z.object({
|
|
51
|
+
name: z.string().min(2),
|
|
52
|
+
price: z.number().positive()
|
|
53
|
+
});
|
|
54
|
+
it('generates complete OpenAPI 3.1 document with parameters, body and tags', () => {
|
|
55
|
+
const registry = createApiRegistry();
|
|
56
|
+
const getEndpoint = yaxaEndpoint({
|
|
57
|
+
summary: 'Get Item By ID',
|
|
58
|
+
description: 'Retrieves an item record by its unique ID',
|
|
59
|
+
tags: ['Items'],
|
|
60
|
+
schema: {
|
|
61
|
+
params: ItemParamsSchema,
|
|
62
|
+
query: ItemQuerySchema
|
|
63
|
+
},
|
|
64
|
+
handle: ({ params }) => ({ id: params.id })
|
|
65
|
+
});
|
|
66
|
+
const postEndpoint = yaxaEndpoint({
|
|
67
|
+
summary: 'Create Item',
|
|
68
|
+
tags: ['Items'],
|
|
69
|
+
schema: {
|
|
70
|
+
body: CreateItemBodySchema
|
|
71
|
+
},
|
|
72
|
+
handle: ({ body }) => body
|
|
73
|
+
});
|
|
74
|
+
registry.register('/api/items/[id]', { GET: getEndpoint });
|
|
75
|
+
registry.register('/api/items', { POST: postEndpoint });
|
|
76
|
+
const spec = generateOpenApiSpec({
|
|
77
|
+
title: 'Inventory API',
|
|
78
|
+
version: '2.0.0',
|
|
79
|
+
description: 'Test Inventory System',
|
|
80
|
+
registry
|
|
81
|
+
});
|
|
82
|
+
expect(spec.openapi).toBe('3.1.0');
|
|
83
|
+
expect(spec.info.title).toBe('Inventory API');
|
|
84
|
+
expect(spec.info.version).toBe('2.0.0');
|
|
85
|
+
// Verify GET /api/items/{id}
|
|
86
|
+
const getOp = spec.paths['/api/items/{id}']['get'];
|
|
87
|
+
expect(getOp.summary).toBe('Get Item By ID');
|
|
88
|
+
expect(getOp.tags).toEqual(['Items']);
|
|
89
|
+
expect(getOp.parameters).toHaveLength(3); // id (path), filter (query), limit (query)
|
|
90
|
+
const idParam = getOp.parameters?.find((p) => p.name === 'id');
|
|
91
|
+
expect(idParam?.in).toBe('path');
|
|
92
|
+
expect(idParam?.required).toBe(true);
|
|
93
|
+
// Verify POST /api/items
|
|
94
|
+
const postOp = spec.paths['/api/items']['post'];
|
|
95
|
+
expect(postOp.summary).toBe('Create Item');
|
|
96
|
+
expect(postOp.requestBody).toBeDefined();
|
|
97
|
+
expect(postOp.requestBody?.content['application/json'].schema?.type).toBe('object');
|
|
98
|
+
expect(postOp.responses['201']).toBeDefined();
|
|
99
|
+
expect(postOp.responses['422']).toBeDefined(); // Schema validation failed
|
|
100
|
+
});
|
|
101
|
+
it('deduces security schemes and RFC 7807 responses from guards automatically', () => {
|
|
102
|
+
const registry = createApiRegistry();
|
|
103
|
+
const secureEndpoint = yaxaEndpoint({
|
|
104
|
+
guards: [
|
|
105
|
+
requireAuth(),
|
|
106
|
+
requireRole(['admin', 'billing_manager']),
|
|
107
|
+
rateLimit({ points: 15, windowSeconds: 60 })
|
|
108
|
+
],
|
|
109
|
+
schema: {
|
|
110
|
+
body: CreateItemBodySchema
|
|
111
|
+
},
|
|
112
|
+
handle: () => ({ ok: true })
|
|
113
|
+
});
|
|
114
|
+
registry.register('/api/secure-action', { POST: secureEndpoint });
|
|
115
|
+
const spec = generateOpenApiSpec({ registry });
|
|
116
|
+
const op = spec.paths['/api/secure-action']['post'];
|
|
117
|
+
// Security schemes attached
|
|
118
|
+
expect(spec.components?.securitySchemes?.BearerAuth).toBeDefined();
|
|
119
|
+
expect(spec.components?.securitySchemes?.CookieAuth).toBeDefined();
|
|
120
|
+
expect(op.security).toBeDefined();
|
|
121
|
+
// Guard error responses generated
|
|
122
|
+
expect(op.responses['401']).toBeDefined();
|
|
123
|
+
expect(op.responses['403']).toBeDefined();
|
|
124
|
+
expect(op.responses['403'].description).toContain('admin, billing_manager');
|
|
125
|
+
expect(op.responses['422']).toBeDefined();
|
|
126
|
+
expect(op.responses['429']).toBeDefined();
|
|
127
|
+
expect(op.responses['429'].headers?.['Retry-After']).toBeDefined();
|
|
128
|
+
expect(op.responses['500']).toBeDefined();
|
|
129
|
+
// Components schema ProblemDetails registered
|
|
130
|
+
expect(spec.components?.schemas?.ProblemDetails).toBeDefined();
|
|
131
|
+
});
|
|
132
|
+
it('supports self-registration via yaxaEndpoint path and method metadata', () => {
|
|
133
|
+
globalApiRegistry.clear();
|
|
134
|
+
yaxaEndpoint({
|
|
135
|
+
path: '/api/v1/auto-registered',
|
|
136
|
+
method: 'GET',
|
|
137
|
+
summary: 'Auto Registered Route',
|
|
138
|
+
handle: () => 'ok'
|
|
139
|
+
});
|
|
140
|
+
const spec = generateOpenApiSpec();
|
|
141
|
+
expect(spec.paths['/api/v1/auto-registered']?.['get']?.summary).toBe('Auto Registered Route');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe('createApiDocsHandler Endpoint', () => {
|
|
145
|
+
it('serves OpenAPI 3.1 JSON when format=json query parameter is present', async () => {
|
|
146
|
+
const handler = createApiDocsHandler({
|
|
147
|
+
title: 'Store API',
|
|
148
|
+
version: '1.2.3'
|
|
149
|
+
});
|
|
150
|
+
const event = createMockRequestEvent({
|
|
151
|
+
url: 'http://localhost:5173/api/docs?format=json'
|
|
152
|
+
});
|
|
153
|
+
const response = await handler(event);
|
|
154
|
+
expect(response.status).toBe(200);
|
|
155
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
156
|
+
const json = await response.json();
|
|
157
|
+
expect(json.openapi).toBe('3.1.0');
|
|
158
|
+
expect(json.info.title).toBe('Store API');
|
|
159
|
+
expect(json.info.version).toBe('1.2.3');
|
|
160
|
+
});
|
|
161
|
+
it('serves OpenAPI 3.1 JSON when Accept header requests application/json', async () => {
|
|
162
|
+
const handler = createApiDocsHandler();
|
|
163
|
+
const event = createMockRequestEvent({
|
|
164
|
+
url: 'http://localhost:5173/api/docs',
|
|
165
|
+
headers: { accept: 'application/json' }
|
|
166
|
+
});
|
|
167
|
+
const response = await handler(event);
|
|
168
|
+
expect(response.status).toBe(200);
|
|
169
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
170
|
+
});
|
|
171
|
+
it('renders Scalar HTML documentation by default', async () => {
|
|
172
|
+
const handler = createApiDocsHandler({
|
|
173
|
+
title: 'Scalar Docs Test',
|
|
174
|
+
theme: 'scalar'
|
|
175
|
+
});
|
|
176
|
+
const event = createMockRequestEvent({
|
|
177
|
+
url: 'http://localhost:5173/api/docs'
|
|
178
|
+
});
|
|
179
|
+
const response = await handler(event);
|
|
180
|
+
expect(response.status).toBe(200);
|
|
181
|
+
expect(response.headers.get('content-type')).toContain('text/html');
|
|
182
|
+
const html = await response.text();
|
|
183
|
+
expect(html).toContain('<title>Scalar Docs Test</title>');
|
|
184
|
+
expect(html).toContain('@scalar/api-reference');
|
|
185
|
+
expect(html).toContain('id="api-reference"');
|
|
186
|
+
});
|
|
187
|
+
it('renders Swagger UI HTML when theme is swagger', async () => {
|
|
188
|
+
const handler = createApiDocsHandler({
|
|
189
|
+
title: 'Swagger Docs Test',
|
|
190
|
+
theme: 'swagger'
|
|
191
|
+
});
|
|
192
|
+
const event = createMockRequestEvent({
|
|
193
|
+
url: 'http://localhost:5173/api/docs'
|
|
194
|
+
});
|
|
195
|
+
const response = await handler(event);
|
|
196
|
+
expect(response.status).toBe(200);
|
|
197
|
+
expect(response.headers.get('content-type')).toContain('text/html');
|
|
198
|
+
const html = await response.text();
|
|
199
|
+
expect(html).toContain('<title>Swagger Docs Test</title>');
|
|
200
|
+
expect(html).toContain('swagger-ui-dist');
|
|
201
|
+
expect(html).toContain('SwaggerUIBundle');
|
|
202
|
+
});
|
|
203
|
+
it('renders Redoc HTML when theme is redoc', async () => {
|
|
204
|
+
const handler = createApiDocsHandler({
|
|
205
|
+
title: 'Redoc Docs Test',
|
|
206
|
+
theme: 'redoc'
|
|
207
|
+
});
|
|
208
|
+
const event = createMockRequestEvent({
|
|
209
|
+
url: 'http://localhost:5173/api/docs'
|
|
210
|
+
});
|
|
211
|
+
const response = await handler(event);
|
|
212
|
+
expect(response.status).toBe(200);
|
|
213
|
+
expect(response.headers.get('content-type')).toContain('text/html');
|
|
214
|
+
const html = await response.text();
|
|
215
|
+
expect(html).toContain('<title>Redoc Docs Test</title>');
|
|
216
|
+
expect(html).toContain('redoc.standalone.js');
|
|
217
|
+
expect(html).toContain('redoc-container');
|
|
218
|
+
});
|
|
219
|
+
});
|