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.
@@ -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,4 @@
1
+ export { generateOpenApiSpec } from './generator';
2
+ export { createApiDocsHandler } from './handler';
3
+ export { ApiRegistry, createApiRegistry, globalApiRegistry } from './registry';
4
+ export { convertToJsonSchema } from './schema';
@@ -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
+ });